From 20eb4ef6bb6e72aa5341750e7612bf70e8c8b6ba Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Mon, 26 Aug 2024 16:11:59 -0400 Subject: [PATCH 1/4] 4. Contact Row - Add Excluded contact --- .../Appeal/List/ContactRow/ContactRow.tsx | 115 +++++++++++++----- 1 file changed, 87 insertions(+), 28 deletions(-) diff --git a/src/components/Tool/Appeal/List/ContactRow/ContactRow.tsx b/src/components/Tool/Appeal/List/ContactRow/ContactRow.tsx index 8779cf5b3..06742532c 100644 --- a/src/components/Tool/Appeal/List/ContactRow/ContactRow.tsx +++ b/src/components/Tool/Appeal/List/ContactRow/ContactRow.tsx @@ -1,12 +1,15 @@ -import React, { useMemo } from 'react'; +import React, { useEffect, useState } from 'react'; +import AddIcon from '@mui/icons-material/Add'; import { Box, Grid, Hidden, + IconButton, ListItemIcon, ListItemText, Typography, } from '@mui/material'; +import { styled } from '@mui/material/styles'; import clsx from 'clsx'; import { useTranslation } from 'react-i18next'; import { @@ -22,19 +25,23 @@ import { AppealsContext, AppealsType, } from '../../AppealsContext/AppealsContext'; +import { + DynamicAddExcludedContactModal, + preloadAddExcludedContactModal, +} from '../../Modals/AddExcludedContactModal/DynamicAddExcludedContactModal'; // When making changes in this file, also check to see if you don't need to make changes to the below file // src/components/Contacts/ContactRow/ContactRow.tsx -type ContactRow = Pick< - Contact, - | 'id' - | 'name' - | 'pledgeAmount' - | 'pledgeFrequency' - | 'pledgeCurrency' - | 'pledgeReceived' ->; +const ListButton = styled(ListItemButton)(() => ({ + '&:hover .contactRowActions': { + opacity: 1, + }, +})); +const ContactRowActions = styled(Box)(() => ({ + opacity: 0, + transition: 'opacity 0.3s', +})); interface Props { contact: ContactRow; useTopMargin?: boolean; @@ -49,6 +56,8 @@ export const ContactRow: React.FC = ({ contact, useTopMargin }) => { } = React.useContext(AppealsContext) as AppealsType; const { t } = useTranslation(); const locale = useLocale(); + const [addExcludedContactModalOpen, setAddExcludedContactModalOpen] = + useState(false); const handleContactClick = () => { onContactSelected(contact.id); @@ -62,22 +71,16 @@ export const ContactRow: React.FC = ({ contact, useTopMargin }) => { pledgeFrequency, } = contact; - const pledge = useMemo( - () => - pledgeAmount && pledgeCurrency - ? currencyFormat(pledgeAmount, pledgeCurrency, locale) - : pledgeAmount || currencyFormat(0, pledgeCurrency, locale), - [pledgeAmount, pledgeAmount, pledgeCurrency, locale], - ); - const frequency = useMemo( - () => - (pledgeFrequency && getLocalizedPledgeFrequency(t, pledgeFrequency)) || - '', - [pledgeFrequency], - ); + const handleAddExcludedContactToAppeal = () => { + setAddExcludedContactModalOpen(true); + }; + + + const isExcludedContact = appealStatus === AppealStatusEnum.Excluded; return ( - + = ({ contact, useTopMargin }) => { - + @@ -110,7 +117,28 @@ export const ContactRow: React.FC = ({ contact, useTopMargin }) => { } /> - + {isExcludedContact && ( + + + + + {/* TODO */} + Reason + + + + + )} + = ({ contact, useTopMargin }) => { {`${pledge} ${frequency}`} - + + + {appealStatus === AppealStatusEnum.Excluded && ( + { + event.stopPropagation(); + handleAddExcludedContactToAppeal(); + }} + onMouseOver={preloadAddExcludedContactModal} + > + + + )} + - + + + {addExcludedContactModalOpen && ( + setAddExcludedContactModalOpen(false)} + /> + )} + + ); }; From 73b822d1e2026ab95e81715461333dfad3eb2a24 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Mon, 26 Aug 2024 16:14:05 -0400 Subject: [PATCH 2/4] 4. Contact Row - Delete contact --- .../Appeal/List/ContactRow/ContactRow.tsx | 31 +++ .../Appeal/List/ContactsList/ContactsList.tsx | 4 + .../DeleteAppealContact.graphql | 24 ++ .../DeleteAppealContactModal.test.tsx | 221 ++++++++++++++++++ .../DeleteAppealContactModal.tsx | 148 ++++++++++++ .../DynamicDeleteAppealContactModal.tsx | 14 ++ 6 files changed, 442 insertions(+) create mode 100644 src/components/Tool/Appeal/Modals/DeleteAppealContact/DeleteAppealContact.graphql create mode 100644 src/components/Tool/Appeal/Modals/DeleteAppealContact/DeleteAppealContactModal.test.tsx create mode 100644 src/components/Tool/Appeal/Modals/DeleteAppealContact/DeleteAppealContactModal.tsx create mode 100644 src/components/Tool/Appeal/Modals/DeleteAppealContact/DynamicDeleteAppealContactModal.tsx diff --git a/src/components/Tool/Appeal/List/ContactRow/ContactRow.tsx b/src/components/Tool/Appeal/List/ContactRow/ContactRow.tsx index 06742532c..142a5fc6d 100644 --- a/src/components/Tool/Appeal/List/ContactRow/ContactRow.tsx +++ b/src/components/Tool/Appeal/List/ContactRow/ContactRow.tsx @@ -29,6 +29,10 @@ import { DynamicAddExcludedContactModal, preloadAddExcludedContactModal, } from '../../Modals/AddExcludedContactModal/DynamicAddExcludedContactModal'; +import { + DynamicDeleteAppealContactModal, + preloadDeleteAppealContactModal, +} from '../../Modals/DeleteAppealContact/DynamicDeleteAppealContactModal'; // When making changes in this file, also check to see if you don't need to make changes to the below file // src/components/Contacts/ContactRow/ContactRow.tsx @@ -58,6 +62,7 @@ export const ContactRow: React.FC = ({ contact, useTopMargin }) => { const locale = useLocale(); const [addExcludedContactModalOpen, setAddExcludedContactModalOpen] = useState(false); + const [removeContactModalOpen, setRemoveContactModalOpen] = useState(false); const handleContactClick = () => { onContactSelected(contact.id); @@ -71,6 +76,10 @@ export const ContactRow: React.FC = ({ contact, useTopMargin }) => { pledgeFrequency, } = contact; + const handleRemoveContactFromAppeal = () => { + setRemoveContactModalOpen(true); + }; + const handleAddExcludedContactToAppeal = () => { setAddExcludedContactModalOpen(true); }; @@ -158,6 +167,21 @@ export const ContactRow: React.FC = ({ contact, useTopMargin }) => { }} className="contactRowActions" > + {appealStatus === AppealStatusEnum.Asked && ( + <> + { + event.stopPropagation(); + handleRemoveContactFromAppeal(); + }} + onMouseOver={preloadDeleteAppealContactModal} + > + + + + )} {appealStatus === AppealStatusEnum.Excluded && ( = ({ contact, useTopMargin }) => { + {removeContactModalOpen && ( + setRemoveContactModalOpen(false)} + /> + )} + {addExcludedContactModalOpen && ( = ({ const { data, loading, fetchMore } = contactsQueryResult; + const appealStatus = + (activeFilters.appealStatus as AppealStatusEnum) ?? AppealStatusEnum.Asked; + useEffect(() => { if (!activeFilters.appealStatus) { return; @@ -143,6 +146,7 @@ export const ContactsList: React.FC = ({ )} diff --git a/src/components/Tool/Appeal/Modals/DeleteAppealContact/DeleteAppealContact.graphql b/src/components/Tool/Appeal/Modals/DeleteAppealContact/DeleteAppealContact.graphql new file mode 100644 index 000000000..2b71c22d7 --- /dev/null +++ b/src/components/Tool/Appeal/Modals/DeleteAppealContact/DeleteAppealContact.graphql @@ -0,0 +1,24 @@ +mutation DeleteAppealContact($input: AppealContactDeleteMutationInput!) { + deleteAppealContact(input: $input) { + id + } +} + +query AppealContacts($appealId: ID!, $after: String) { + appealContacts(appealId: $appealId, first: 50, after: $after) { + nodes { + ...AppealContactsInfo + } + pageInfo { + hasNextPage + endCursor + } + } +} + +fragment AppealContactsInfo on AppealContact { + id + contact { + id + } +} diff --git a/src/components/Tool/Appeal/Modals/DeleteAppealContact/DeleteAppealContactModal.test.tsx b/src/components/Tool/Appeal/Modals/DeleteAppealContact/DeleteAppealContactModal.test.tsx new file mode 100644 index 000000000..b0834543a --- /dev/null +++ b/src/components/Tool/Appeal/Modals/DeleteAppealContact/DeleteAppealContactModal.test.tsx @@ -0,0 +1,221 @@ +import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SnackbarProvider } from 'notistack'; +import { I18nextProvider } from 'react-i18next'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { AppealsWrapper } from 'pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper'; +import i18n from 'src/lib/i18n'; +import theme from 'src/theme'; +import { AppealsContext } from '../../AppealsContext/AppealsContext'; +import { AppealContactInfoFragment } from '../../AppealsContext/contacts.generated'; +import { DeleteAppealContactModal } from './DeleteAppealContactModal'; + +const mockEnqueue = jest.fn(); +jest.mock('notistack', () => ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...jest.requireActual('notistack'), + useSnackbar: () => { + return { + enqueueSnackbar: mockEnqueue, + }; + }, +})); + +const accountListId = 'abc'; +const appealId = 'appealId'; +const contactId = 'contact-1'; +const appealContactId = 'appealContactId'; +const router = { + query: { accountListId }, + isReady: true, +}; +const handleClose = jest.fn(); +const mutationSpy = jest.fn(); +const refetch = jest.fn(); + +interface ComponentsProps { + contact?: AppealContactInfoFragment; +} + +const Components = ({}: ComponentsProps) => { + let requestCount = 0; + return ( + + + + + + + { + let mutationResponse; + if (requestCount < 3) { + mutationResponse = { + appealContacts: { + nodes: [ + { + id: `id1${requestCount}`, + contact: { + id: `contactId1${requestCount}`, + }, + }, + { + id: `id2${requestCount}`, + contact: { + id: `contactId2${requestCount}`, + }, + }, + { + id: `id3${requestCount}`, + contact: { + id: `contactId3${requestCount}`, + }, + }, + ], + pageInfo: { + hasNextPage: true, + endCursor: 'endCursor', + }, + }, + }; + } else { + mutationResponse = { + appealContacts: { + nodes: [ + { + id: appealContactId, + contact: { + id: contactId, + }, + }, + { + id: `id5${requestCount}`, + contact: { + id: `contactId5${requestCount}`, + }, + }, + { + id: `id6${requestCount}`, + contact: { + id: `contactId6${requestCount}`, + }, + }, + ], + pageInfo: { + hasNextPage: false, + endCursor: 'endCursor', + }, + }, + }; + } + requestCount++; + return mutationResponse; + }, + }} + onCall={mutationSpy} + > + + + + + + + + + + + + + ); +}; + +describe('DeleteAppealContactModal', () => { + beforeEach(() => { + handleClose.mockClear(); + refetch.mockClear(); + }); + it('default', () => { + const { getByRole } = render(); + + expect( + getByRole('heading', { name: 'Remove Contact' }), + ).toBeInTheDocument(); + + expect(getByRole('button', { name: 'No' })).toBeInTheDocument(); + expect(getByRole('button', { name: 'Yes' })).toBeInTheDocument(); + }); + + it('should close modal', () => { + const { getByRole } = render(); + + expect(handleClose).toHaveBeenCalledTimes(0); + userEvent.click(getByRole('button', { name: 'No' })); + expect(handleClose).toHaveBeenCalledTimes(1); + + userEvent.click(getByRole('button', { name: 'Close' })); + expect(handleClose).toHaveBeenCalledTimes(2); + }); + + it('fetches all the appealContacts and matches up the correct ID to send to the API', async () => { + const { getByRole } = render(); + + expect(mutationSpy).toHaveBeenCalledTimes(0); + + await waitFor(() => { + expect(mutationSpy).toHaveBeenCalledTimes(8); + }); + + // Call AppealContacts 4 times getting all contacts. + expect(mutationSpy.mock.calls[0][0].operation.operationName).toEqual( + 'AppealContacts', + ); + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'AppealContacts', + ); + expect(mutationSpy.mock.calls[5][0].operation.operationName).toEqual( + 'AppealContacts', + ); + expect(mutationSpy.mock.calls[6][0].operation.operationName).toEqual( + 'AppealContacts', + ); + + userEvent.click(getByRole('button', { name: 'Yes' })); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Successfully remove contact from appeal.', + { + variant: 'success', + }, + ); + }); + + await waitFor(() => { + expect(mutationSpy).toHaveGraphqlOperation('DeleteAppealContact', { + input: { + id: 'appealContactId', + }, + }); + }); + expect(handleClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/Tool/Appeal/Modals/DeleteAppealContact/DeleteAppealContactModal.tsx b/src/components/Tool/Appeal/Modals/DeleteAppealContact/DeleteAppealContactModal.tsx new file mode 100644 index 000000000..97bb5bf02 --- /dev/null +++ b/src/components/Tool/Appeal/Modals/DeleteAppealContact/DeleteAppealContactModal.tsx @@ -0,0 +1,148 @@ +import React, { useEffect, useState } from 'react'; +import { + Box, + CircularProgress, + DialogActions, + DialogContent, + DialogContentText, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { useSnackbar } from 'notistack'; +import { useTranslation } from 'react-i18next'; +import { + CancelButton, + SubmitButton, +} from 'src/components/common/Modal/ActionButtons/ActionButtons'; +import Modal from 'src/components/common/Modal/Modal'; +import { + AppealsContext, + AppealsType, +} from '../../AppealsContext/AppealsContext'; +import { + AppealContactsInfoFragment, + useAppealContactsQuery, + useDeleteAppealContactMutation, +} from './DeleteAppealContact.generated'; + +const LoadingIndicator = styled(CircularProgress)(({ theme }) => ({ + margin: theme.spacing(0, 1, 0, 0), +})); + +export interface DeleteAppealContactModalProps { + contactId: string; + handleClose: () => void; +} + +export const DeleteAppealContactModal: React.FC< + DeleteAppealContactModalProps +> = ({ contactId, handleClose }) => { + const { t } = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); + const { appealId } = React.useContext(AppealsContext) as AppealsType; + const [deleteAppealContact] = useDeleteAppealContactMutation(); + const { data, fetchMore } = useAppealContactsQuery({ + variables: { + appealId: appealId ?? '', + }, + }); + const [mutating, setMutating] = useState(false); + const [loading, setLoading] = useState(false); + const [appealContactsIds, setAppealContactsIds] = useState< + AppealContactsInfoFragment[] + >([]); + + const loadAllAppealContacts = async () => { + let allContacts = data?.appealContacts.nodes ?? []; + let hasNextPage = true; + let cursor: string | null = null; + + while (hasNextPage) { + const response = await fetchMore({ + variables: { + appealId: appealId, + after: cursor, + first: 100, + }, + }); + + const newContacts = response.data.appealContacts.nodes; + allContacts = [...allContacts, ...newContacts]; + hasNextPage = response.data.appealContacts.pageInfo.hasNextPage; + cursor = response.data.appealContacts.pageInfo.endCursor ?? null; + } + + setAppealContactsIds(allContacts); + setLoading(false); + return allContacts; + }; + + useEffect(() => { + loadAllAppealContacts(); + }, []); + + const handleRemoveContact = async () => { + const appealContactId = appealContactsIds.find( + (appealContact) => appealContact.contact.id === contactId, + )?.id; + if (!appealContactId) { + enqueueSnackbar('Error while removing contact from appeal.', { + variant: 'error', + }); + return; + } + await deleteAppealContact({ + variables: { + input: { + id: appealContactId, + }, + }, + update: (cache) => { + cache.evict({ id: `Contact:${contactId}` }); + }, + onCompleted: () => { + enqueueSnackbar('Successfully remove contact from appeal.', { + variant: 'success', + }); + handleClose(); + }, + onError: () => { + enqueueSnackbar('Error while removing contact from appeal.', { + variant: 'error', + }); + }, + }); + setMutating(false); + }; + + const onClickDecline = () => { + handleClose(); + }; + + return ( + + + {mutating ? ( + + + + ) : ( + + {t('Are you sure you wish to remove this contact from the appeal?')} + + )} + + + + {t('No')} + + + {t('Yes')} + + + + ); +}; diff --git a/src/components/Tool/Appeal/Modals/DeleteAppealContact/DynamicDeleteAppealContactModal.tsx b/src/components/Tool/Appeal/Modals/DeleteAppealContact/DynamicDeleteAppealContactModal.tsx new file mode 100644 index 000000000..1b555144b --- /dev/null +++ b/src/components/Tool/Appeal/Modals/DeleteAppealContact/DynamicDeleteAppealContactModal.tsx @@ -0,0 +1,14 @@ +import dynamic from 'next/dynamic'; +import { DynamicModalPlaceholder } from 'src/components/DynamicPlaceholders/DynamicModalPlaceholder'; + +export const preloadDeleteAppealContactModal = () => + import( + /* webpackChunkName: "DeleteAppealContactModal" */ './DeleteAppealContactModal' + ).then(({ DeleteAppealContactModal }) => DeleteAppealContactModal); + +export const DynamicDeleteAppealContactModal = dynamic( + preloadDeleteAppealContactModal, + { + loading: DynamicModalPlaceholder, + }, +); From 5be8ca45de61dbfe6402f10826a4dabf2ee88357 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Mon, 26 Aug 2024 16:17:43 -0400 Subject: [PATCH 3/4] 4. Contact Row - Add/Edit/Delete pledge --- .../AddMenu/Items/AddDonation/AddDonation.tsx | 18 +- .../Items/AddDonation/StyledComponents.tsx | 17 + .../Appeal/AppealsContext/contacts.graphql | 41 +- .../Appeal/List/ContactRow/ContactRow.tsx | 330 +++++++++++--- .../DeletePledgeModal/DeletePledge.graphql | 7 + .../DeletePledgeModal.test.tsx | 134 ++++++ .../DeletePledgeModal/DeletePledgeModal.tsx | 94 ++++ .../DynamicDeletePledgeModal.tsx | 11 + .../Modals/PledgeModal/ContactPledge.graphql | 21 + .../Modals/PledgeModal/DynamicPledgeModal.tsx | 11 + .../Modals/PledgeModal/PledgeModal.test.tsx | 212 +++++++++ .../Appeal/Modals/PledgeModal/PledgeModal.tsx | 401 ++++++++++++++++++ 12 files changed, 1226 insertions(+), 71 deletions(-) create mode 100644 src/components/Layouts/Primary/TopBar/Items/AddMenu/Items/AddDonation/StyledComponents.tsx create mode 100644 src/components/Tool/Appeal/Modals/DeletePledgeModal/DeletePledge.graphql create mode 100644 src/components/Tool/Appeal/Modals/DeletePledgeModal/DeletePledgeModal.test.tsx create mode 100644 src/components/Tool/Appeal/Modals/DeletePledgeModal/DeletePledgeModal.tsx create mode 100644 src/components/Tool/Appeal/Modals/DeletePledgeModal/DynamicDeletePledgeModal.tsx create mode 100644 src/components/Tool/Appeal/Modals/PledgeModal/ContactPledge.graphql create mode 100644 src/components/Tool/Appeal/Modals/PledgeModal/DynamicPledgeModal.tsx create mode 100644 src/components/Tool/Appeal/Modals/PledgeModal/PledgeModal.test.tsx create mode 100644 src/components/Tool/Appeal/Modals/PledgeModal/PledgeModal.tsx diff --git a/src/components/Layouts/Primary/TopBar/Items/AddMenu/Items/AddDonation/AddDonation.tsx b/src/components/Layouts/Primary/TopBar/Items/AddMenu/Items/AddDonation/AddDonation.tsx index 844aa1d51..56cb0d03f 100644 --- a/src/components/Layouts/Primary/TopBar/Items/AddMenu/Items/AddDonation/AddDonation.tsx +++ b/src/components/Layouts/Primary/TopBar/Items/AddMenu/Items/AddDonation/AddDonation.tsx @@ -7,7 +7,6 @@ import { DialogContent, FormControl, FormHelperText, - FormLabel, Grid, MenuItem, Select, @@ -15,7 +14,6 @@ import { Theme, useMediaQuery, } from '@mui/material'; -import { styled } from '@mui/material/styles'; import { FastField, Field, FieldProps, Form, Formik } from 'formik'; import { DateTime } from 'luxon'; import { useSnackbar } from 'notistack'; @@ -35,6 +33,7 @@ import { useAddDonationMutation, useGetDonationModalQuery, } from './AddDonation.generated'; +import { FormTextField, LogFormLabel } from './StyledComponents'; interface AddDonationProps { accountListId: string; @@ -83,21 +82,6 @@ const donationSchema = yup.object({ type Attributes = yup.InferType; -const LogFormLabel = styled(FormLabel)(({ theme }) => ({ - margin: theme.spacing(1, 0), - fontWeight: 'bold', - color: theme.palette.primary.dark, - '& span': { - color: theme.palette.error.main, - }, -})); - -const FormTextField = styled(TextField)(({ theme }) => ({ - '& .MuiInputBase-root.Mui-disabled': { - backgroundColor: theme.palette.cruGrayLight.main, - }, -})); - export const AddDonation = ({ accountListId, handleClose, diff --git a/src/components/Layouts/Primary/TopBar/Items/AddMenu/Items/AddDonation/StyledComponents.tsx b/src/components/Layouts/Primary/TopBar/Items/AddMenu/Items/AddDonation/StyledComponents.tsx new file mode 100644 index 000000000..0dadfefca --- /dev/null +++ b/src/components/Layouts/Primary/TopBar/Items/AddMenu/Items/AddDonation/StyledComponents.tsx @@ -0,0 +1,17 @@ +import { FormLabel, TextField } from '@mui/material'; +import { styled } from '@mui/material/styles'; + +export const LogFormLabel = styled(FormLabel)(({ theme }) => ({ + margin: theme.spacing(1, 0), + fontWeight: 'bold', + color: theme.palette.primary.dark, + '& span': { + color: theme.palette.error.main, + }, +})); + +export const FormTextField = styled(TextField)(({ theme }) => ({ + '& .MuiInputBase-root.Mui-disabled': { + backgroundColor: theme.palette.cruGrayLight.main, + }, +})); diff --git a/src/components/Tool/Appeal/AppealsContext/contacts.graphql b/src/components/Tool/Appeal/AppealsContext/contacts.graphql index 9579da6b3..e91f5bc53 100644 --- a/src/components/Tool/Appeal/AppealsContext/contacts.graphql +++ b/src/components/Tool/Appeal/AppealsContext/contacts.graphql @@ -11,12 +11,7 @@ query Contacts( first: $first ) { nodes { - name - id - pledgeAmount - pledgeCurrency - pledgeFrequency - pledgeReceived + ...AppealContactInfo } pageInfo { hasNextPage @@ -25,3 +20,37 @@ query Contacts( totalCount } } + +fragment AppealContactInfo on Contact { + id + name + pledgeAmount + pledgeCurrency + pledgeFrequency + pledgeReceived + pledgeStartDate + pledges { + id + amount + amountCurrency + appeal { + id + } + expectedDate + status + } + donations { + nodes { + appeal { + id + } + id + donationDate + appealAmount { + amount + convertedAmount + convertedCurrency + } + } + } +} diff --git a/src/components/Tool/Appeal/List/ContactRow/ContactRow.tsx b/src/components/Tool/Appeal/List/ContactRow/ContactRow.tsx index 142a5fc6d..d90175ce4 100644 --- a/src/components/Tool/Appeal/List/ContactRow/ContactRow.tsx +++ b/src/components/Tool/Appeal/List/ContactRow/ContactRow.tsx @@ -1,5 +1,7 @@ import React, { useEffect, useState } from 'react'; import AddIcon from '@mui/icons-material/Add'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; import { Box, Grid, @@ -11,20 +13,25 @@ import { } from '@mui/material'; import { styled } from '@mui/material/styles'; import clsx from 'clsx'; +import { TFunction } from 'i18next'; +import { DateTime } from 'luxon'; import { useTranslation } from 'react-i18next'; import { ListItemButton, StyledCheckbox, } from 'src/components/Contacts/ContactRow/ContactRow'; import { preloadContactsRightPanel } from 'src/components/Contacts/ContactsRightPanel/DynamicContactsRightPanel'; -import { Contact } from 'src/graphql/types.generated'; +import { PledgeFrequencyEnum } from 'src/graphql/types.generated'; import { useLocale } from 'src/hooks/useLocale'; -import { currencyFormat } from 'src/lib/intlFormat'; +import { currencyFormat, dateFormat } from 'src/lib/intlFormat'; +import theme from 'src/theme'; import { getLocalizedPledgeFrequency } from 'src/utils/functions/getLocalizedPledgeFrequency'; import { + AppealStatusEnum, AppealsContext, AppealsType, } from '../../AppealsContext/AppealsContext'; +import { AppealContactInfoFragment } from '../../AppealsContext/contacts.generated'; import { DynamicAddExcludedContactModal, preloadAddExcludedContactModal, @@ -33,6 +40,14 @@ import { DynamicDeleteAppealContactModal, preloadDeleteAppealContactModal, } from '../../Modals/DeleteAppealContact/DynamicDeleteAppealContactModal'; +import { + DynamicDeletePledgeModal, + preloadDeletePledgeModal, +} from '../../Modals/DeletePledgeModal/DynamicDeletePledgeModal'; +import { + DynamicPledgeModal, + preloadPledgeModal, +} from '../../Modals/PledgeModal/DynamicPledgeModal'; // When making changes in this file, also check to see if you don't need to make changes to the below file // src/components/Contacts/ContactRow/ContactRow.tsx @@ -45,14 +60,64 @@ const ListButton = styled(ListItemButton)(() => ({ const ContactRowActions = styled(Box)(() => ({ opacity: 0, transition: 'opacity 0.3s', + display: 'flex', + alignItems: 'center', + paddingRight: theme.spacing(2), })); + +type FormatPledgeOrDonationProps = { + amount?: number | null; + currency?: string | null; + appealStatus: AppealStatusEnum; + dateOrFrequency?: PledgeFrequencyEnum | string | null; + locale: string; + t: TFunction; +}; + +const formatPledgeOrDonation = ({ + amount, + currency, + appealStatus, + dateOrFrequency, + locale, + t, +}: FormatPledgeOrDonationProps) => { + const pledgeOrDonationAmount = + amount && currency + ? currencyFormat(amount, currency, locale) + : amount || currencyFormat(0, currency, locale); + + const pledgeOrDonationDate = + appealStatus === AppealStatusEnum.Asked || + appealStatus === AppealStatusEnum.Excluded + ? (dateOrFrequency && + getLocalizedPledgeFrequency( + t, + dateOrFrequency as PledgeFrequencyEnum, + )) ?? + '' + : dateOrFrequency + ? dateFormat(DateTime.fromISO(dateOrFrequency), locale) + : null; + return { + amount: pledgeOrDonationAmount, + dateOrFrequency: pledgeOrDonationDate, + }; +}; + interface Props { - contact: ContactRow; + contact: AppealContactInfoFragment; + appealStatus: AppealStatusEnum; useTopMargin?: boolean; } -export const ContactRow: React.FC = ({ contact, useTopMargin }) => { +export const ContactRow: React.FC = ({ + contact, + appealStatus, + useTopMargin, +}) => { const { + appealId, isRowChecked: isChecked, contactDetailsOpen, setContactFocus: onContactSelected, @@ -60,9 +125,15 @@ export const ContactRow: React.FC = ({ contact, useTopMargin }) => { } = React.useContext(AppealsContext) as AppealsType; const { t } = useTranslation(); const locale = useLocale(); + const [createPledgeModalOpen, setPledgeModalOpen] = useState(false); + const [deletePledgeModalOpen, setDeletePledgeModalOpen] = useState(false); const [addExcludedContactModalOpen, setAddExcludedContactModalOpen] = useState(false); const [removeContactModalOpen, setRemoveContactModalOpen] = useState(false); + const [pledgeValues, setPledgeValues] = + useState(); + const [amountAndFrequency, setAmountAndFrequency] = useState(); + const [pledgeDonations, setPledgeDonations] = useState(null); const handleContactClick = () => { onContactSelected(contact.id); @@ -74,8 +145,103 @@ export const ContactRow: React.FC = ({ contact, useTopMargin }) => { pledgeAmount, pledgeCurrency, pledgeFrequency, + pledges, + donations, } = contact; + useEffect(() => { + if ( + appealStatus === AppealStatusEnum.Asked || + appealStatus === AppealStatusEnum.Excluded + ) { + const { amount, dateOrFrequency } = formatPledgeOrDonation({ + amount: pledgeAmount, + currency: pledgeCurrency, + appealStatus, + dateOrFrequency: pledgeFrequency, + locale, + t, + }); + setAmountAndFrequency(`${amount} ${dateOrFrequency}`); + setPledgeValues(undefined); + } else if ( + appealStatus === AppealStatusEnum.NotReceived || + appealStatus === AppealStatusEnum.ReceivedNotProcessed + ) { + const appealPledge = pledges?.find( + (pledge) => pledge.appeal.id === appealId, + ); + + if (appealPledge) { + const { amount, dateOrFrequency } = formatPledgeOrDonation({ + amount: appealPledge?.amount, + currency: appealPledge.amountCurrency, + appealStatus, + dateOrFrequency: appealPledge.expectedDate, + locale, + t, + }); + + setPledgeValues(appealPledge); + setAmountAndFrequency(`${amount} (${dateOrFrequency})`); + } else { + setAmountAndFrequency(`${currencyFormat(0, 'USD', locale)}`); + } + } else if (appealStatus === AppealStatusEnum.Processed) { + const appealPledge = pledges?.find( + (pledge) => pledge.appeal.id === appealId, + ); + + if (appealPledge) { + const { amount } = formatPledgeOrDonation({ + amount: appealPledge?.amount, + currency: appealPledge.amountCurrency, + appealStatus, + locale, + t, + }); + setPledgeValues(appealPledge); + setAmountAndFrequency(`${amount}`); + } else { + setAmountAndFrequency(`${currencyFormat(0, 'USD', locale)}`); + } + + // Currently we grab all the donations and filter them by the appeal id + // We need a query that allows us to filter by the appeal id + // Maybe buy the backend team some donuts and ask them to add a filter to the donations query + const appealDonations = donations.nodes.filter( + (donation) => donation?.appeal?.id === appealId, + ); + + const givenDonations = appealDonations.map((donation) => { + const amount = donation?.appealAmount?.amount; + const currency = donation?.appealAmount?.convertedCurrency; + const donationAmount = currencyFormat( + amount && currency ? amount : 0, + currency, + locale, + ); + + const donationDate = dateFormat( + DateTime.fromISO(donation.donationDate), + locale, + ); + + return `(${donationAmount}) (${donationDate})`; + }); + + setPledgeDonations(givenDonations); + } + }, [appealStatus, contact, locale]); + + const handleCreatePledge = () => { + setPledgeModalOpen(true); + }; + + const handleEditContact = () => { + setPledgeModalOpen(true); + }; + const handleRemoveContactFromAppeal = () => { setRemoveContactModalOpen(true); }; @@ -84,48 +250,51 @@ export const ContactRow: React.FC = ({ contact, useTopMargin }) => { setAddExcludedContactModalOpen(true); }; + const handleRemovePledge = () => { + setDeletePledgeModalOpen(true); + }; const isExcludedContact = appealStatus === AppealStatusEnum.Excluded; return ( <> - - - event.stopPropagation()} - onChange={() => onContactCheckToggle(contact.id)} - value={isChecked} - /> - - - + focusRipple + onClick={handleContactClick} + onMouseEnter={preloadContactsRightPanel} + className={clsx({ + 'top-margin': useTopMargin, + checked: isChecked(contactId), + })} + data-testid="rowButton" + > + + + event.stopPropagation()} + onChange={() => onContactCheckToggle(contact.id)} + value={isChecked} + /> + + + - - - {name} - - - } - /> - + + + {name} + + + } + /> + {isExcludedContact && ( @@ -148,15 +317,27 @@ export const ContactRow: React.FC = ({ contact, useTopMargin }) => { display={'flex'} style={{ justifyContent: 'space-between' }} > - - - - {`${pledge} ${frequency}`} - + + + {appealStatus !== AppealStatusEnum.Processed && ( + {amountAndFrequency} + )} + + {appealStatus === AppealStatusEnum.Processed && + pledgeDonations?.map((donation, idx) => ( + + {amountAndFrequency} {donation} + + ))} + = ({ contact, useTopMargin }) => { > {appealStatus === AppealStatusEnum.Asked && ( <> + { + event.stopPropagation(); + handleCreatePledge(); + }} + onMouseOver={preloadPledgeModal} + > + + = ({ contact, useTopMargin }) => { )} + {(appealStatus === AppealStatusEnum.NotReceived || + appealStatus === AppealStatusEnum.Processed || + appealStatus === AppealStatusEnum.ReceivedNotProcessed) && ( + <> + { + event.stopPropagation(); + handleEditContact(); + }} + onMouseOver={preloadPledgeModal} + > + + + { + event.stopPropagation(); + handleRemovePledge(); + }} + onMouseOver={preloadDeletePledgeModal} + > + + + + )} {appealStatus === AppealStatusEnum.Excluded && ( = ({ contact, useTopMargin }) => { )} + - - - - + + + {removeContactModalOpen && ( @@ -217,6 +437,20 @@ export const ContactRow: React.FC = ({ contact, useTopMargin }) => { /> )} + {createPledgeModalOpen && ( + setPledgeModalOpen(false)} + pledge={pledgeValues} + /> + )} + + {deletePledgeModalOpen && pledgeValues && ( + setDeletePledgeModalOpen(false)} + /> + )} ); }; diff --git a/src/components/Tool/Appeal/Modals/DeletePledgeModal/DeletePledge.graphql b/src/components/Tool/Appeal/Modals/DeletePledgeModal/DeletePledge.graphql new file mode 100644 index 000000000..50555ac1c --- /dev/null +++ b/src/components/Tool/Appeal/Modals/DeletePledgeModal/DeletePledge.graphql @@ -0,0 +1,7 @@ +mutation DeleteAccountListPledge( + $input: AccountListPledgeDeleteMutationInput! +) { + deleteAccountListPledge(input: $input) { + id + } +} diff --git a/src/components/Tool/Appeal/Modals/DeletePledgeModal/DeletePledgeModal.test.tsx b/src/components/Tool/Appeal/Modals/DeletePledgeModal/DeletePledgeModal.test.tsx new file mode 100644 index 000000000..6be030e32 --- /dev/null +++ b/src/components/Tool/Appeal/Modals/DeletePledgeModal/DeletePledgeModal.test.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SnackbarProvider } from 'notistack'; +import { I18nextProvider } from 'react-i18next'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { AppealsWrapper } from 'pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper'; +import { PledgeStatusEnum } from 'src/graphql/types.generated'; +import i18n from 'src/lib/i18n'; +import theme from 'src/theme'; +import { AppealsContext } from '../../AppealsContext/AppealsContext'; +import { DeletePledgeModal } from './DeletePledgeModal'; + +const accountListId = 'abc'; +const appealId = 'appealId'; +const router = { + query: { accountListId }, + isReady: true, +}; +const handleClose = jest.fn(); +const mutationSpy = jest.fn(); +const refetch = jest.fn(); +const mockEnqueue = jest.fn(); +jest.mock('notistack', () => ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...jest.requireActual('notistack'), + useSnackbar: () => { + return { + enqueueSnackbar: mockEnqueue, + }; + }, +})); + +const pledge = { + id: 'pledge-1', + appeal: { + id: appealId, + }, + amount: 100, + amountCurrency: 'USD', + expectedDate: '2020-01-01', + status: PledgeStatusEnum.NotReceived, +}; + +const Components = () => ( + + + + + + + + + + + + + + + + + +); + +describe('DeletePledgeModal', () => { + beforeEach(() => { + handleClose.mockClear(); + refetch.mockClear(); + }); + it('default', async () => { + const { getByRole } = render(); + + expect( + getByRole('heading', { name: 'Remove Commitment' }), + ).toBeInTheDocument(); + + expect(getByRole('button', { name: 'No' })).toBeInTheDocument(); + expect(getByRole('button', { name: 'Yes' })).toBeInTheDocument(); + }); + + it('should close modal', () => { + const { getByRole } = render(); + + expect(handleClose).toHaveBeenCalledTimes(0); + userEvent.click(getByRole('button', { name: 'No' })); + expect(handleClose).toHaveBeenCalledTimes(1); + + userEvent.click(getByRole('button', { name: 'Close' })); + expect(handleClose).toHaveBeenCalledTimes(2); + }); + + it('should remove commitment', async () => { + const { getByRole } = render(); + + expect(mutationSpy).toHaveBeenCalledTimes(0); + + userEvent.click(getByRole('button', { name: 'Yes' })); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Successfully removed commitment from appeal', + { + variant: 'success', + }, + ); + }); + + await waitFor(() => { + expect(mutationSpy).toHaveGraphqlOperation('DeleteAccountListPledge', { + input: { + id: pledge.id, + }, + }); + }); + + expect(refetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/Tool/Appeal/Modals/DeletePledgeModal/DeletePledgeModal.tsx b/src/components/Tool/Appeal/Modals/DeletePledgeModal/DeletePledgeModal.tsx new file mode 100644 index 000000000..3740eaae0 --- /dev/null +++ b/src/components/Tool/Appeal/Modals/DeletePledgeModal/DeletePledgeModal.tsx @@ -0,0 +1,94 @@ +import React, { useContext } from 'react'; +import { + Box, + CircularProgress, + DialogActions, + DialogContent, + DialogContentText, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { useSnackbar } from 'notistack'; +import { useTranslation } from 'react-i18next'; +import { + CancelButton, + SubmitButton, +} from 'src/components/common/Modal/ActionButtons/ActionButtons'; +import Modal from 'src/components/common/Modal/Modal'; +import { + AppealsContext, + AppealsType, +} from '../../AppealsContext/AppealsContext'; +import { AppealContactInfoFragment } from '../../AppealsContext/contacts.generated'; +import { useDeleteAccountListPledgeMutation } from './DeletePledge.generated'; + +const LoadingIndicator = styled(CircularProgress)(({ theme }) => ({ + margin: theme.spacing(0, 1, 0, 0), +})); + +interface DeletePledgeModalProps { + handleClose: () => void; + pledge: AppealContactInfoFragment['pledges'][0]; +} + +export const DeletePledgeModal: React.FC = ({ + pledge, + handleClose, +}) => { + const { t } = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); + const [deleteAccountListPledge, { loading }] = + useDeleteAccountListPledgeMutation(); + const { contactsQueryResult } = useContext(AppealsContext) as AppealsType; + + const handleConfirm = async () => { + await deleteAccountListPledge({ + variables: { + input: { + id: pledge.id, + }, + }, + update: () => { + contactsQueryResult.refetch(); + }, + onCompleted: () => { + enqueueSnackbar(t('Successfully removed commitment from appeal'), { + variant: 'success', + }); + handleClose(); + }, + onError: () => { + enqueueSnackbar(t('Unable to remove commitment from appeal'), { + variant: 'error', + }); + }, + }); + }; + + return ( + + + {loading ? ( + + + + ) : ( + + {t('Are you sure you wish to remove this commitment?')} + + )} + + + + {t('No')} + + + {t('Yes')} + + + + ); +}; diff --git a/src/components/Tool/Appeal/Modals/DeletePledgeModal/DynamicDeletePledgeModal.tsx b/src/components/Tool/Appeal/Modals/DeletePledgeModal/DynamicDeletePledgeModal.tsx new file mode 100644 index 000000000..840795ce1 --- /dev/null +++ b/src/components/Tool/Appeal/Modals/DeletePledgeModal/DynamicDeletePledgeModal.tsx @@ -0,0 +1,11 @@ +import dynamic from 'next/dynamic'; +import { DynamicModalPlaceholder } from 'src/components/DynamicPlaceholders/DynamicModalPlaceholder'; + +export const preloadDeletePledgeModal = () => + import( + /* webpackChunkName: "DeletePledgeModal" */ './DeletePledgeModal' + ).then(({ DeletePledgeModal }) => DeletePledgeModal); + +export const DynamicDeletePledgeModal = dynamic(preloadDeletePledgeModal, { + loading: DynamicModalPlaceholder, +}); diff --git a/src/components/Tool/Appeal/Modals/PledgeModal/ContactPledge.graphql b/src/components/Tool/Appeal/Modals/PledgeModal/ContactPledge.graphql new file mode 100644 index 000000000..dfdae86fe --- /dev/null +++ b/src/components/Tool/Appeal/Modals/PledgeModal/ContactPledge.graphql @@ -0,0 +1,21 @@ +mutation CreateAccountListPledge( + $input: AccountListPledgeCreateMutationInput! +) { + createAccountListPledge(input: $input) { + pledge { + id + amount + amountCurrency + } + } +} + +mutation UpdateAccountListPledge( + $input: AccountListPledgeUpdateMutationInput! +) { + updateAccountListPledge(input: $input) { + pledge { + id + } + } +} diff --git a/src/components/Tool/Appeal/Modals/PledgeModal/DynamicPledgeModal.tsx b/src/components/Tool/Appeal/Modals/PledgeModal/DynamicPledgeModal.tsx new file mode 100644 index 000000000..199d746c9 --- /dev/null +++ b/src/components/Tool/Appeal/Modals/PledgeModal/DynamicPledgeModal.tsx @@ -0,0 +1,11 @@ +import dynamic from 'next/dynamic'; +import { DynamicModalPlaceholder } from 'src/components/DynamicPlaceholders/DynamicModalPlaceholder'; + +export const preloadPledgeModal = () => + import(/* webpackChunkName: "PledgeModal" */ './PledgeModal').then( + ({ PledgeModal }) => PledgeModal, + ); + +export const DynamicPledgeModal = dynamic(preloadPledgeModal, { + loading: DynamicModalPlaceholder, +}); diff --git a/src/components/Tool/Appeal/Modals/PledgeModal/PledgeModal.test.tsx b/src/components/Tool/Appeal/Modals/PledgeModal/PledgeModal.test.tsx new file mode 100644 index 000000000..be5533c95 --- /dev/null +++ b/src/components/Tool/Appeal/Modals/PledgeModal/PledgeModal.test.tsx @@ -0,0 +1,212 @@ +import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SnackbarProvider } from 'notistack'; +import { I18nextProvider } from 'react-i18next'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { AppealsWrapper } from 'pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper'; +import { PledgeStatusEnum } from 'src/graphql/types.generated'; +import i18n from 'src/lib/i18n'; +import theme from 'src/theme'; +import { AppealsContext } from '../../AppealsContext/AppealsContext'; +import { AppealContactInfoFragment } from '../../AppealsContext/contacts.generated'; +import { defaultContact } from '../../List/ContactRow/ContactRowMock'; +import { PledgeModal } from './PledgeModal'; + +const accountListId = 'abc'; +const appealId = 'appealId'; +const router = { + query: { accountListId }, + isReady: true, +}; +const handleClose = jest.fn(); +const mutationSpy = jest.fn(); +const refetch = jest.fn(); + +interface ComponentsProps { + pledge?: AppealContactInfoFragment['pledges'][0]; +} + +const Components = ({ pledge = undefined }: ComponentsProps) => ( + + + + + + + + + + + + + + + + + +); + +describe('PledgeModal', () => { + beforeEach(() => { + handleClose.mockClear(); + refetch.mockClear(); + }); + it('default', async () => { + const { getByRole, getByText, findByRole } = render(); + + expect( + getByRole('heading', { name: 'Add Commitment' }), + ).toBeInTheDocument(); + + expect(getByText('You are adding a commitment for')).toBeInTheDocument(); + expect(getByText(defaultContact.name)).toBeInTheDocument(); + + expect(getByRole('textbox', { name: 'Amount' })).toBeInTheDocument(); + expect(getByText('Currency')).toBeInTheDocument(); + + expect( + await findByRole('combobox', { name: 'Currency' }), + ).toBeInTheDocument(); + + expect(getByRole('textbox', { name: 'Expected Date' })).toBeInTheDocument(); + expect(getByRole('combobox', { name: 'Status' })).toBeInTheDocument(); + + expect(getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + expect(getByRole('button', { name: 'Save' })).toBeInTheDocument(); + }); + + it('should close modal', () => { + const { getByRole } = render(); + + expect(handleClose).toHaveBeenCalledTimes(0); + userEvent.click(getByRole('button', { name: 'Cancel' })); + expect(handleClose).toHaveBeenCalledTimes(1); + + userEvent.click(getByRole('button', { name: 'Close' })); + expect(handleClose).toHaveBeenCalledTimes(2); + }); + + it('Add commitment', async () => { + const { getByRole, getByText, findByText, queryByText } = render( + , + ); + + expect(mutationSpy).toHaveBeenCalledTimes(0); + + const amountInput = getByRole('textbox', { name: 'Amount' }); + userEvent.clear(amountInput); + userEvent.type(amountInput, '0'); + userEvent.tab(); + await waitFor(() => + expect( + getByText(/must use a positive number for amount/i), + ).toBeInTheDocument(), + ); + + userEvent.clear(amountInput); + userEvent.tab(); + expect(await findByText('Amount is required')).toBeInTheDocument(); + + userEvent.type(amountInput, '100'); + await waitFor(() => { + expect( + queryByText(/must use a positive number for amount/i), + ).not.toBeInTheDocument(); + expect(queryByText('Amount is required')).not.toBeInTheDocument(); + }); + + userEvent.click(getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect(mutationSpy).toHaveGraphqlOperation('CreateAccountListPledge', { + input: { + accountListId, + attributes: { + appealId: appealId, + contactId: defaultContact.id, + amount: 100, + amountCurrency: 'USD', + expectedDate: '2020-01-01', + status: PledgeStatusEnum.NotReceived, + }, + }, + }); + }); + + expect(refetch).toHaveBeenCalledTimes(1); + }); + + it('Edit commitment', async () => { + const pledgeId = 'pledge-1'; + const { getByRole, findByText } = render( + , + ); + + expect(mutationSpy).toHaveBeenCalledTimes(0); + + expect( + getByRole('heading', { name: 'Edit Commitment' }), + ).toBeInTheDocument(); + + const amountInput = getByRole('textbox', { name: 'Amount' }); + + expect(amountInput).toHaveValue('444'); + expect(getByRole('textbox', { name: 'Expected Date' })).toHaveValue( + '08/08/2024', + ); + + expect(await findByText('Received')).toBeInTheDocument(); + + userEvent.clear(amountInput); + userEvent.type(amountInput, '500'); + + userEvent.click(getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect(mutationSpy).toHaveGraphqlOperation('UpdateAccountListPledge', { + input: { + pledgeId, + attributes: { + id: pledgeId, + appealId: appealId, + contactId: defaultContact.id, + amount: 500, + amountCurrency: 'USD', + expectedDate: '2024-08-08', + status: PledgeStatusEnum.ReceivedNotProcessed, + }, + }, + }); + }); + + expect(refetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/Tool/Appeal/Modals/PledgeModal/PledgeModal.tsx b/src/components/Tool/Appeal/Modals/PledgeModal/PledgeModal.tsx new file mode 100644 index 000000000..9d8a113a7 --- /dev/null +++ b/src/components/Tool/Appeal/Modals/PledgeModal/PledgeModal.tsx @@ -0,0 +1,401 @@ +import React, { ReactElement } from 'react'; +import { + Alert, + DialogActions, + DialogContent, + FormControl, + FormHelperText, + Grid, + MenuItem, + Select, + Theme, + Typography, +} from '@mui/material'; +import { Box, useMediaQuery } from '@mui/system'; +import { FastField, FieldProps, Formik } from 'formik'; +import { DateTime } from 'luxon'; +import { useSnackbar } from 'notistack'; +import { useTranslation } from 'react-i18next'; +import * as yup from 'yup'; +import { useApiConstants } from 'src/components/Constants/UseApiConstants'; +import { + FormTextField, + LogFormLabel, +} from 'src/components/Layouts/Primary/TopBar/Items/AddMenu/Items/AddDonation/StyledComponents'; +import { CustomDateField } from 'src/components/common/DateTimePickers/CustomDateField'; +import { + CancelButton, + SubmitButton, +} from 'src/components/common/Modal/ActionButtons/ActionButtons'; +import Modal from 'src/components/common/Modal/Modal'; +import { PledgeStatusEnum } from 'src/graphql/types.generated'; +import { requiredDateTime } from 'src/lib/formikHelpers'; +import { getPledgeCurrencyOptions } from 'src/lib/getCurrencyOptions'; +import i18n from 'src/lib/i18n'; +import { + AppealsContext, + AppealsType, +} from '../../AppealsContext/AppealsContext'; +import { AppealContactInfoFragment } from '../../AppealsContext/contacts.generated'; +import { + useCreateAccountListPledgeMutation, + useUpdateAccountListPledgeMutation, +} from './ContactPledge.generated'; + +interface PledgeModalProps { + handleClose: () => void; + contact: AppealContactInfoFragment; + pledge?: AppealContactInfoFragment['pledges'][0]; +} + +const CreatePledgeSchema = yup.object({ + amount: yup + .number() + .typeError(i18n.t('Amount must be a valid number')) + .required(i18n.t('Amount is required')) + .test( + i18n.t('Is amount in valid currency format?'), + i18n.t('Amount must be in valid currency format'), + (amount) => /\$?[0-9][0-9.,]*/.test(amount as unknown as string), + ) + .test( + i18n.t('Is positive?'), + i18n.t('Must use a positive number for amount'), + (value) => parseFloat(value as unknown as string) > 0, + ), + amountCurrency: yup.string().required(i18n.t('Currency is required')), + expectedDate: requiredDateTime(i18n.t('Expected Date is required')), + status: yup.string().required(i18n.t('Status is required')), +}); + +type Attributes = yup.InferType; + +export const PledgeModal: React.FC = ({ + contact, + pledge, + handleClose, +}) => { + const { t } = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); + const [createAccountListPledge] = useCreateAccountListPledgeMutation(); + const [updateAccountListPledge] = useUpdateAccountListPledgeMutation(); + const { accountListId, appealId, contactsQueryResult } = React.useContext( + AppealsContext, + ) as AppealsType; + const isMobile = useMediaQuery((theme: Theme) => + theme.breakpoints.down('sm'), + ); + const constants = useApiConstants(); + const pledgeCurrencies = constants?.pledgeCurrency; + + const isNewPledge = pledge === undefined; + + const onSubmit = async (attributes: Attributes) => { + const amount = parseFloat( + attributes.amount.toString().replace(/[^\d.-]/g, ''), + ); + + if (isNewPledge) { + await createAccountListPledge({ + variables: { + input: { + accountListId: accountListId ?? '', + attributes: { + appealId: appealId, + contactId: contact.id, + amount: amount, + amountCurrency: attributes.amountCurrency, + expectedDate: attributes.expectedDate.toISODate() ?? '', + status: attributes.status as PledgeStatusEnum, + }, + }, + }, + onCompleted: () => { + contactsQueryResult.refetch(); + enqueueSnackbar(t('Successfully added commitment to appeal'), { + variant: 'success', + }); + handleClose(); + }, + onError: () => { + enqueueSnackbar(t('Unable to add commitment to appeal'), { + variant: 'error', + }); + }, + }); + } else { + await updateAccountListPledge({ + variables: { + input: { + pledgeId: pledge.id ?? '', + attributes: { + id: pledge.id, + appealId: appealId ?? '', + contactId: contact.id, + amount: amount, + amountCurrency: attributes.amountCurrency, + expectedDate: attributes.expectedDate.toISODate() ?? '', + status: attributes.status as PledgeStatusEnum, + }, + }, + }, + update: () => { + contactsQueryResult.refetch(); + }, + onCompleted: () => { + enqueueSnackbar(t('Successfully edited commitment'), { + variant: 'success', + }); + handleClose(); + }, + onError: () => { + enqueueSnackbar(t('Unable to edit commitment'), { + variant: 'error', + }); + }, + }); + } + }; + + const initialValues = pledge + ? { + amount: pledge.amount, + amountCurrency: pledge.amountCurrency ?? 'USD', + expectedDate: DateTime.fromISO(pledge.expectedDate), + status: pledge.status ?? PledgeStatusEnum.NotReceived, + } + : { + amount: 0, + amountCurrency: 'USD', + expectedDate: DateTime.local().startOf('day'), + status: PledgeStatusEnum.NotReceived, + }; + + return ( + + + {({ + setFieldValue, + handleSubmit, + isSubmitting, + isValid, + errors, + touched, + }): ReactElement => ( +
+ + + + + {t('You are adding a commitment for')} {contact.name} + + + + + {/* Amount and Currency Row */} + + + + + {t('Amount')} + + + {({ field, meta }: FieldProps) => ( + + + + {meta.touched && meta.error} + + + )} + + + + + + + {t('Currency')} + + {pledgeCurrencies && ( + + {({ field }: FieldProps) => ( + + + + )} + + )} + + + + + + + + + {t('Expected Date')} + + + {({ field }: FieldProps) => ( + + setFieldValue('expectedDate', date) + } + /> + )} + + + + + + + {t('Status')} + + + {({ field }: FieldProps) => ( + + + + )} + + + + + + + + + + {t('Save')} + + +
+ )} +
+
+ ); +}; From 3600e13506210de2f5b43c529bbdfec094b585d1 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Mon, 26 Aug 2024 16:21:09 -0400 Subject: [PATCH 4/4] 4. Contact Row - Add pledge and donation amounts --- .../List/ContactRow/ContactRow.test.tsx | 138 ++++++++++++------ .../Appeal/List/ContactRow/ContactRowMock.ts | 63 ++++++++ 2 files changed, 155 insertions(+), 46 deletions(-) create mode 100644 src/components/Tool/Appeal/List/ContactRow/ContactRowMock.ts diff --git a/src/components/Tool/Appeal/List/ContactRow/ContactRow.test.tsx b/src/components/Tool/Appeal/List/ContactRow/ContactRow.test.tsx index e5d01012f..d2bb921e6 100644 --- a/src/components/Tool/Appeal/List/ContactRow/ContactRow.test.tsx +++ b/src/components/Tool/Appeal/List/ContactRow/ContactRow.test.tsx @@ -3,66 +3,39 @@ import { ThemeProvider } from '@mui/material/styles'; import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import TestRouter from '__tests__/util/TestRouter'; -import { GqlMockedProvider, gqlMock } from '__tests__/util/graphqlMocking'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; import { AppealsWrapper } from 'pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper'; -import { - ContactRowFragment, - ContactRowFragmentDoc, -} from 'src/components/Contacts/ContactRow/ContactRow.generated'; import theme from 'src/theme'; import { + AppealStatusEnum, AppealsContext, AppealsType, } from '../../AppealsContext/AppealsContext'; +import { AppealContactInfoFragment } from '../../AppealsContext/contacts.generated'; import { ContactRow } from './ContactRow'; +import { defaultContact } from './ContactRowMock'; const accountListId = 'account-list-1'; +const appealId = 'appealId'; const router = { query: { accountListId }, isReady: true, }; -const contactMock = { - id: 'test-id', - lateAt: null, - name: 'Test, Name', - people: { - nodes: [ - { - anniversaryDay: null, - anniversaryMonth: null, - birthdayDay: null, - birthdayMonth: null, - }, - ], - }, - pledgeAmount: null, - pledgeCurrency: 'CAD', - pledgeFrequency: null, - primaryAddress: { - city: 'Any City', - country: null, - postalCode: 'Test', - state: 'TT', - street: '1111 Test Street', - updatedAt: new Date('2021-06-21T03:40:05-06:00').toISOString(), - }, - starred: false, - status: null, - uncompletedTasksCount: 0, -}; - -const contact = gqlMock(ContactRowFragmentDoc, { - mocks: contactMock, -}); - const setContactFocus = jest.fn(); const contactDetailsOpen = true; const toggleSelectionById = jest.fn(); const isRowChecked = jest.fn(); -const Components = () => ( +type ComponentsProps = { + appealStatus?: AppealStatusEnum; + contact?: AppealContactInfoFragment; +}; +const Components = ({ + appealStatus = AppealStatusEnum.Asked, + contact = defaultContact, +}: ComponentsProps) => ( @@ -70,6 +43,7 @@ const Components = () => ( ( } as unknown as AppealsType } > - + @@ -90,7 +64,7 @@ describe('ContactsRow', () => { const { getByText } = render(); expect(getByText('Test, Name')).toBeInTheDocument(); - expect(getByText('CA$0')).toBeInTheDocument(); + expect(getByText('CA$500 Monthly')).toBeInTheDocument(); }); it('should render check event', async () => { @@ -103,7 +77,7 @@ describe('ContactsRow', () => { }); it('should open contact on click', () => { - isRowChecked.mockImplementationOnce((id) => id === contact.id); + isRowChecked.mockImplementationOnce((id) => id === defaultContact.id); const { getByTestId } = render(); @@ -112,11 +86,11 @@ describe('ContactsRow', () => { const rowButton = getByTestId('rowButton'); userEvent.click(rowButton); - expect(setContactFocus).toHaveBeenCalledWith(contact.id); + expect(setContactFocus).toHaveBeenCalledWith(defaultContact.id); }); it('should render contact select event', () => { - isRowChecked.mockImplementationOnce((id) => id === contact.id); + isRowChecked.mockImplementationOnce((id) => id === defaultContact.id); const { getByTestId } = render(); @@ -125,6 +99,78 @@ describe('ContactsRow', () => { const rowButton = getByTestId('rowButton'); userEvent.click(rowButton); - expect(setContactFocus).toHaveBeenCalledWith(contact.id); + expect(setContactFocus).toHaveBeenCalledWith(defaultContact.id); + }); + + describe('Contact Row by status type', () => { + it('Excluded', () => { + isRowChecked.mockImplementationOnce(() => true); + + const { getByText } = render( + , + ); + + expect(getByText('Reason')).toBeInTheDocument(); + expect(getByText('CA$500 Monthly')).toBeInTheDocument(); + }); + + it('Asked', () => { + isRowChecked.mockImplementationOnce(() => true); + + const { getByText, queryByText } = render( + , + ); + + expect(queryByText('Reason')).not.toBeInTheDocument(); + expect(getByText('CA$500 Monthly')).toBeInTheDocument(); + }); + + it('Committed', () => { + isRowChecked.mockImplementationOnce(() => true); + + const { getByText, queryByText } = render( + , + ); + + expect(queryByText('Reason')).not.toBeInTheDocument(); + expect(getByText('$3,000 (Aug 8, 2024)')).toBeInTheDocument(); + }); + + it('Committed - with no pledges', () => { + isRowChecked.mockImplementationOnce(() => true); + + const { getByText } = render( + , + ); + expect(getByText('$0')).toBeInTheDocument(); + }); + + it('Received', () => { + isRowChecked.mockImplementationOnce(() => true); + + const { getByText, queryByText } = render( + , + ); + + expect(queryByText('Reason')).not.toBeInTheDocument(); + expect(getByText('$3,000 (Aug 8, 2024)')).toBeInTheDocument(); + }); + + it('Given', () => { + isRowChecked.mockImplementationOnce(() => true); + + const { getByText, queryByText } = render( + , + ); + + expect(queryByText('Reason')).not.toBeInTheDocument(); + expect(getByText('$3,000 ($50) (Jun 25, 2019)')).toBeInTheDocument(); + }); }); }); diff --git a/src/components/Tool/Appeal/List/ContactRow/ContactRowMock.ts b/src/components/Tool/Appeal/List/ContactRow/ContactRowMock.ts new file mode 100644 index 000000000..af636c902 --- /dev/null +++ b/src/components/Tool/Appeal/List/ContactRow/ContactRowMock.ts @@ -0,0 +1,63 @@ +import { PledgeFrequencyEnum } from 'src/graphql/types.generated'; +import { AppealContactInfoFragment } from '../../AppealsContext/contacts.generated'; + +export const defaultContact: AppealContactInfoFragment = { + id: 'test-id', + name: 'Test, Name', + pledgeAmount: 500, + pledgeCurrency: 'CAD', + pledgeFrequency: PledgeFrequencyEnum.Monthly, + pledgeReceived: true, + pledges: [ + { + id: 'pledge-1', + amount: 3000, + amountCurrency: 'USD', + appeal: { + id: 'appealId', + }, + expectedDate: '2024-08-08', + }, + { + id: 'pledge-2', + amount: 5000, + amountCurrency: 'USD', + appeal: { + id: 'appeal-2', + }, + expectedDate: '2024-11-11', + }, + ], + donations: { + nodes: [ + { + id: 'donation-1', + appeal: { + id: 'appeal-1', + }, + donationDate: '2024-08-23', + appealAmount: null, + }, + { + id: 'donation-2', + appeal: { + id: 'appeal-2', + }, + donationDate: '2024-08-22', + appealAmount: null, + }, + { + id: 'donation-3', + appeal: { + id: 'appealId', + }, + donationDate: '2019-06-25', + appealAmount: { + amount: 50, + convertedAmount: 50, + convertedCurrency: 'USD', + }, + }, + ], + }, +};