From f7e9100ceedec85845563892b5379225947e40d4 Mon Sep 17 00:00:00 2001 From: Will James Date: Wed, 28 Aug 2024 13:30:43 -0400 Subject: [PATCH] MPDX - (#8032, #8033, #7533, #8034, #8035, #8036, #8037) - FixCommitmentInfo (#974) * Updates Fix Commitment Info Tool --- .../[[...contactId]].page.test.tsx | 95 +++ .../commitmentInfo/[[...contactId]].page.tsx | 4 +- .../[accountListId]/tools/useToolsHelper.ts | 13 +- .../ContactDetails/ContactDetailContext.tsx | 2 +- .../Tool/FixCommitmentInfo/Contact.test.tsx | 267 ++++--- .../Tool/FixCommitmentInfo/Contact.tsx | 723 +++++++++++++----- .../FixCommitmentInfo.test.tsx | 177 +++++ .../FixCommitmentInfo/FixCommitmentInfo.tsx | 297 ++++--- .../FixCommitmentInfoMocks.ts | 24 + .../GetInvalidStatuses.graphql | 24 +- ...lidStatus.graphql => UpdateStatus.graphql} | 5 +- src/lib/getCurrencyOptions.tsx | 10 +- 12 files changed, 1183 insertions(+), 458 deletions(-) create mode 100644 pages/accountLists/[accountListId]/tools/fix/commitmentInfo/[[...contactId]].page.test.tsx create mode 100644 src/components/Tool/FixCommitmentInfo/FixCommitmentInfo.test.tsx create mode 100644 src/components/Tool/FixCommitmentInfo/FixCommitmentInfoMocks.ts rename src/components/Tool/FixCommitmentInfo/{UpdateInvalidStatus.graphql => UpdateStatus.graphql} (58%) diff --git a/pages/accountLists/[accountListId]/tools/fix/commitmentInfo/[[...contactId]].page.test.tsx b/pages/accountLists/[accountListId]/tools/fix/commitmentInfo/[[...contactId]].page.test.tsx new file mode 100644 index 000000000..f0a720569 --- /dev/null +++ b/pages/accountLists/[accountListId]/tools/fix/commitmentInfo/[[...contactId]].page.test.tsx @@ -0,0 +1,95 @@ +import { useRouter } from 'next/router'; +import { ThemeProvider } from '@mui/material/styles'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ErgonoMockShape } from 'graphql-ergonomock'; +import { getSession } from 'next-auth/react'; +import { SnackbarProvider } from 'notistack'; +import { I18nextProvider } from 'react-i18next'; +import { VirtuosoMockContext } from 'react-virtuoso'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { mockInvalidStatusesResponse } from 'src/components/Tool/FixCommitmentInfo/FixCommitmentInfoMocks'; +import { InvalidStatusesQuery } from 'src/components/Tool/FixCommitmentInfo/GetInvalidStatuses.generated'; +import i18n from 'src/lib/i18n'; +import theme from 'src/theme'; +import FixCommitmentInfoPage from './[[...contactId]].page'; + +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + +const pushFn = jest.fn(); +const accountListId = 'account-list-1'; +const session = { + expires: '2021-10-28T14:48:20.897Z', + user: { + email: 'Chair Library Bed', + image: null, + name: 'Dung Tapestry', + token: 'superLongJwtString', + }, +}; +const Components = ({ + mockNodes = mockInvalidStatusesResponse, +}: { + mockNodes?: ErgonoMockShape[]; +}) => ( + + + + mocks={{ + InvalidStatuses: { + contacts: { + nodes: mockNodes, + }, + }, + }} + > + + + + + + + + + + +); + +describe('FixCommitmentInfoPage', () => { + beforeEach(() => { + (getSession as jest.Mock).mockResolvedValue(session); + (useRouter as jest.Mock).mockReturnValue({ + query: { + accountListId, + }, + isReady: true, + push: pushFn, + }); + }); + + it('should open up contact details', async () => { + const { getByText, queryByText } = render(); + await waitFor(() => expect(queryByText('Tester 1')).toBeInTheDocument()); + + const contactName = getByText('Tester 1'); + + expect(contactName).toBeInTheDocument(); + userEvent.click(contactName); + + await waitFor(() => { + expect(pushFn).toHaveBeenCalledWith({ + pathname: + '/accountLists/account-list-1/tools/fix/commitmentInfo/tester-1', + + query: { tab: 'Donations' }, + }); + }); + }); +}); diff --git a/pages/accountLists/[accountListId]/tools/fix/commitmentInfo/[[...contactId]].page.tsx b/pages/accountLists/[accountListId]/tools/fix/commitmentInfo/[[...contactId]].page.tsx index a8cffca57..1c72551dc 100644 --- a/pages/accountLists/[accountListId]/tools/fix/commitmentInfo/[[...contactId]].page.tsx +++ b/pages/accountLists/[accountListId]/tools/fix/commitmentInfo/[[...contactId]].page.tsx @@ -10,8 +10,8 @@ const FixCommitmentInfoPage: React.FC = () => { const { accountListId, handleSelectContact } = useToolsHelper(); const pageUrl = 'tools/fix/commitmentInfo'; - const setContactFocus: SetContactFocus = (contactId) => { - handleSelectContact(pageUrl, contactId); + const setContactFocus: SetContactFocus = (contactId, tabKey) => { + handleSelectContact(pageUrl, contactId, tabKey); }; return ( diff --git a/pages/accountLists/[accountListId]/tools/useToolsHelper.ts b/pages/accountLists/[accountListId]/tools/useToolsHelper.ts index 2942b6d26..d8e49403f 100644 --- a/pages/accountLists/[accountListId]/tools/useToolsHelper.ts +++ b/pages/accountLists/[accountListId]/tools/useToolsHelper.ts @@ -1,9 +1,10 @@ import { useRouter } from 'next/router'; import { useCallback } from 'react'; +import { TabKey } from 'src/components/Contacts/ContactDetails/ContactDetails'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { getQueryParam } from 'src/utils/queryParam'; -export type SetContactFocus = (contactId: string) => void; +export type SetContactFocus = (contactId: string, tab?: TabKey) => void; export const useToolsHelper = () => { const { query, push } = useRouter(); @@ -11,8 +12,14 @@ export const useToolsHelper = () => { const selectedContactId = getQueryParam(query, 'contactId'); const handleSelectContact = useCallback( - (pagePath: string, contactId: string) => { - push(`/accountLists/${accountListId}/${pagePath}/${contactId}`); + (pagePath: string, contactId: string, tab?: TabKey) => { + const pathname = `/accountLists/${accountListId}/${pagePath}/${contactId}`; + tab + ? push({ + pathname, + query: { tab }, + }) + : push(pathname); }, [accountListId], ); diff --git a/src/components/Contacts/ContactDetails/ContactDetailContext.tsx b/src/components/Contacts/ContactDetails/ContactDetailContext.tsx index ee92bd47a..22b808edc 100644 --- a/src/components/Contacts/ContactDetails/ContactDetailContext.tsx +++ b/src/components/Contacts/ContactDetails/ContactDetailContext.tsx @@ -59,7 +59,7 @@ export const ContactDetailProvider: React.FC = ({ children }) => { const [editOtherModalOpen, setEditOtherModalOpen] = useState(false); const [editMailingModalOpen, setEditMailingModalOpen] = useState(false); const [selectedTabKey, setSelectedTabKey] = React.useState( - query?.tab ? TabKey[query?.tab.toString()] ?? TabKey.Tasks : TabKey.Tasks, + query?.tab ? TabKey[query.tab.toString()] ?? TabKey.Tasks : TabKey.Tasks, ); const handleTabChange = ( _event: React.ChangeEvent>, diff --git a/src/components/Tool/FixCommitmentInfo/Contact.test.tsx b/src/components/Tool/FixCommitmentInfo/Contact.test.tsx index 51548b2c4..c22bbe33b 100644 --- a/src/components/Tool/FixCommitmentInfo/Contact.test.tsx +++ b/src/components/Tool/FixCommitmentInfo/Contact.test.tsx @@ -3,159 +3,192 @@ import { ThemeProvider } from '@mui/material/styles'; import userEvent from '@testing-library/user-event'; import TestRouter from '__tests__/util/TestRouter'; import TestWrapper from '__tests__/util/TestWrapper'; -import { render } from '__tests__/util/testingLibraryReactMock'; +import { + fireEvent, + render, + waitFor, +} from '__tests__/util/testingLibraryReactMock'; +import { TabKey } from 'src/components/Contacts/ContactDetails/ContactDetails'; +import { PledgeFrequencyEnum } from 'src/graphql/types.generated'; import theme from '../../../theme'; import Contact from './Contact'; -const testData = { - id: 'test123', - name: 'Test test', +let testData = { + id: 'test 1', + name: 'Tester 1', + avatar: '', statusTitle: 'Partner - Financial', - statusValue: 'partner-financial', + statusValue: 'NEW_CONNECTION', frequencyTitle: 'Monthly', - frequencyValue: 'monthly', + frequencyValue: PledgeFrequencyEnum.Monthly, amount: 50, - amountCurrency: 'CAD', + amountCurrency: 'ARM', + donations: { + nodes: [ + { + amount: { + amount: 175, + currency: 'USD', + conversionDate: '2019-10-15', + convertedCurrency: 'USD', + }, + }, + ], + }, }; const router = { push: jest.fn(), }; + const setContactFocus = jest.fn(); +const handleShowModal = jest.fn(); + +const TestComponent = ({ + statuses = ['Partner - Financial', 'test_option_1'], +}: { + statuses?: string[]; +}) => ( + + + + + +); describe('FixCommitmentContact', () => { + beforeEach(() => { + handleShowModal.mockClear(); + setContactFocus.mockClear(); + }); + it('default', () => { - const hideFunction = jest.fn(); - const updateFunction = jest.fn(); - const { getByText } = render( - - - - - , - ); + const { getByText, getByTestId } = render(); expect(getByText(testData.name)).toBeInTheDocument(); expect( - getByText( - `Current: ${testData.statusTitle} ${testData.amount.toFixed(2)} ${ - testData.amountCurrency - } ${testData.frequencyTitle}`, - ), + getByText('Current: Partner - Financial ARM 50 Monthly'), ).toBeInTheDocument(); + expect(getByTestId('pledgeCurrency-input')).toBeInTheDocument(); }); - it('should call hide and update functions', () => { - const hideFunction = jest.fn(); - const updateFunction = jest.fn(); + it('should call hide and update functions', async () => { + const { getByTestId } = render(); + userEvent.click(getByTestId('doNotChangeButton')); + expect(handleShowModal).toHaveBeenCalledTimes(1); + userEvent.click(getByTestId('hideButton')); + expect(handleShowModal).toHaveBeenCalledTimes(2); + }); + + it('should redirect the page', () => { const { getByTestId } = render( - - - - - , + + + , ); + userEvent.click(getByTestId('contactSelect')); + expect(setContactFocus).toHaveBeenCalledWith(testData.id, TabKey.Donations); + }); + + it('should fail validation', async () => { + testData = { + id: 'test 2', + name: 'Tester 2', + avatar: '', + statusTitle: '', + statusValue: '', + frequencyTitle: '', + frequencyValue: PledgeFrequencyEnum.Annual, + amount: null!, + amountCurrency: '', + donations: { + nodes: [ + { + amount: { + amount: 0, + currency: 'UGX', + conversionDate: '2021-12-24', + convertedCurrency: 'UGX', + }, + }, + ], + }, + }; + + const { getByTestId } = render(); userEvent.click(getByTestId('confirmButton')); + await waitFor(() => { + expect(getByTestId('statusSelectError')).toHaveTextContent( + 'Please select a status', + ); + }); + }); - expect(updateFunction).toHaveBeenCalledTimes(1); + it('should should render select field options and inputs', async () => { + const { getByTestId } = render( + , + ); - userEvent.click(getByTestId('doNotChangeButton')); + const frequency = getByTestId('pledgeFrequency-input'); + fireEvent.change(frequency, { + target: { value: 'WEEKLY' }, + }); + expect(frequency).toHaveValue('WEEKLY'); - expect(updateFunction).toHaveBeenCalledTimes(2); + const currency = getByTestId('pledgeCurrency-input'); + fireEvent.select(currency, { + target: { value: 'USD ($)' }, + }); + expect(currency).toHaveValue('USD ($)'); - userEvent.click(getByTestId('hideButton')); + const status = getByTestId('pledgeStatus-input'); + fireEvent.change(status, { + target: { value: 'Partner - Financial' }, + }); + expect(status).toHaveValue('Partner - Financial'); - expect(hideFunction).toHaveBeenCalledTimes(1); + const amount = getByTestId('pledgeAmount-input'); + fireEvent.change(amount, { + target: { value: '2.00' }, + }); + expect(amount).toHaveValue(2); }); - it('should redirect the page', () => { - const hideFunction = jest.fn(); - const updateFunction = jest.fn(); - + it('should render with correct styles', async () => { const { getByTestId } = render( - - - - - , + + + , ); - userEvent.click(getByTestId('goToContactsButton')); - expect(router.push).toHaveBeenCalled(); + const boxBottom = getByTestId('BoxBottom'); + expect(boxBottom.className).toEqual(expect.stringContaining('boxBottom')); + expect(boxBottom).toHaveStyle('margin-left: 8px'); }); - it('should render statuses', () => { - const hideFunction = jest.fn(); - const updateFunction = jest.fn(); - - const { getByTestId, getByText } = render( - - - - - , + it('should render donation data', async () => { + const { getByTestId } = render( + + + , ); - expect(getByText(testData.statusTitle)).toBeInTheDocument(); - userEvent.click(getByTestId('statusSelect')); - expect(getByText('test_option_1')).toBeInTheDocument(); + const donationDate = getByTestId('donationDate'); + expect(donationDate).toHaveTextContent('12/24/2021'); + const donationAmount = getByTestId('donationAmount'); + expect(donationAmount).toHaveTextContent('0 UGX'); }); }); diff --git a/src/components/Tool/FixCommitmentInfo/Contact.tsx b/src/components/Tool/FixCommitmentInfo/Contact.tsx index 728268424..5108b81ad 100644 --- a/src/components/Tool/FixCommitmentInfo/Contact.tsx +++ b/src/components/Tool/FixCommitmentInfo/Contact.tsx @@ -1,70 +1,118 @@ -import { useRouter } from 'next/router'; -import React, { useState } from 'react'; -import SearchIcon from '@mui/icons-material/Search'; +import React, { ReactElement } from 'react'; import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; import { Avatar, Box, Button, + Card, + FormControl, + FormHelperText, Grid, IconButton, + InputLabel, Link, - NativeSelect, + MenuItem, + Select, TextField, + Tooltip, Typography, } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { Field, Form, Formik } from 'formik'; +import { DateTime } from 'luxon'; import { useTranslation } from 'react-i18next'; import { makeStyles } from 'tss-react/mui'; +import * as yup from 'yup'; import { SetContactFocus } from 'pages/accountLists/[accountListId]/tools/useToolsHelper'; -import { FilterOption } from 'src/graphql/types.generated'; -import { useAccountListId } from 'src/hooks/useAccountListId'; +import { useApiConstants } from 'src/components/Constants/UseApiConstants'; +import { TabKey } from 'src/components/Contacts/ContactDetails/ContactDetails'; +import { PledgeFrequencyEnum } from 'src/graphql/types.generated'; +import { useLocale } from 'src/hooks/useLocale'; +import { + PledgeCurrencyOptionFormatEnum, + getPledgeCurrencyOptions, +} from 'src/lib/getCurrencyOptions'; +import { currencyFormat } from 'src/lib/intlFormat'; +import { getLocalizedPledgeFrequency } from 'src/utils/functions/getLocalizedPledgeFrequency'; import theme from '../../../theme'; import { StyledInput } from '../StyledInput'; -import { frequencies } from './InputOptions/Frequencies'; +import { + ContactType, + DonationsType, + SuggestedChangesType, + UpdateTypeEnum, +} from './FixCommitmentInfo'; + +interface FormAttributes { + status?: string; + pledgeCurrency?: string; + pledgeAmount?: number | null; + pledgeFrequency?: PledgeFrequencyEnum | string | null; +} const useStyles = makeStyles()(() => ({ right: { display: 'flex', justifyContent: 'flex-start', alignItems: 'center', - [theme.breakpoints.up('lg')]: { - borderTop: `1px solid ${theme.palette.cruGrayMedium.main}`, - borderBottom: `16px solid ${theme.palette.cruGrayMedium.main}`, - borderRight: `1px solid ${theme.palette.cruGrayMedium.main}`, + order: 1, + padding: theme.spacing(2), + [theme.breakpoints.up('md')]: { + order: 0, + + borderLeft: 'none', }, }, left: { height: '100%', - [theme.breakpoints.up('lg')]: { - borderTop: `1px solid ${theme.palette.cruGrayMedium.main}`, - borderBottom: `16px solid ${theme.palette.cruGrayMedium.main}`, - borderLeft: `1px solid ${theme.palette.cruGrayMedium.main}`, + [theme.breakpoints.up('md')]: { + borderTopRightRadius: 0, + borderRight: 'none', }, }, container: { - display: 'flex', + display: 'block', alignItems: 'center', + width: '100%', height: '100%', marginBottom: theme.spacing(3), - [theme.breakpoints.down('md')]: { - border: `1px solid ${theme.palette.cruGrayMedium.main}`, - }, }, boxTop: { marginRight: theme.spacing(1), marginBottom: theme.spacing(2), marginLeft: theme.spacing(1), - [theme.breakpoints.down('md')]: { - marginLeft: theme.spacing(2), - marginTop: theme.spacing(0), - }, }, boxBottom: { marginLeft: theme.spacing(1), marginRight: theme.spacing(1), - [theme.breakpoints.down('md')]: { + [theme.breakpoints.down('lg')]: { marginBottom: theme.spacing(2), - marginLeft: theme.spacing(2), + }, + }, + donationsTable: { + display: 'flex', + justifyContent: 'space-around', + padding: theme.spacing(1), + borderTop: '1px solid #EBECEC', + borderBottom: '1px solid #EBECEC', + [theme.breakpoints.up('md')]: { + borderTop: '1px solid #EBECEC', + borderBottom: 'none', + }, + }, + buttonGroup: { + display: 'flex', + alignItems: 'center', + height: '100%', + }, + buttonGroupBox: { + display: 'flex', + flexDirection: 'row', + marginBottom: theme.spacing(3), + paddingLeft: theme.spacing(1), + [theme.breakpoints.up('md')]: { + flexDirection: 'column', + marginBottom: 0, }, }, buttonTop: { @@ -79,225 +127,480 @@ const useStyles = makeStyles()(() => ({ }, buttonBottom: { margin: theme.spacing(1), + }, + ButtonIcons: { + display: 'flex', + justifyContent: 'center', + [theme.breakpoints.down('md')]: { + display: 'flex', + }, + }, + select: { + width: '100%', + }, + formWrapper: { + display: 'flex', + alignItems: 'center', + flexDirection: 'row', + flexWrap: 'nowrap', [theme.breakpoints.down('md')]: { - marginRight: theme.spacing(1), + flexDirection: 'column', + flexWrap: 'wrap', + }, + }, + formInner: { + width: '99%', + [theme.breakpoints.up('md')]: { + width: '100%', + margin: theme.spacing(1), }, }, })); +const ContactAvatar = styled(Avatar)(() => ({ + width: theme.spacing(4), + height: theme.spacing(4), +})); + interface Props { id: string; name: string; + donations: DonationsType[]; statusTitle: string; statusValue: string; amount: number; amountCurrency: string; - frequencyTitle: string; - frequencyValue: string; - hideFunction: (hideId: string) => void; - updateFunction: ( - id: string, - change: boolean, - status?: string, - pledgeCurrency?: string, - pledgeAmount?: number, - pledgeFrequency?: string, - ) => Promise; - statuses: FilterOption[]; + frequencyValue: PledgeFrequencyEnum | null; + showModal: ( + contact: ContactType, + message: string, + title: string, + updateType: UpdateTypeEnum, + ) => void; + statuses: string[]; setContactFocus: SetContactFocus; + avatar?: string; + suggestedChanges?: SuggestedChangesType; } const Contact: React.FC = ({ id, name, + donations, statusTitle, statusValue, amount, amountCurrency, - frequencyTitle, frequencyValue, - hideFunction, - updateFunction, + showModal, statuses, setContactFocus, + avatar, + suggestedChanges, }) => { - const [values, setValues] = useState({ - statusValue: statusValue, - amountCurrency: amountCurrency, - amount: amount, - frequencyValue: frequencyValue, - }); + const { pledgeCurrency: pledgeCurrencies } = useApiConstants() || {}; + const locale = useLocale(); const { classes } = useStyles(); const { t } = useTranslation(); - const accountListId = useAccountListId(); - const { push } = useRouter(); - //TODO: Add button functionality - //TODO: Show donation history - const handleChange = ( - event: - | React.ChangeEvent - | React.ChangeEvent, - props: string, - ): void => { - setValues((prevState) => ({ ...prevState, [props]: event.target.value })); - }; + const suggestedAmount = !suggestedChanges?.pledge_amount + ? null + : typeof suggestedChanges.pledge_amount === 'string' + ? parseInt(suggestedChanges.pledge_amount) + : suggestedChanges.pledge_amount; + + const suggestedFrequency = suggestedChanges?.pledge_frequency || null; + + const onSubmit = async ({ + status, + pledgeCurrency, + pledgeAmount, + pledgeFrequency, + }: FormAttributes) => { + const modalContact = { + id: id, + status, + name: name, + pledgeCurrency, + pledgeAmount, + pledgeFrequency, + }; - const handleContactNameClick = () => { - setContactFocus(id); + showModal( + modalContact, + t(`Are you sure you wish to update {{source}} commitment info?`, { + source: name, + }), + t('Update'), + UpdateTypeEnum.Change, + ); }; + const commitmentInfoFormSchema = yup.object({ + statusValue: yup.string().required('Please select a status'), + pledgeCurrency: yup.string().nullable(), + pledgeAmount: yup.number().nullable(), + pledgeFrequency: yup.string().nullable(), + }); + return ( - - - - - - - {name} - - - Current:{' '} - {`${statusTitle} ${amount.toFixed( - 2, - )} ${amountCurrency} ${frequencyTitle}`} - - - - - - - - - } - data-testid="statusSelect" - style={{ width: '100%' }} - value={values.statusValue} - onChange={(event) => handleChange(event, 'statusValue')} - > - {statuses.map((status) => ( - - ))} - - - - - - handleChange(event, 'amountCurrency')} - /> - - - - - handleChange(event, 'amount')} - /> - - - - - } - style={{ width: '100%' }} - value={values.frequencyValue} - onChange={(event) => handleChange(event, 'frequencyValue')} - > - - {Object.entries(frequencies).map( - ([freqValue, freqTranslated]) => ( - - ), - )} - - - - - - - - - - - - - - - - push({ - pathname: `/accountLists/[accountListId]/contacts/[contactId]`, - query: { accountListId, contactId: id }, - }) - } - > - - - hideFunction(id)} - > - - - - - - + { + await onSubmit(values); + }} + > + {({ + values: { + statusValue, + pledgeCurrency, + pledgeAmount, + pledgeFrequency, + }, + handleSubmit, + setFieldValue, + errors, + }): ReactElement => { + const modalContact = { + id: id, + status: statusValue, + name: name, + pledgeCurrency, + pledgeAmount, + pledgeFrequency, + }; + return ( +
+ + + + + + + + + setContactFocus(id, TabKey.Donations) + } + > + {name} + + + {`Current: ${statusTitle} ${ + amount && amountCurrency + ? currencyFormat(amount, amountCurrency, locale) + : '' + } ${getLocalizedPledgeFrequency( + t, + pledgeFrequency, + )}`} + + + + + + + + + + {t('Status')} + + + + + {errors.statusValue && errors.statusValue} + + + + + + + {t('Currency')} + + + + + {errors.pledgeCurrency && errors.pledgeCurrency} + + + + + + + } + label={t('Amount')} + labelId="amount-label" + placeholder="Amount" + type="number" + variant="standard" + size="small" + fullWidth + render={() => ( + + setFieldValue( + 'pledgeAmount', + parseFloat(event.target.value), + ) + } + /> + )} + /> + + + {errors.pledgeAmount && errors.pledgeAmount} + + + + + + + + {t('Frequency')} + + + + + {errors.pledgeFrequency && errors.pledgeFrequency} + + + + + + {!!donations.length && ( + + {donations + .map((donation) => ( + + + + {DateTime.fromISO( + donation.amount.conversionDate, + ) + .setLocale(locale || 'en') + .toLocaleString()} + + + + + {`${donation.amount.amount} ${donation.amount.currency}`} + + + + )) + .reverse()} + + )} + + + + + + + + + + + + + + showModal( + modalContact, + t( + `Are you sure you wish to hide {{source}}? Hiding a contact in MPDX actually sets the contact status to "Never Ask".`, + { source: name }, + ), + t('Hide'), + UpdateTypeEnum.Hide, + ) + } + > + + + + + + + +
+ ); + }} +
); }; diff --git a/src/components/Tool/FixCommitmentInfo/FixCommitmentInfo.test.tsx b/src/components/Tool/FixCommitmentInfo/FixCommitmentInfo.test.tsx new file mode 100644 index 000000000..79e8ae516 --- /dev/null +++ b/src/components/Tool/FixCommitmentInfo/FixCommitmentInfo.test.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import userEvent from '@testing-library/user-event'; +import { ErgonoMockShape } from 'graphql-ergonomock'; +import { SnackbarProvider } from 'notistack'; +import TestRouter from '__tests__/util/TestRouter'; +import TestWrapper from '__tests__/util/TestWrapper'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { render, waitFor } from '__tests__/util/testingLibraryReactMock'; +import theme from '../../../theme'; +import FixCommitmentInfo from './FixCommitmentInfo'; +import { mockInvalidStatusesResponse } from './FixCommitmentInfoMocks'; +import { InvalidStatusesQuery } from './GetInvalidStatuses.generated'; + +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 = 'test121'; +const router = { + pathname: '/accountLists/[accountListId]/tools/fixCommitmentInfo', + query: { accountListId: accountListId }, + push: jest.fn(), +}; + +const setContactFocus = jest.fn(); + +const Components = ({ + mockNodes = mockInvalidStatusesResponse, +}: { + mockNodes?: ErgonoMockShape[]; +}) => ( + + + + + + mocks={{ + InvalidStatuses: { + contacts: { + nodes: mockNodes, + totalCount: 2, + }, + }, + }} + > + + + + + + +); + +describe('FixCommitmentContact', () => { + beforeEach(() => { + setContactFocus.mockClear(); + }); + + it('default with test data', async () => { + const { getByText, findByText } = render(); + await findByText('Fix Commitment Info'); + expect(getByText('Fix Commitment Info')).toBeInTheDocument(); + + expect( + getByText('You have 2 partner statuses to confirm.'), + ).toBeInTheDocument(); + }); + + it('has correct styles', async () => { + const { getByTestId, findByTestId } = render(); + const home = getByTestId('Home'); + + expect(home).toHaveStyle('display: flex'); + const container = await findByTestId('Container'); + const divider = await findByTestId('Divider'); + const description = await findByTestId('Description'); + + expect(container.className).toEqual(expect.stringContaining('container')); + expect(container).toHaveStyle('width: 70%'); + + expect(divider.className).toEqual(expect.stringContaining('divider')); + expect(divider).toHaveStyle('margin-top: 16px'); + + expect(description.className).toEqual( + expect.stringContaining('descriptionBox'), + ); + + expect(description).toHaveStyle('margin-bottom: 16px'); + }); + + it('Shows hide modal', async () => { + const { getAllByTestId, queryByText, getByText, findAllByTestId } = render( + , + ); + + userEvent.click((await findAllByTestId('hideButton'))[0]); + + expect( + getByText( + 'Are you sure you wish to hide Tester 1? Hiding a contact in MPDX actually sets the contact status to "Never Ask".', + ), + ).toBeInTheDocument(); + + userEvent.click(getAllByTestId('action-button')[0]); + + expect( + queryByText( + 'Are you sure you wish to hide Tester 1? Hiding a contact in MPDX actually sets the contact status to "Never Ask".', + ), + ).not.toBeInTheDocument(); + }); + + it('updates commitment info', async () => { + const { getAllByTestId, queryByText, findByText, findAllByTestId } = render( + , + ); + + userEvent.click((await findAllByTestId('confirmButton'))[0]); + + expect( + await findByText( + 'Are you sure you wish to update Tester 1 commitment info?', + ), + ).toBeInTheDocument(), + userEvent.click(getAllByTestId('action-button')[1]); + + await waitFor(() => + expect(queryByText('Tester 1')).not.toBeInTheDocument(), + ); + + userEvent.click((await findAllByTestId('confirmButton'))[0]); + + expect( + await findByText( + 'Are you sure you wish to update Tester 2 commitment info?', + ), + ).toBeInTheDocument(); + + userEvent.click((await findAllByTestId('hideButton'))[0]); + + expect( + await findByText( + `Are you sure you wish to hide Tester 2? Hiding a contact in MPDX actually sets the contact status to "Never Ask".`, + ), + ).toBeInTheDocument(); + + userEvent.click(getAllByTestId('action-button')[1]); + + await waitFor(() => + expect(queryByText('Tester 2')).not.toBeInTheDocument(), + ); + }); + + it('opens contact drawer to donations tab', async () => { + const { findAllByTestId } = render(); + + userEvent.click((await findAllByTestId('hideButton'))[0]); + + userEvent.click((await findAllByTestId('contactSelect'))[0]); + + expect(setContactFocus).toHaveBeenCalled(); + }); +}); diff --git a/src/components/Tool/FixCommitmentInfo/FixCommitmentInfo.tsx b/src/components/Tool/FixCommitmentInfo/FixCommitmentInfo.tsx index 7b9db2f0e..9186d45bb 100644 --- a/src/components/Tool/FixCommitmentInfo/FixCommitmentInfo.tsx +++ b/src/components/Tool/FixCommitmentInfo/FixCommitmentInfo.tsx @@ -1,5 +1,4 @@ -import React from 'react'; -import { useApolloClient } from '@apollo/client'; +import React, { useMemo, useState } from 'react'; import { Box, CircularProgress, @@ -11,25 +10,17 @@ import { import { useSnackbar } from 'notistack'; import { Trans, useTranslation } from 'react-i18next'; import { makeStyles } from 'tss-react/mui'; -import { useContactFiltersQuery } from 'pages/accountLists/[accountListId]/contacts/Contacts.generated'; import { SetContactFocus } from 'pages/accountLists/[accountListId]/tools/useToolsHelper'; -import { - MultiselectFilter, - PledgeFrequencyEnum, - StatusEnum, -} from 'src/graphql/types.generated'; +import { Confirmation } from 'src/components/common/Modal/Confirmation/Confirmation'; +import { PledgeFrequencyEnum, StatusEnum } from 'src/graphql/types.generated'; import useGetAppSettings from 'src/hooks/useGetAppSettings'; import { contactPartnershipStatus } from 'src/utils/contacts/contactPartnershipStatus'; +import { getLocalizedContactStatus } from 'src/utils/functions/getLocalizedContactStatus'; import theme from '../../../theme'; import NoData from '../NoData'; import Contact from './Contact'; -import { - GetInvalidStatusesDocument, - GetInvalidStatusesQuery, - useGetInvalidStatusesQuery, -} from './GetInvalidStatuses.generated'; -import { frequencies } from './InputOptions/Frequencies'; -import { useUpdateInvalidStatusMutation } from './UpdateInvalidStatus.generated'; +import { useInvalidStatusesQuery } from './GetInvalidStatuses.generated'; +import { useUpdateStatusMutation } from './UpdateStatus.generated'; const useStyles = makeStyles()((theme: Theme) => ({ container: { @@ -65,122 +56,186 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, })); +export interface DonationsType { + amount: { + amount: number; + currency: string; + conversionDate: string; + }; +} + +export interface SuggestedChangesType { + pledge_amount: string | number; + pledge_frequency: string; +} + +export interface ContactType { + id?: string | undefined; + status?: string | undefined; + name?: string | undefined; + pledgeCurrency?: string | undefined; + pledgeAmount?: number | undefined | null; + pledgeFrequency?: PledgeFrequencyEnum | string | null; + donations?: DonationsType[] | []; + suggestedChanges?: SuggestedChangesType | string | undefined; +} + +export interface ModalStateType { + open: boolean; + contact: ContactType; + message: string; + title: string; + updateType: UpdateTypeEnum | null; +} + +const defaultModalState = { + open: false, + contact: {}, + message: '', + title: '', + updateType: null, +}; + interface Props { accountListId: string; setContactFocus: SetContactFocus; } +export enum UpdateTypeEnum { + Change = 'CHANGE', + DontChange = 'DONT_CHANGE', + Hide = 'HIDE', +} + const FixCommitmentInfo: React.FC = ({ accountListId, setContactFocus, }: Props) => { const { classes } = useStyles(); + const [modalState, setModalState] = + useState(defaultModalState); const { t } = useTranslation(); const { enqueueSnackbar } = useSnackbar(); const { appName } = useGetAppSettings(); - const client = useApolloClient(); - const { data, loading } = useGetInvalidStatusesQuery({ + const { data } = useInvalidStatusesQuery({ variables: { accountListId }, }); - const { data: contactFilterGroups, loading: loadingStatuses } = - useContactFiltersQuery({ - variables: { - accountListId, - }, - context: { - doNotBatch: true, - }, - }); - const [updateInvalidStatus, { loading: updating }] = - useUpdateInvalidStatusMutation(); - - const contactStatuses = contactFilterGroups?.accountList?.contactFilterGroups - ? ( - contactFilterGroups.accountList.contactFilterGroups - .find((group) => group?.filters[0]?.filterKey === 'status') - ?.filters.find( - (filter: { filterKey: string }) => filter.filterKey === 'status', - ) as MultiselectFilter - ).options?.filter( - (status) => - status.value !== 'NULL' && - status.value !== 'HIDDEN' && - status.value !== 'ACTIVE', - ) - : [{ name: '', value: '' }]; - //TODO: Make currency field a select element - - const updateContact = async ( - id: string, - change: boolean, - status?: string, - pledgeCurrency?: string, - pledgeAmount?: number, - pledgeFrequency?: string, - ): Promise => { - const attributes = change - ? { - id, - status: status as StatusEnum, - pledgeAmount, - pledgeCurrency, - pledgeFrequency: pledgeFrequency as PledgeFrequencyEnum, + + const [updateInvalidStatus] = useUpdateStatusMutation(); + + const contactStatuses = useMemo(() => { + return Object.values(StatusEnum).map((value) => + getLocalizedContactStatus(t, value), + ); + }, [t]); + + const formatSuggestedChanges = ( + suggestedChanges: SuggestedChangesType | string | undefined | null, + ) => { + if (typeof suggestedChanges !== 'string') { + return {}; + } + + try { + return JSON.parse(suggestedChanges); + } catch (error) { + return {}; + } + }; + + const updateContact = async (): Promise => { + let attributes; + + switch (modalState.updateType) { + case 'CHANGE': + attributes = { + id: modalState.contact.id, + status: modalState.contact.status, + pledgeAmount: modalState.contact.pledgeAmount, + pledgeCurrency: modalState.contact.pledgeCurrency, + pledgeFrequency: modalState.contact.pledgeFrequency, + statusValid: true, + }; + break; + case 'DONT_CHANGE': + attributes = { + id: modalState.contact.id, statusValid: true, - } - : { id, statusValid: true }; + }; + break; + case 'HIDE': + attributes = { + id: modalState.contact.id, + status: StatusEnum.NeverAsk, + }; + break; + } + await updateInvalidStatus({ variables: { accountListId, attributes, }, + update: (cache) => { + cache.evict({ id: `Contact:${modalState.contact.id}` }); + }, + onError() { + enqueueSnackbar( + t(`Error updating {{name}}'s commitment info`, { + name: modalState.contact.name, + }), + { + variant: 'error', + }, + ); + }, + onCompleted() { + enqueueSnackbar( + t(`{{name}}'s commitment info updated!`, { + name: modalState.contact.name, + }), + { + variant: 'success', + }, + ); + }, }); - enqueueSnackbar(t('Contact commitment info updated!'), { - variant: 'success', - }); - hideContact(id); }; - const hideContact = (hideId: string): void => { - const query = { - query: GetInvalidStatusesDocument, - variables: { - accountListId, - }, - }; - - const dataFromCache = client.readQuery(query); - - if (dataFromCache) { - const data = { - ...dataFromCache, - contacts: { - ...dataFromCache.contacts, - nodes: dataFromCache.contacts.nodes.filter( - (contact) => contact.id !== hideId, - ), - }, - }; - - client.writeQuery({ ...query, data }); - } + const handleShowModal = ( + contact: ContactType, + message: string, + title: string, + updateType: UpdateTypeEnum, + ) => { + setModalState({ + open: true, + contact, + message, + title, + updateType, + }); }; return ( - {!loading && !updating && !loadingStatuses && data ? ( - + {data ? ( + {t('Fix Commitment Info')} - + {data.contacts?.nodes.length > 0 ? ( <> - + {t('You have {{amount}} partner statuses to confirm.', { - amount: data?.contacts.nodes.length, + amount: data?.contacts.totalCount, })} @@ -196,48 +251,36 @@ const FixCommitmentInfo: React.FC = ({ - {data.contacts.nodes.map((contact) => ( ))} - - - - }} - /> - - - ) : ( @@ -246,6 +289,24 @@ const FixCommitmentInfo: React.FC = ({ ) : ( )} + {modalState.open && ( + }} + /> + } + handleClose={() => setModalState(defaultModalState)} + mutation={updateContact} + /> + )} ); }; diff --git a/src/components/Tool/FixCommitmentInfo/FixCommitmentInfoMocks.ts b/src/components/Tool/FixCommitmentInfo/FixCommitmentInfoMocks.ts new file mode 100644 index 000000000..ab8640439 --- /dev/null +++ b/src/components/Tool/FixCommitmentInfo/FixCommitmentInfoMocks.ts @@ -0,0 +1,24 @@ +import { ErgonoMockShape } from 'graphql-ergonomock'; + +export const contactId = 'contactId'; + +export const mockInvalidStatusesResponse: ErgonoMockShape[] = [ + { + id: 'tester-1', + name: 'Tester 1', + status: 'PARTNER_FINANCIAL', + pledgeAmount: 1, + pledgeCurrency: 'USD', + pledgeFrequency: 'WEEKLY', + statusValid: false, + }, + { + id: 'tester-2', + name: 'Tester 2', + status: 'PARTNER_FINANCIAL', + pledgeAmount: 1, + pledgeCurrency: 'USD', + pledgeFrequency: 'WEEKLY', + statusValid: false, + }, +]; diff --git a/src/components/Tool/FixCommitmentInfo/GetInvalidStatuses.graphql b/src/components/Tool/FixCommitmentInfo/GetInvalidStatuses.graphql index e3dac88e3..1693d9d0d 100644 --- a/src/components/Tool/FixCommitmentInfo/GetInvalidStatuses.graphql +++ b/src/components/Tool/FixCommitmentInfo/GetInvalidStatuses.graphql @@ -1,16 +1,36 @@ -query GetInvalidStatuses($accountListId: ID!) { +query InvalidStatuses($accountListId: ID!, $after: String) { contacts( accountListId: $accountListId contactsFilter: { statusValid: false } - first: 100 + after: $after + first: 50 ) { nodes { id name + avatar status pledgeAmount pledgeCurrency pledgeFrequency + suggestedChanges + donations(first: 6) { + nodes { + amount { + amount + currency + conversionDate + } + } + } } + pageInfo { + endCursor + hasNextPage + } + edges { + cursor + } + totalCount } } diff --git a/src/components/Tool/FixCommitmentInfo/UpdateInvalidStatus.graphql b/src/components/Tool/FixCommitmentInfo/UpdateStatus.graphql similarity index 58% rename from src/components/Tool/FixCommitmentInfo/UpdateInvalidStatus.graphql rename to src/components/Tool/FixCommitmentInfo/UpdateStatus.graphql index 5b24d947c..4bd8bf0ae 100644 --- a/src/components/Tool/FixCommitmentInfo/UpdateInvalidStatus.graphql +++ b/src/components/Tool/FixCommitmentInfo/UpdateStatus.graphql @@ -1,7 +1,4 @@ -mutation UpdateInvalidStatus( - $accountListId: ID! - $attributes: ContactUpdateInput! -) { +mutation UpdateStatus($accountListId: ID!, $attributes: ContactUpdateInput!) { updateContact( input: { accountListId: $accountListId, attributes: $attributes } ) { diff --git a/src/lib/getCurrencyOptions.tsx b/src/lib/getCurrencyOptions.tsx index 576ba87f1..e9b0b7d0a 100644 --- a/src/lib/getCurrencyOptions.tsx +++ b/src/lib/getCurrencyOptions.tsx @@ -1,8 +1,14 @@ import { MenuItem } from '@mui/material'; import { Currency } from 'src/graphql/types.generated'; +export enum PledgeCurrencyOptionFormatEnum { + Long = 'long', + Short = 'short', +} + export const getPledgeCurrencyOptions = ( pledgeCurrencies: Currency[] | undefined | null, + format = PledgeCurrencyOptionFormatEnum.Long, ) => { return pledgeCurrencies?.map( ({ code, codeSymbolString, name }) => @@ -10,7 +16,9 @@ export const getPledgeCurrencyOptions = ( code && codeSymbolString && ( - {name + ' - ' + codeSymbolString} + {format === PledgeCurrencyOptionFormatEnum.Long + ? name + ' - ' + codeSymbolString + : codeSymbolString} ), );