diff --git a/pages/accountLists/[accountListId]/tools/ToolsWrapper.tsx b/pages/accountLists/[accountListId]/tools/ToolsWrapper.tsx index 89ff1df59..b9d0bda42 100644 --- a/pages/accountLists/[accountListId]/tools/ToolsWrapper.tsx +++ b/pages/accountLists/[accountListId]/tools/ToolsWrapper.tsx @@ -12,12 +12,14 @@ interface ToolsWrapperProps { pageUrl: string; selectedMenuId: string; // for later use children: ReactElement>; + styles?: React.ReactNode; } export const ToolsWrapper: React.FC = ({ pageTitle, pageUrl, children, + styles, }) => { const { appName } = useGetAppSettings(); const { accountListId, selectedContactId, handleSelectContact } = @@ -29,6 +31,7 @@ export const ToolsWrapper: React.FC = ({ {appName} | {pageTitle} + {styles} {accountListId ? ( ({ + useRouter: jest.fn(), +})); +jest.mock('src/lib/helpScout', () => ({ + suggestArticles: jest.fn(), +})); +jest.mock('notistack', () => ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...jest.requireActual('notistack'), + useSnackbar: () => { + return { + enqueueSnackbar: 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 = () => ( + + + + mocks={getContactDuplicatesMocks} + > + + + + + + + + +); + +describe('MergeContactsPage', () => { + 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 { findByText, queryByTestId } = render(); + await waitFor(() => + expect(queryByTestId('loading')).not.toBeInTheDocument(), + ); + + const contactName = await findByText('Doe, John'); + + expect(contactName).toBeInTheDocument(); + userEvent.click(contactName); + + await waitFor(() => { + expect(pushFn).toHaveBeenCalledWith( + `/accountLists/${accountListId}/tools/mergeContacts/${'contact-1'}`, + ); + }); + }); +}); diff --git a/pages/accountLists/[accountListId]/tools/mergeContacts/[[...contactId]].page.tsx b/pages/accountLists/[accountListId]/tools/mergeContacts/[[...contactId]].page.tsx index a5ad4eaca..1984a548d 100644 --- a/pages/accountLists/[accountListId]/tools/mergeContacts/[[...contactId]].page.tsx +++ b/pages/accountLists/[accountListId]/tools/mergeContacts/[[...contactId]].page.tsx @@ -19,6 +19,14 @@ const MergeContactsPage: React.FC = () => { pageTitle={t('Merge Contacts')} pageUrl={pageUrl} selectedMenuId="mergeContacts" + styles={ + + } > { - return dataSources.mpdxRestApi.mergeContacts( - loserContactIds, - winnerContactId, - ); + return dataSources.mpdxRestApi.mergeContacts(winnersAndLosers); }, }, }; diff --git a/pages/api/graphql-rest.page.ts b/pages/api/graphql-rest.page.ts index 75854e4ca..fedc116d3 100644 --- a/pages/api/graphql-rest.page.ts +++ b/pages/api/graphql-rest.page.ts @@ -11,6 +11,7 @@ import { ExportFormatEnum, ExportLabelTypeEnum, ExportSortEnum, + MergeContactsInput, } from 'src/graphql/types.generated'; import schema from './Schema'; import { getAccountListAnalytics } from './Schema/AccountListAnalytics/dataHandler'; @@ -201,21 +202,23 @@ class MpdxRestApi extends RESTDataSource { return `${process.env.REST_API_URL}contacts/exports${pathAddition}/${data.id}.${format}`; } - async mergeContacts(loserContactIds: Array, winnerContactId: string) { + async mergeContacts( + winnersAndLosers: MergeContactsInput['winnersAndLosers'], + ) { const response = await this.post('contacts/merges/bulk', { - data: loserContactIds.map((loserId) => ({ + data: winnersAndLosers.map((contact) => ({ data: { type: 'contacts', attributes: { - loser_id: loserId, - winner_id: winnerContactId, + loser_id: contact.loserId, + winner_id: contact.winnerId, }, }, })), }); - // Return the id of the winner - return response[0].data.id; + // Return the id of the winners + return response.map((contact) => contact.data.id); } async getAccountListAnalytics( diff --git a/src/components/Contacts/MassActions/Merge/MassActionsMerge.graphql b/src/components/Contacts/MassActions/Merge/MassActionsMerge.graphql index 94dc724fa..2520688b0 100644 --- a/src/components/Contacts/MassActions/Merge/MassActionsMerge.graphql +++ b/src/components/Contacts/MassActions/Merge/MassActionsMerge.graphql @@ -1,10 +1,5 @@ -mutation MassActionsMerge($loserContactIds: [ID!]!, $winnerContactId: ID!) { - mergeContacts( - input: { - loserContactIds: $loserContactIds - winnerContactId: $winnerContactId - } - ) +mutation MassActionsMerge($input: MergeContactsInput!) { + mergeContacts(input: $input) } query GetContactsForMerging( diff --git a/src/components/Contacts/MassActions/Merge/MassActionsMergeModal.test.tsx b/src/components/Contacts/MassActions/Merge/MassActionsMergeModal.test.tsx index 76cbd2f66..18ddfeb6a 100644 --- a/src/components/Contacts/MassActions/Merge/MassActionsMergeModal.test.tsx +++ b/src/components/Contacts/MassActions/Merge/MassActionsMergeModal.test.tsx @@ -164,8 +164,14 @@ describe('MassActionsMergeModal', () => { .filter(({ operationName }) => operationName === 'MassActionsMerge'); expect(mergeCalls).toHaveLength(1); expect(mergeCalls[0].variables).toEqual({ - loserContactIds: ['contact-2'], - winnerContactId: 'contact-1', + input: { + winnersAndLosers: [ + { + loserId: 'contact-2', + winnerId: 'contact-1', + }, + ], + }, }); }); diff --git a/src/components/Contacts/MassActions/Merge/MassActionsMergeModal.tsx b/src/components/Contacts/MassActions/Merge/MassActionsMergeModal.tsx index e90709255..704d20ca5 100644 --- a/src/components/Contacts/MassActions/Merge/MassActionsMergeModal.tsx +++ b/src/components/Contacts/MassActions/Merge/MassActionsMergeModal.tsx @@ -63,16 +63,21 @@ export const MassActionsMergeModal: React.FC = ({ }); const mergeContacts = async () => { - const loserContactIds = ids.filter((id) => id !== primaryContactId); + const winnersAndLosers = ids + .filter((id) => id !== primaryContactId) + .map((id) => { + return { winnerId: primaryContactId, loserId: id }; + }); await contactsMerge({ variables: { - loserContactIds, - winnerContactId: primaryContactId, + input: { + winnersAndLosers, + }, }, update: (cache) => { // Delete the loser contacts and remove dangling references to them - loserContactIds.forEach((id) => { - cache.evict({ id: `Contact:${id}` }); + winnersAndLosers.forEach((contact) => { + cache.evict({ id: `Contact:${contact.loserId}` }); }); cache.gc(); }, diff --git a/src/components/GlobalStyles/GlobalStyles.tsx b/src/components/GlobalStyles/GlobalStyles.tsx index 62acab56d..39ed8df9b 100644 --- a/src/components/GlobalStyles/GlobalStyles.tsx +++ b/src/components/GlobalStyles/GlobalStyles.tsx @@ -14,10 +14,10 @@ const useStyles = makeStyles(() => '-moz-osx-font-smoothing': 'grayscale', height: '100%', width: '100%', - overflow: 'hidden', }, body: { height: '100%', + minHeight: '100vh', width: '100%', }, '#__next': { diff --git a/src/components/Layouts/Primary/Primary.tsx b/src/components/Layouts/Primary/Primary.tsx index 2905ea6fc..d9800a0ca 100644 --- a/src/components/Layouts/Primary/Primary.tsx +++ b/src/components/Layouts/Primary/Primary.tsx @@ -1,4 +1,5 @@ import React, { ReactElement, ReactNode, useState } from 'react'; +import { Box } from '@mui/material'; import { styled } from '@mui/material/styles'; import { NavBar } from 'src/components/Layouts/Primary/NavBar/NavBar'; import { useAccountListId } from 'src/hooks/useAccountListId'; @@ -15,10 +16,9 @@ const RootContainer = styled('div')(({ theme }) => ({ const ContentContainer = styled('div')(() => ({ display: 'flex', - overflow: 'hidden', })); -const Content = styled('div')(() => ({ +const Content = styled(Box)(() => ({ flex: '1 1 auto', height: '100%', overflow: 'auto', diff --git a/src/components/Tool/MergeContacts/Contact.tsx b/src/components/Tool/MergeContacts/Contact.tsx deleted file mode 100644 index 9beae2de0..000000000 --- a/src/components/Tool/MergeContacts/Contact.tsx +++ /dev/null @@ -1,357 +0,0 @@ -import React, { useState } from 'react'; -import { - mdiArrowDownBold, - mdiArrowLeftBold, - mdiArrowRightBold, - mdiArrowUpBold, - mdiCloseThick, -} from '@mdi/js'; -import { Icon } from '@mdi/react'; -import { - Avatar, - Box, - Grid, - Hidden, - IconButton, - Link, - Typography, -} from '@mui/material'; -import { DateTime } from 'luxon'; -import { useTranslation } from 'react-i18next'; -import { makeStyles } from 'tss-react/mui'; -import { SetContactFocus } from 'pages/accountLists/[accountListId]/tools/useToolsHelper'; -import { useLocale } from 'src/hooks/useLocale'; -import { dateFormatShort } from 'src/lib/intlFormat'; -import { contactPartnershipStatus } from 'src/utils/contacts/contactPartnershipStatus'; -import theme from '../../../theme'; -import { RecordInfoFragment } from './GetContactDuplicates.generated'; - -const useStyles = makeStyles()(() => ({ - container: { - display: 'flex', - alignItems: 'center', - marginBottom: theme.spacing(2), - [theme.breakpoints.down('sm')]: { - border: `1px solid ${theme.palette.cruGrayMedium.main}`, - padding: theme.spacing(2), - backgroundColor: theme.palette.cruGrayLight.main, - }, - }, - avatar: { - width: theme.spacing(7), - height: theme.spacing(7), - }, - outer: { - [theme.breakpoints.down('sm')]: { - flexDirection: 'column', - }, - }, - contactBasic: { - height: '100%', - width: '45%', - position: 'relative', - '&:hover': { - cursor: 'pointer', - }, - [theme.breakpoints.down('sm')]: { - backgroundColor: 'white', - width: '100%', - }, - }, - selected: { - position: 'absolute', - top: 0, - right: 0, - color: 'white', - backgroundColor: theme.palette.mpdxGreen.main, - paddingRight: theme.spacing(1), - paddingLeft: theme.spacing(1), - }, - contactInfo: { - width: '100%', - overflow: 'auto', - scrollbarWidth: 'thin', - }, -})); - -interface Props { - contact1: RecordInfoFragment; - contact2: RecordInfoFragment; - update: (id1: string, id2: string, action: string) => void; - setContactFocus: SetContactFocus; -} - -const Contact: React.FC = ({ - contact1, - contact2, - update, - setContactFocus, -}) => { - const [selected, setSelected] = useState('none'); - const { t } = useTranslation(); - const locale = useLocale(); - const { classes } = useStyles(); - //TODO: Add button functionality - //TODO: Make contact title a link to contact page - - const updateState = (side: string): void => { - switch (side) { - case 'left': - setSelected('left'); - update(contact1.id, contact2.id, 'merge'); - break; - case 'right': - setSelected('right'); - update(contact2.id, contact1.id, 'merge'); - break; - case 'cancel': - setSelected('cancel'); - update(contact1.id, contact2.id, 'cancel'); - break; - default: - setSelected(''); - update(contact1.id, contact2.id, 'cancel'); - } - }; - - const handleContactNameClick = (contactId) => { - setContactFocus(contactId); - }; - - return ( - - - - - - - updateState('left')} - p={2} - style={{ - border: - selected === 'left' - ? `1px solid ${theme.palette.mpdxGreen.main}` - : `1px solid ${theme.palette.cruGrayMedium.main}`, - }} - > - - - {selected === 'left' && ( - - {t('Use this one')} - - )} - - handleContactNameClick(contact1.id)} - > - {contact1.name} - - - {contact1.status && ( - - {t('Status: {{status}}', { - status: contactPartnershipStatus[contact1.status], - })} - - )} - {contact1.primaryAddress ? ( - <> - - {contact1.primaryAddress.street} - - {`${contact1.primaryAddress.city}, ${contact1.primaryAddress.state} ${contact1.primaryAddress.postalCode}`} - - ) : ( - '' - )} - - {t('From: {{where}}', { where: contact1.source })} - - - {t('On: {{when}}', { - when: dateFormatShort( - DateTime.fromISO(contact1.createdAt), - locale, - ), - })} - - - - - - updateState('left')} - style={{ - color: - selected === 'left' - ? theme.palette.mpdxGreen.main - : theme.palette.cruGrayMedium.main, - }} - > - - - updateState('right')} - style={{ - color: - selected === 'right' - ? theme.palette.mpdxGreen.main - : theme.palette.cruGrayMedium.main, - }} - > - - - updateState('cancel')} - style={{ - color: - selected === 'cancel' - ? 'red' - : theme.palette.cruGrayMedium.main, - }} - > - - - - - - - updateState('left')} - style={{ - color: - selected === 'left' - ? theme.palette.mpdxGreen.main - : theme.palette.cruGrayMedium.main, - }} - > - - - updateState('right')} - style={{ - color: - selected === 'right' - ? theme.palette.mpdxGreen.main - : theme.palette.cruGrayMedium.main, - }} - > - - - updateState('cancel')} - style={{ - color: - selected === 'cancel' - ? 'red' - : theme.palette.cruGrayMedium.main, - }} - > - - - - - - updateState('right')} - p={2} - style={{ - border: - selected === 'right' - ? `1px solid ${theme.palette.mpdxGreen.main}` - : `1px solid ${theme.palette.cruGrayMedium.main}`, - }} - > - - - {selected === 'right' && ( - - {t('Use this one')} - - )} - - handleContactNameClick(contact2.id)} - > - {contact2.name} - - - {contact2.status && ( - - {t('Status: {{status}}', { - status: contactPartnershipStatus[contact2.status], - })} - - )} - {contact2.primaryAddress ? ( - <> - - {contact2.primaryAddress.street} - - {`${contact2.primaryAddress.city}, ${contact2.primaryAddress.state} ${contact2.primaryAddress.postalCode}`} - - ) : ( - '' - )} - - {t('From: {{where}}', { where: contact2.source })} - - - {t('On: {{when}}', { - when: dateFormatShort( - DateTime.fromISO(contact2.createdAt), - locale, - ), - })} - - - - - - - - - - ); -}; - -export default Contact; diff --git a/src/components/Tool/MergeContacts/ContactPair.tsx b/src/components/Tool/MergeContacts/ContactPair.tsx new file mode 100644 index 000000000..dc95123f1 --- /dev/null +++ b/src/components/Tool/MergeContacts/ContactPair.tsx @@ -0,0 +1,360 @@ +import React, { useState } from 'react'; +import { + mdiArrowDownBold, + mdiArrowLeftBold, + mdiArrowRightBold, + mdiArrowUpBold, + mdiCloseThick, +} from '@mdi/js'; +import { Icon } from '@mdi/react'; +import { + Avatar, + Box, + Card, + CardContent, + CardHeader, + Grid, + IconButton, + Link, + Tooltip, + Typography, + useMediaQuery, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { DateTime } from 'luxon'; +import { TFunction, Trans, useTranslation } from 'react-i18next'; +import { makeStyles } from 'tss-react/mui'; +import { SetContactFocus } from 'pages/accountLists/[accountListId]/tools/useToolsHelper'; +import { useLocale } from 'src/hooks/useLocale'; +import { dateFormatShort } from 'src/lib/intlFormat'; +import { contactPartnershipStatus } from 'src/utils/contacts/contactPartnershipStatus'; +import theme from '../../../theme'; +import { RecordInfoFragment } from './GetContactDuplicates.generated'; + +const useStyles = makeStyles()(() => ({ + container: { + display: 'flex', + alignItems: 'center', + marginBottom: theme.spacing(2), + [theme.breakpoints.down('sm')]: { + border: `1px solid ${theme.palette.cruGrayMedium.main}`, + padding: theme.spacing(2), + backgroundColor: theme.palette.cruGrayLight.main, + }, + }, + avatar: { + width: theme.spacing(7), + height: theme.spacing(7), + }, + outer: { + [theme.breakpoints.down('sm')]: { + flexDirection: 'column', + }, + }, + green: { + color: theme.palette.mpdxGreen.main, + }, + grey: { + color: theme.palette.cruGrayMedium.main, + }, + red: { + color: 'red', + }, +})); + +const IconWrapper = styled(Box)(({ theme }) => ({ + [theme.breakpoints.down('sm')]: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + }, + [theme.breakpoints.up('sm')]: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + width: '10%', + }, +})); +const ContactAvatar = styled(Avatar)(() => ({ + width: theme.spacing(4), + height: theme.spacing(4), +})); +interface ContactItemProps { + contact: RecordInfoFragment; + side: string; + updateState: (side: string) => void; + selected: boolean; + loser: boolean; + t: TFunction; + setContactFocus: SetContactFocus; +} +const ContactItem: React.FC = ({ + contact, + updateState, + selected, + loser, + t, + side, + setContactFocus, +}) => { + const useStyles = makeStyles()(() => ({ + contactBasic: { + height: '100%', + width: '45%', + position: 'relative', + '&:hover': { + cursor: 'pointer', + }, + [theme.breakpoints.down('sm')]: { + backgroundColor: 'white', + width: '100%', + overflow: 'initial', + }, + }, + selectedBox: { + border: '2px solid', + borderColor: theme.palette.mpdxGreen.main, + }, + unselectedBox: { + border: '2px solid rgba(0,0,0,0)', + }, + loserBox: { + border: '2px solid rgba(0,0,0,0)', + opacity: '50%', + }, + selected: { + position: 'absolute', + top: 0, + right: 0, + color: 'white', + backgroundColor: theme.palette.mpdxGreen.main, + paddingRight: theme.spacing(1), + paddingLeft: theme.spacing(1), + borderTopRightRadius: '5px', + }, + minimalPadding: { + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + paddingTop: theme.spacing(1), + paddingBottom: '8px!important', + [theme.breakpoints.down('sm')]: { + padding: '5px 15px!important', + }, + }, + })); + const { classes } = useStyles(); + const locale = useLocale(); + const handleContactNameClick = (contactId) => { + setContactFocus(contactId); + }; + return ( + updateState(side)} + > + + } + title={ + <> + { + e.stopPropagation(); + handleContactNameClick(contact.id); + }} + > + + {contact.name} + + {' '} + {selected && ( + + {t('Use this one')} + + )} + + } + subheader={ + + {contact.status && contactPartnershipStatus[contact.status]} + + } + className={classes.minimalPadding} + /> + + {contact.primaryAddress && ( + + {`${contact?.primaryAddress?.street} + ${contact?.primaryAddress?.city}, ${contact?.primaryAddress?.state} ${contact?.primaryAddress?.postalCode}`} + + )} + + }} + /> + + + {t('Created:')}{' '} + + + {dateFormatShort(DateTime.fromISO(contact.createdAt), locale)} + + + + ); +}; + +interface Props { + contact1: RecordInfoFragment; + contact2: RecordInfoFragment; + update: (id1: string, id2: string, action: string) => void; + updating: boolean; + setContactFocus: SetContactFocus; +} + +const ContactPair: React.FC = ({ + contact1, + contact2, + update, + updating, + setContactFocus, +}) => { + const [selected, setSelected] = useState('none'); + const { t } = useTranslation(); + const matches = useMediaQuery('(max-width:600px)'); + const { classes } = useStyles(); + const leftSelected = selected === 'left'; + const rightSelected = selected === 'right'; + + const updateState = (side: string): void => { + if (!updating) { + switch (side) { + case 'left': + setSelected('left'); + update(contact1.id, contact2.id, 'merge'); + break; + case 'right': + setSelected('right'); + update(contact2.id, contact1.id, 'merge'); + break; + case 'cancel': + setSelected('cancel'); + update(contact1.id, contact2.id, 'cancel'); + break; + default: + setSelected(''); + update(contact1.id, contact2.id, 'cancel'); + } + } + }; + + return ( + + + + + + + + + + updateState('left')} + className={leftSelected ? classes.green : classes.grey} + data-testid="leftButton" + > + + + + + updateState('right')} + className={rightSelected ? classes.green : classes.grey} + data-testid="rightButton" + > + + + + + updateState('cancel')} + className={ + selected === 'cancel' ? classes.red : classes.grey + } + data-testid="ignoreButton" + > + + + + + + + + + + + + ); +}; + +export default ContactPair; diff --git a/src/components/Tool/MergeContacts/GetContactDuplicates.graphql b/src/components/Tool/MergeContacts/GetContactDuplicates.graphql index 92b852f75..006013c37 100644 --- a/src/components/Tool/MergeContacts/GetContactDuplicates.graphql +++ b/src/components/Tool/MergeContacts/GetContactDuplicates.graphql @@ -1,6 +1,7 @@ query GetContactDuplicates($accountListId: ID!) { # TODO: Eventually needs pagination (Jira issue: MPDX-7642) - contactDuplicates(accountListId: $accountListId, first: 50) { + contactDuplicates(accountListId: $accountListId, ignore: false, first: 10) { + totalCount nodes { id reason @@ -27,6 +28,7 @@ fragment RecordInfo on Contact { status source createdAt + avatar primaryAddress { ...BasicAddressInfo } diff --git a/src/components/Tool/MergeContacts/MergeContacts.test.tsx b/src/components/Tool/MergeContacts/MergeContacts.test.tsx new file mode 100644 index 000000000..a458f546b --- /dev/null +++ b/src/components/Tool/MergeContacts/MergeContacts.test.tsx @@ -0,0 +1,159 @@ +import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SnackbarProvider } from 'notistack'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { ContactsProvider } from 'src/components/Contacts/ContactsContext/ContactsContext'; +import theme from 'src/theme'; +import { GetContactDuplicatesQuery } from './GetContactDuplicates.generated'; +import MergeContacts from './MergeContacts'; +import { getContactDuplicatesMocks } from './MergeContactsMock'; + +const accountListId = '123'; + +const setContactFocus = 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, + }; + }, +})); + +interface MergeContactsWrapperProps { + mutationSpy?: () => void; +} + +const MergeContactsWrapper: React.FC = ({ + mutationSpy, +}) => { + return ( + + + + mocks={getContactDuplicatesMocks} + onCall={mutationSpy} + > + {}} + starredFilter={{}} + setStarredFilter={() => {}} + filterPanelOpen={false} + setFilterPanelOpen={() => {}} + contactId={[]} + searchTerm={''} + > + + + + + + ); +}; + +describe('Tools - MergeContacts', () => { + it('should render', async () => { + const { findByText, getByTestId } = render(); + + expect(await findByText('Merge Contacts')).toBeInTheDocument(); + expect(getByTestId('ContactMergeDescription').textContent).toMatch( + 'You have 55 possible duplicate contacts', + ); + }); + + it('should merge contacts', async () => { + const mutationSpy = jest.fn(); + + const { getByText, queryAllByTestId, findByText, getByRole } = render( + + + , + ); + + await waitFor(() => + expect(queryAllByTestId('MergeContactPair')).toHaveLength(2), + ); + const confirmButton = getByRole('button', { name: 'Confirm and Continue' }); + + expect(confirmButton).toBeDisabled(); + userEvent.click(getByText('123 John St Orlando, FL 32832')); + expect(await findByText('Use this one')).toBeInTheDocument(); + expect(confirmButton).not.toBeDisabled(); + + userEvent.click(confirmButton); + await waitFor(() => + expect(mockEnqueue).toHaveBeenCalledWith('Success!', { + variant: 'success', + }), + ); + + const mergeCalls = mutationSpy.mock.calls + .map(([{ operation }]) => operation) + .filter(({ operationName }) => operationName === 'MassActionsMerge'); + expect(mergeCalls).toHaveLength(1); + expect(mergeCalls[0].variables).toEqual({ + input: { + winnersAndLosers: [ + { + loserId: 'contact-1', + winnerId: 'contact-2', + }, + ], + }, + }); + }); + + it('should ignore contacts', async () => { + const mutationSpy = jest.fn(); + + const { queryByText, queryAllByTestId, findByText, getByRole } = render( + + + , + ); + + await waitFor(() => + expect(queryAllByTestId('MergeContactPair')).toHaveLength(2), + ); + const confirmButton = getByRole('button', { name: 'Confirm and Continue' }); + + expect(confirmButton).toBeDisabled(); + userEvent.click(queryAllByTestId('rightButton')[0]); + expect(await findByText('Use this one')).toBeInTheDocument(); + userEvent.click(queryAllByTestId('ignoreButton')[0]); + expect(queryByText('Use this one')).not.toBeInTheDocument(); + expect(confirmButton).not.toBeDisabled(); + }); + + describe('setContactFocus()', () => { + it('should open up contact details', async () => { + const mutationSpy = jest.fn(); + const { findByText, queryByTestId } = render( + , + ); + await waitFor(() => + expect(queryByTestId('loading')).not.toBeInTheDocument(), + ); + expect(setContactFocus).not.toHaveBeenCalled(); + + const contactName = await findByText('Doe, John and Nancy'); + + expect(contactName).toBeInTheDocument(); + userEvent.click(contactName); + expect(setContactFocus).toHaveBeenCalledWith('contact-2'); + }); + }); +}); diff --git a/src/components/Tool/MergeContacts/MergeContacts.tsx b/src/components/Tool/MergeContacts/MergeContacts.tsx index 5fdf9b1b3..25a1f65fb 100644 --- a/src/components/Tool/MergeContacts/MergeContacts.tsx +++ b/src/components/Tool/MergeContacts/MergeContacts.tsx @@ -1,4 +1,5 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; +import styled from '@emotion/styled'; import { Box, Button, @@ -7,24 +8,25 @@ import { Grid, Typography, } from '@mui/material'; +import { useSnackbar } from 'notistack'; import { Trans, useTranslation } from 'react-i18next'; import { makeStyles } from 'tss-react/mui'; import { SetContactFocus } from 'pages/accountLists/[accountListId]/tools/useToolsHelper'; +import { useMassActionsMergeMutation } from 'src/components/Contacts/MassActions/Merge/MassActionsMerge.generated'; +import { LoadingSpinner } from 'src/components/Settings/Organization/LoadingSpinner'; import useGetAppSettings from 'src/hooks/useGetAppSettings'; import theme from '../../../theme'; import NoData from '../NoData'; -import Contact from './Contact'; +import ContactPair from './ContactPair'; import { useGetContactDuplicatesQuery } from './GetContactDuplicates.generated'; const useStyles = makeStyles()(() => ({ container: { padding: theme.spacing(3), - width: '70%', + width: '80%', display: 'flex', - [theme.breakpoints.down('md')]: { - width: '80%', - }, - [theme.breakpoints.down('sm')]: { + height: 'auto', + [theme.breakpoints.down('lg')]: { width: '100%', }, }, @@ -39,17 +41,32 @@ const useStyles = makeStyles()(() => ({ marginBottom: theme.spacing(2), }, descriptionBox: { - marginBottom: theme.spacing(2), + marginBottom: theme.spacing(1), }, footer: { width: '100%', display: 'flex', justifyContent: 'center', }, - confirmButton: { - backgroundColor: theme.palette.mpdxBlue.main, - width: 200, - color: 'white', +})); +const ButtonHeaderBox = styled(Box)(() => ({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + width: '100%', + backgroundColor: 'white', + paddingTop: theme.spacing(2), + paddingBottom: theme.spacing(2), + marginBottom: theme.spacing(2), + position: 'sticky', + top: '64px', + zIndex: '100', + borderBottom: '1px solid', + borderBottomColor: theme.palette.cruGrayLight.main, + [theme.breakpoints.down('sm')]: { + flexDirection: 'column', + alignItems: 'start', + top: '56px', }, })); @@ -70,41 +87,81 @@ const MergeContacts: React.FC = ({ const { classes } = useStyles(); const [actions, setActions] = useState>({}); const { t } = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); const { data, loading } = useGetContactDuplicatesQuery({ variables: { accountListId }, }); const { appName } = useGetAppSettings(); + const [contactsMerge, { loading: updating }] = useMassActionsMergeMutation(); + const actionsLength = useMemo( + () => Object.entries(actions).length, + [actions], + ); + const disabled = updating || !actionsLength; + const totalCount = data?.contactDuplicates.totalCount || 0; + const showing = data?.contactDuplicates.nodes.length || 0; const updateActions = (id1: string, id2: string, action: string): void => { - if (action === 'cancel') { - setActions((prevState) => ({ - ...prevState, - [id1]: { action: '' }, - [id2]: { action: '' }, - })); - } else { - setActions((prevState) => ({ - ...prevState, - [id1]: { action: 'merge', mergeId: id2 }, - [id2]: { action: 'delete' }, - })); + if (!updating) { + if (action === 'cancel') { + setActions((prevState) => ({ + ...prevState, + [id1]: { action: '' }, + [id2]: { action: '' }, + })); + } else { + setActions((prevState) => ({ + ...prevState, + [id1]: { action: 'merge', mergeId: id2 }, + [id2]: { action: 'delete' }, + })); + } } }; + const handleConfirmAndContinue = async () => { + await mergeContacts(); + setActions({}); + }; + const handleConfirmAndLeave = async () => { + await mergeContacts().then(() => { + window.location.href = `${process.env.SITE_URL}/accountLists/${accountListId}/tools`; + setActions({}); + }); + }; - const testFnc = (): void => { - for (const [id, action] of Object.entries(actions)) { - switch (action.action) { - case 'merge': - // eslint-disable-next-line no-console - console.log(`Merging ${id} with ${action.mergeId}`); - break; - case 'delete': - // eslint-disable-next-line no-console - console.log(`Deleting ${id}`); - break; - default: - break; - } + const mergeContacts = async () => { + const mergeActions = Object.entries(actions).filter( + (action) => action[1].action === 'merge', + ); + if (mergeActions.length > 0) { + const winnersAndLosers: { winnerId: string; loserId: string }[] = + mergeActions.map((action) => { + return { winnerId: action[0], loserId: action[1].mergeId || '' }; + }); + await contactsMerge({ + variables: { + input: { + winnersAndLosers, + }, + }, + update: (cache) => { + // Delete the loser contacts and remove dangling references to them + winnersAndLosers.forEach((contact) => { + cache.evict({ id: `Contact:${contact.loserId}` }); + }); + cache.gc(); + }, + onCompleted: () => { + enqueueSnackbar(t('Success!'), { + variant: 'success', + }); + }, + onError: (err) => { + enqueueSnackbar(t('A server error occurred. {{err}}', { err }), { + variant: 'error', + }); + }, + }); } }; @@ -121,71 +178,80 @@ const MergeContacts: React.FC = ({ {t('Merge Contacts')} - {data?.contactDuplicates.nodes.length > 0 ? ( + {showing > 0 ? ( <> - + - {t( - 'You have {{amount}} possible duplicate contacts. This is sometimes caused when you imported data into {{appName}}. We recommend reconciling these as soon as possible. Please select the duplicate that should win the merge. No data will be lost. ', - { - amount: data?.contactDuplicates.nodes.length, + }} + /> {t('This cannot be undone.')} - - {data?.contactDuplicates.nodes.map((duplicate) => ( - + + + , i: }} + /> + + + {(loading || updating) && ( + - ))} - - - + )} + - - - {t('OR')} - - - - - - - - }} + + + {data?.contactDuplicates.nodes + .map((duplicate) => ( + - - + )) + .reverse()} ) : ( diff --git a/src/components/Tool/MergeContacts/MergeContactsMock.ts b/src/components/Tool/MergeContacts/MergeContactsMock.ts new file mode 100644 index 000000000..813973e6a --- /dev/null +++ b/src/components/Tool/MergeContacts/MergeContactsMock.ts @@ -0,0 +1,77 @@ +import { StatusEnum } from 'src/graphql/types.generated'; + +export const getContactDuplicatesMocks = { + GetContactDuplicates: { + contactDuplicates: { + totalCount: 55, + nodes: [ + { + id: '1', + recordOne: { + id: 'contact-1', + avatar: 'https://mpdx.org/images/avatar.png', + name: 'Doe, John', + createdAt: '2022-09-06T00:00:00-05:00', + status: null, + primaryAddress: { + id: 'address-1', + street: '123 Main St', + city: 'Orlando', + state: 'FL', + postalCode: '32832', + source: 'MPDX', + }, + }, + recordTwo: { + id: 'contact-2', + avatar: 'https://mpdx.org/images/avatar.png', + name: 'Doe, John and Nancy', + createdAt: '2020-09-06T00:00:00-05:00', + status: null, + primaryAddress: { + id: 'address-1', + street: '123 John St', + city: 'Orlando', + state: 'FL', + postalCode: '32832', + source: 'MPDX', + }, + }, + }, + { + id: '2', + recordOne: { + id: 'contact-3', + avatar: 'https://mpdx.org/images/avatar.png', + name: 'Doe, Jane', + createdAt: '2022-04-02T00:00:00-05:00', + status: StatusEnum.NeverContacted, + primaryAddress: { + id: 'address-2', + street: '123 First Ave', + city: 'Orlando', + state: 'FL', + postalCode: '32832', + source: 'MPDX', + }, + }, + recordTwo: { + id: 'contact-4', + avatar: 'https://mpdx.org/images/avatar.png', + name: 'Doe, Jane and Paul', + createdAt: '1999-04-02T00:00:00-05:00', + status: StatusEnum.NeverContacted, + primaryAddress: { + id: 'address-2', + street: '123 Leonard Ave', + city: 'Orlando', + state: 'FL', + postalCode: '32832', + source: 'MPDX', + }, + }, + }, + ], + }, + }, +};