From dc24b4934aee55f458d409ef4e857b29ef81d43a Mon Sep 17 00:00:00 2001 From: Caleb Alldrin Date: Tue, 18 Jun 2024 08:53:09 -0700 Subject: [PATCH 01/13] Update mergeContacts rest proxy and fix affected components --- .../Schema/MergeContacts/mergeContacts.graphql | 17 +++++++---------- pages/api/Schema/MergeContacts/resolvers.ts | 7 ++----- pages/api/graphql-rest.page.ts | 15 +++++++++------ .../MassActions/Merge/MassActionsMerge.graphql | 9 ++------- .../Merge/MassActionsMergeModal.test.tsx | 10 ++++++++-- .../MassActions/Merge/MassActionsMergeModal.tsx | 15 ++++++++++----- 6 files changed, 38 insertions(+), 35 deletions(-) diff --git a/pages/api/Schema/MergeContacts/mergeContacts.graphql b/pages/api/Schema/MergeContacts/mergeContacts.graphql index cf3ddc5de..0fdc8a785 100644 --- a/pages/api/Schema/MergeContacts/mergeContacts.graphql +++ b/pages/api/Schema/MergeContacts/mergeContacts.graphql @@ -2,17 +2,14 @@ extend type Mutation { """ Returns the ids of the winner """ - mergeContacts(input: MergeContactsInput!): ID! + mergeContacts(input: MergeContactsInput!): [ID!]! } -input MergeContactsInput { - """ - The ids of the contacts to make the losers of the merge - """ - loserContactIds: [ID!]! +input WinnersAndLosers { + winner_id: ID! + loser_id: ID! +} - """ - The id of the contact to make the winner of the merge - """ - winnerContactId: ID! +input MergeContactsInput { + winnersAndLosers: [WinnersAndLosers!]! } diff --git a/pages/api/Schema/MergeContacts/resolvers.ts b/pages/api/Schema/MergeContacts/resolvers.ts index 5fd6edef1..5ab1d9c54 100644 --- a/pages/api/Schema/MergeContacts/resolvers.ts +++ b/pages/api/Schema/MergeContacts/resolvers.ts @@ -4,13 +4,10 @@ const MergeContactsResolvers: Resolvers = { Mutation: { mergeContacts: ( _source, - { input: { loserContactIds, winnerContactId } }, + { input: { winnersAndLosers } }, { dataSources }, ) => { - 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..9ee60b46a 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.loser_id, + winner_id: contact.winner_id, }, }, })), }); - // 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..a7f51ddbf 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: [ + { + loser_id: 'contact-2', + winner_id: 'contact-1', + }, + ], + }, }); }); diff --git a/src/components/Contacts/MassActions/Merge/MassActionsMergeModal.tsx b/src/components/Contacts/MassActions/Merge/MassActionsMergeModal.tsx index e90709255..ea46f2f82 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 { winner_id: primaryContactId, loser_id: 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.loser_id}` }); }); cache.gc(); }, From 560f331c9e56ac6c14a5d07a4e0c0da4a7808b38 Mon Sep 17 00:00:00 2001 From: Caleb Alldrin Date: Tue, 18 Jun 2024 08:59:51 -0700 Subject: [PATCH 02/13] Update UI and styling --- src/components/Tool/MergeContacts/Contact.tsx | 172 ++++++++------- .../GetContactDuplicates.graphql | 3 +- .../Tool/MergeContacts/MergeContacts.test.tsx | 208 ++++++++++++++++++ .../Tool/MergeContacts/MergeContacts.tsx | 103 ++++++--- 4 files changed, 370 insertions(+), 116 deletions(-) create mode 100644 src/components/Tool/MergeContacts/MergeContacts.test.tsx diff --git a/src/components/Tool/MergeContacts/Contact.tsx b/src/components/Tool/MergeContacts/Contact.tsx index d758f2b02..276acf033 100644 --- a/src/components/Tool/MergeContacts/Contact.tsx +++ b/src/components/Tool/MergeContacts/Contact.tsx @@ -15,6 +15,7 @@ import { IconButton, Typography, } from '@mui/material'; +import { styled } from '@mui/material/styles'; import { DateTime } from 'luxon'; import { useTranslation } from 'react-i18next'; import { makeStyles } from 'tss-react/mui'; @@ -48,6 +49,7 @@ const useStyles = makeStyles()(() => ({ height: '100%', width: '45%', position: 'relative', + padding: theme.spacing(2), '&:hover': { cursor: 'pointer', }, @@ -56,6 +58,14 @@ const useStyles = makeStyles()(() => ({ width: '100%', }, }, + selectedBox: { + border: '2px solid', + borderColor: theme.palette.mpdxGreen.main, + }, + unselectedBox: { + border: '2px solid', + borderColor: theme.palette.cruGrayMedium.main, + }, selected: { position: 'absolute', top: 0, @@ -70,6 +80,32 @@ const useStyles = makeStyles()(() => ({ overflow: 'auto', scrollbarWidth: 'thin', }, + 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%', + }, })); interface Props { @@ -83,7 +119,6 @@ const Contact: React.FC = ({ contact1, contact2, update }) => { 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 => { @@ -107,7 +142,11 @@ const Contact: React.FC = ({ contact1, contact2, update }) => { }; return ( - + @@ -120,15 +159,15 @@ const Contact: React.FC = ({ contact1, contact2, update }) => { updateState('left')} - p={2} - style={{ - border: - selected === 'left' - ? `1px solid ${theme.palette.mpdxGreen.main}` - : `1px solid ${theme.palette.cruGrayMedium.main}`, - }} > = ({ contact1, contact2, update }) => { {t('From: {{where}}', { where: contact1.source })} - {t('On: {{when}}', { - when: dateFormatShort( - DateTime.fromISO(contact1.createdAt), - locale, - ), - })} + {t('On:')}{' '} + {dateFormatShort( + DateTime.fromISO(contact1.createdAt), + locale, + )} - + updateState('left')} - style={{ - color: - selected === 'left' - ? theme.palette.mpdxGreen.main - : theme.palette.cruGrayMedium.main, - }} + className={ + selected === 'left' ? classes.green : classes.grey + } > updateState('right')} - style={{ - color: - selected === 'right' - ? theme.palette.mpdxGreen.main - : theme.palette.cruGrayMedium.main, - }} + className={ + selected === 'right' ? classes.green : classes.grey + } > updateState('cancel')} - style={{ - color: - selected === 'cancel' - ? 'red' - : theme.palette.cruGrayMedium.main, - }} + className={ + selected === 'cancel' ? classes.red : classes.grey + } > - + - - + + updateState('left')} - style={{ - color: - selected === 'left' - ? theme.palette.mpdxGreen.main - : theme.palette.cruGrayMedium.main, - }} + className={ + selected === 'left' ? classes.green : classes.grey + } > updateState('right')} - style={{ - color: - selected === 'right' - ? theme.palette.mpdxGreen.main - : theme.palette.cruGrayMedium.main, - }} + className={ + selected === 'right' ? classes.green : classes.grey + } > updateState('cancel')} - style={{ - color: - selected === 'cancel' - ? 'red' - : theme.palette.cruGrayMedium.main, - }} + className={ + selected === 'cancel' ? classes.red : classes.grey + } > - + updateState('right')} - p={2} - style={{ - border: + className={` + ${classes.contactBasic} + ${ selected === 'right' - ? `1px solid ${theme.palette.mpdxGreen.main}` - : `1px solid ${theme.palette.cruGrayMedium.main}`, - }} + ? classes.selectedBox + : classes.unselectedBox + } + `} + onClick={() => updateState('right')} > = ({ contact1, contact2, update }) => { {t('From: {{where}}', { where: contact2.source })} - {t('On: {{when}}', { - when: dateFormatShort( - DateTime.fromISO(contact2.createdAt), - locale, - ), - })} + {t('On:')}{' '} + {dateFormatShort( + DateTime.fromISO(contact2.createdAt), + locale, + )} diff --git a/src/components/Tool/MergeContacts/GetContactDuplicates.graphql b/src/components/Tool/MergeContacts/GetContactDuplicates.graphql index 92b852f75..98b0ad509 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, first: 10) { + totalCount nodes { id reason diff --git a/src/components/Tool/MergeContacts/MergeContacts.test.tsx b/src/components/Tool/MergeContacts/MergeContacts.test.tsx new file mode 100644 index 000000000..df0c0caa2 --- /dev/null +++ b/src/components/Tool/MergeContacts/MergeContacts.test.tsx @@ -0,0 +1,208 @@ +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 { StatusEnum } from 'src/graphql/types.generated'; +import theme from 'src/theme'; +import { GetContactDuplicatesQuery } from './GetContactDuplicates.generated'; +import MergeContacts from './MergeContacts'; + +const accountListId = '123'; + +const mocks = { + 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', + }, + }, + }, + ], + }, + }, +}; + +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={mocks} + 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', + ); + }); + + // TODO + // it('should select clicked contact', async () => { + // const { queryAllByTestId } = render(); + + // await waitFor(() => + // expect(queryAllByTestId('MassActionsMergeModalContact')).toHaveLength(2), + // ); + + // const contacts = queryAllByTestId('MassActionsMergeModalContact'); + // expect(queryByText(contacts[0], 'Use This One')).toBeInTheDocument(); + // expect(queryByText(contacts[1], 'Use This One')).not.toBeInTheDocument(); + + // userEvent.click(contacts[1]); + + // expect(queryByText(contacts[0], 'Use This One')).not.toBeInTheDocument(); + // expect(queryByText(contacts[1], 'Use This One')).toBeInTheDocument(); + // }); + + 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('Doe, John and Nancy')); + expect(await findByText('Use this one')).toBeInTheDocument(); + expect(confirmButton).not.toBeDisabled(); + + userEvent.click(confirmButton); + await waitFor(() => + expect(mockEnqueue).toHaveBeenCalledWith('Contacts merged!', { + 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: [ + { + loser_id: 'contact-1', + winner_id: 'contact-2', + }, + ], + }, + }); + }); +}); diff --git a/src/components/Tool/MergeContacts/MergeContacts.tsx b/src/components/Tool/MergeContacts/MergeContacts.tsx index 935e6b96f..a7ba320cd 100644 --- a/src/components/Tool/MergeContacts/MergeContacts.tsx +++ b/src/components/Tool/MergeContacts/MergeContacts.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Box, Button, @@ -7,8 +7,10 @@ import { Grid, Typography, } from '@mui/material'; +import { useSnackbar } from 'notistack'; import { Trans, useTranslation } from 'react-i18next'; import { makeStyles } from 'tss-react/mui'; +import { useMassActionsMergeMutation } from 'src/components/Contacts/MassActions/Merge/MassActionsMerge.generated'; import useGetAppSettings from 'src/hooks/useGetAppSettings'; import theme from '../../../theme'; import NoData from '../NoData'; @@ -18,12 +20,9 @@ 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')]: { + [theme.breakpoints.down('lg')]: { width: '100%', }, }, @@ -45,11 +44,6 @@ const useStyles = makeStyles()(() => ({ display: 'flex', justifyContent: 'center', }, - confirmButton: { - backgroundColor: theme.palette.mpdxBlue.main, - width: 200, - color: 'white', - }, })); interface ActionType { @@ -65,10 +59,17 @@ const MergeContacts: React.FC = ({ accountListId }: Props) => { 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 updateActions = (id1: string, id2: string, action: string): void => { if (action === 'cancel') { @@ -85,21 +86,47 @@ const MergeContacts: React.FC = ({ accountListId }: Props) => { })); } }; + const handleConfirmAndContinue = () => { + mergeContacts(); + }; + const handleConfirmAndLeave = () => { + mergeContacts(); + window.location.href = `${process.env.SITE_URL}/accountLists/${accountListId}/tools`; + }; - 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: { winner_id: string; loser_id: string }[] = + mergeActions.map((action) => { + return { winner_id: action[0], loser_id: 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.loser_id}` }); + }); + cache.gc(); + }, + onCompleted: () => { + enqueueSnackbar(t('Contacts merged!'), { + variant: 'success', + }); + }, + onError: (err) => { + enqueueSnackbar(t('A server error occurred. {{err}}', { err }), { + variant: 'error', + }); + }, + }); } }; @@ -119,12 +146,15 @@ const MergeContacts: React.FC = ({ accountListId }: Props) => { {data?.contactDuplicates.nodes.length > 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. ', + 'You have {{totalCount}} 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, + totalCount: data?.contactDuplicates.totalCount, appName, }, )} @@ -154,8 +184,8 @@ const MergeContacts: React.FC = ({ accountListId }: Props) => { > @@ -164,7 +194,11 @@ const MergeContacts: React.FC = ({ accountListId }: Props) => { {t('OR')} - @@ -173,9 +207,12 @@ const MergeContacts: React.FC = ({ accountListId }: Props) => { }} /> From 7994eedbdd4fee8a3b1a0eec7bffbd0917e67690 Mon Sep 17 00:00:00 2001 From: Caleb Alldrin Date: Tue, 18 Jun 2024 22:13:30 -0700 Subject: [PATCH 03/13] UI and Styling Changes and code refactoring --- .../MergeContacts/mergeContacts.graphql | 4 +- pages/api/graphql-rest.page.ts | 4 +- .../Merge/MassActionsMergeModal.test.tsx | 4 +- .../Merge/MassActionsMergeModal.tsx | 4 +- src/components/Tool/MergeContacts/Contact.tsx | 379 +++++++++--------- .../GetContactDuplicates.graphql | 3 +- .../Tool/MergeContacts/MergeContacts.test.tsx | 25 +- .../Tool/MergeContacts/MergeContacts.tsx | 123 +++--- 8 files changed, 269 insertions(+), 277 deletions(-) diff --git a/pages/api/Schema/MergeContacts/mergeContacts.graphql b/pages/api/Schema/MergeContacts/mergeContacts.graphql index 0fdc8a785..fa26885b6 100644 --- a/pages/api/Schema/MergeContacts/mergeContacts.graphql +++ b/pages/api/Schema/MergeContacts/mergeContacts.graphql @@ -6,8 +6,8 @@ extend type Mutation { } input WinnersAndLosers { - winner_id: ID! - loser_id: ID! + winnerId: ID! + loserId: ID! } input MergeContactsInput { diff --git a/pages/api/graphql-rest.page.ts b/pages/api/graphql-rest.page.ts index 9ee60b46a..fedc116d3 100644 --- a/pages/api/graphql-rest.page.ts +++ b/pages/api/graphql-rest.page.ts @@ -210,8 +210,8 @@ class MpdxRestApi extends RESTDataSource { data: { type: 'contacts', attributes: { - loser_id: contact.loser_id, - winner_id: contact.winner_id, + loser_id: contact.loserId, + winner_id: contact.winnerId, }, }, })), diff --git a/src/components/Contacts/MassActions/Merge/MassActionsMergeModal.test.tsx b/src/components/Contacts/MassActions/Merge/MassActionsMergeModal.test.tsx index a7f51ddbf..18ddfeb6a 100644 --- a/src/components/Contacts/MassActions/Merge/MassActionsMergeModal.test.tsx +++ b/src/components/Contacts/MassActions/Merge/MassActionsMergeModal.test.tsx @@ -167,8 +167,8 @@ describe('MassActionsMergeModal', () => { input: { winnersAndLosers: [ { - loser_id: 'contact-2', - winner_id: 'contact-1', + 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 ea46f2f82..704d20ca5 100644 --- a/src/components/Contacts/MassActions/Merge/MassActionsMergeModal.tsx +++ b/src/components/Contacts/MassActions/Merge/MassActionsMergeModal.tsx @@ -66,7 +66,7 @@ export const MassActionsMergeModal: React.FC = ({ const winnersAndLosers = ids .filter((id) => id !== primaryContactId) .map((id) => { - return { winner_id: primaryContactId, loser_id: id }; + return { winnerId: primaryContactId, loserId: id }; }); await contactsMerge({ variables: { @@ -77,7 +77,7 @@ export const MassActionsMergeModal: React.FC = ({ update: (cache) => { // Delete the loser contacts and remove dangling references to them winnersAndLosers.forEach((contact) => { - cache.evict({ id: `Contact:${contact.loser_id}` }); + cache.evict({ id: `Contact:${contact.loserId}` }); }); cache.gc(); }, diff --git a/src/components/Tool/MergeContacts/Contact.tsx b/src/components/Tool/MergeContacts/Contact.tsx index 276acf033..e2db573fc 100644 --- a/src/components/Tool/MergeContacts/Contact.tsx +++ b/src/components/Tool/MergeContacts/Contact.tsx @@ -10,14 +10,18 @@ import { Icon } from '@mdi/react'; import { Avatar, Box, + Card, + CardContent, + CardHeader, Grid, - Hidden, IconButton, + Tooltip, Typography, + useMediaQuery, } from '@mui/material'; import { styled } from '@mui/material/styles'; import { DateTime } from 'luxon'; -import { useTranslation } from 'react-i18next'; +import { TFunction, Trans, useTranslation } from 'react-i18next'; import { makeStyles } from 'tss-react/mui'; import { useLocale } from 'src/hooks/useLocale'; import { dateFormatShort } from 'src/lib/intlFormat'; @@ -45,41 +49,6 @@ const useStyles = makeStyles()(() => ({ flexDirection: 'column', }, }, - contactBasic: { - height: '100%', - width: '45%', - position: 'relative', - padding: theme.spacing(2), - '&:hover': { - cursor: 'pointer', - }, - [theme.breakpoints.down('sm')]: { - backgroundColor: 'white', - width: '100%', - }, - }, - selectedBox: { - border: '2px solid', - borderColor: theme.palette.mpdxGreen.main, - }, - unselectedBox: { - border: '2px solid', - borderColor: theme.palette.cruGrayMedium.main, - }, - 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', - }, green: { color: theme.palette.mpdxGreen.main, }, @@ -107,6 +76,140 @@ const IconWrapper = styled(Box)(({ theme }) => ({ 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; +} +const ContactItem: React.FC = ({ + contact, + updateState, + selected, + loser, + t, + side, +}) => { + 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(); + return ( + updateState(side)} + > + + } + title={ + <> + {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; @@ -117,9 +220,10 @@ interface Props { const Contact: React.FC = ({ contact1, contact2, update }) => { const [selected, setSelected] = useState('none'); const { t } = useTranslation(); - const locale = useLocale(); + const matches = useMediaQuery('(max-width:600px)'); const { classes } = useStyles(); - //TODO: Make contact title a link to contact page + const leftSelected = selected === 'left'; + const rightSelected = selected === 'right'; const updateState = (side: string): void => { switch (side) { @@ -156,113 +260,46 @@ const Contact: React.FC = ({ contact1, contact2, update }) => { style={{ width: '100%' }} className={classes.outer} > - updateState('left')} - > - - + + - {selected === 'left' && ( - - {t('Use this one')} - - )} - - {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:')}{' '} - {dateFormatShort( - DateTime.fromISO(contact1.createdAt), - locale, - )} - - - - - - updateState('left')} - className={ - selected === 'left' ? classes.green : classes.grey - } - > - - - updateState('right')} - className={ - selected === 'right' ? classes.green : classes.grey - } - > - - - updateState('cancel')} - className={ - selected === 'cancel' ? classes.red : classes.grey - } - > - - - - - - updateState('left')} - className={ - selected === 'left' ? classes.green : classes.grey - } + className={leftSelected ? classes.green : classes.grey} > - + + + updateState('right')} - className={ - selected === 'right' ? classes.green : classes.grey - } + className={rightSelected ? classes.green : classes.grey} > - + + + updateState('cancel')} className={ @@ -271,64 +308,16 @@ const Contact: React.FC = ({ contact1, contact2, update }) => { > - - - - updateState('right')} - > - - - {selected === 'right' && ( - - {t('Use this one')} - - )} - {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:')}{' '} - {dateFormatShort( - DateTime.fromISO(contact2.createdAt), - locale, - )} - - - + + + diff --git a/src/components/Tool/MergeContacts/GetContactDuplicates.graphql b/src/components/Tool/MergeContacts/GetContactDuplicates.graphql index 98b0ad509..006013c37 100644 --- a/src/components/Tool/MergeContacts/GetContactDuplicates.graphql +++ b/src/components/Tool/MergeContacts/GetContactDuplicates.graphql @@ -1,6 +1,6 @@ query GetContactDuplicates($accountListId: ID!) { # TODO: Eventually needs pagination (Jira issue: MPDX-7642) - contactDuplicates(accountListId: $accountListId, first: 10) { + contactDuplicates(accountListId: $accountListId, ignore: false, first: 10) { totalCount nodes { id @@ -28,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 index df0c0caa2..1a85ba5fc 100644 --- a/src/components/Tool/MergeContacts/MergeContacts.test.tsx +++ b/src/components/Tool/MergeContacts/MergeContacts.test.tsx @@ -146,24 +146,6 @@ describe('Tools - MergeContacts', () => { ); }); - // TODO - // it('should select clicked contact', async () => { - // const { queryAllByTestId } = render(); - - // await waitFor(() => - // expect(queryAllByTestId('MassActionsMergeModalContact')).toHaveLength(2), - // ); - - // const contacts = queryAllByTestId('MassActionsMergeModalContact'); - // expect(queryByText(contacts[0], 'Use This One')).toBeInTheDocument(); - // expect(queryByText(contacts[1], 'Use This One')).not.toBeInTheDocument(); - - // userEvent.click(contacts[1]); - - // expect(queryByText(contacts[0], 'Use This One')).not.toBeInTheDocument(); - // expect(queryByText(contacts[1], 'Use This One')).toBeInTheDocument(); - // }); - it('should merge contacts', async () => { const mutationSpy = jest.fn(); @@ -198,11 +180,14 @@ describe('Tools - MergeContacts', () => { input: { winnersAndLosers: [ { - loser_id: 'contact-1', - winner_id: 'contact-2', + loserId: 'contact-1', + winnerId: 'contact-2', }, ], }, }); + // await waitFor(() => + // expect(queryByText('Doe, John and Nancy')).not.toBeInTheDocument(), + // ); }); }); diff --git a/src/components/Tool/MergeContacts/MergeContacts.tsx b/src/components/Tool/MergeContacts/MergeContacts.tsx index a7ba320cd..5e49031c0 100644 --- a/src/components/Tool/MergeContacts/MergeContacts.tsx +++ b/src/components/Tool/MergeContacts/MergeContacts.tsx @@ -1,4 +1,5 @@ import React, { useMemo, useState } from 'react'; +import styled from '@emotion/styled'; import { Box, Button, @@ -22,6 +23,7 @@ const useStyles = makeStyles()(() => ({ padding: theme.spacing(3), width: '80%', display: 'flex', + height: '100dvh', [theme.breakpoints.down('lg')]: { width: '100%', }, @@ -37,7 +39,7 @@ const useStyles = makeStyles()(() => ({ marginBottom: theme.spacing(2), }, descriptionBox: { - marginBottom: theme.spacing(2), + marginBottom: theme.spacing(1), }, footer: { width: '100%', @@ -45,6 +47,25 @@ const useStyles = makeStyles()(() => ({ justifyContent: 'center', }, })); +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: '0', + zIndex: '100', + borderBottom: '1px solid', + borderBottomColor: theme.palette.cruGrayLight.main, + [theme.breakpoints.down('sm')]: { + flexDirection: 'column', + alignItems: 'start', + }, +})); interface ActionType { action: string; @@ -70,6 +91,8 @@ const MergeContacts: React.FC = ({ accountListId }: Props) => { [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') { @@ -86,12 +109,14 @@ const MergeContacts: React.FC = ({ accountListId }: Props) => { })); } }; - const handleConfirmAndContinue = () => { - mergeContacts(); + const handleConfirmAndContinue = async () => { + await mergeContacts(); }; - const handleConfirmAndLeave = () => { - mergeContacts(); - window.location.href = `${process.env.SITE_URL}/accountLists/${accountListId}/tools`; + const handleConfirmAndLeave = async () => { + await mergeContacts().then( + () => + (window.location.href = `${process.env.SITE_URL}/accountLists/${accountListId}/tools`), + ); }; const mergeContacts = async () => { @@ -99,9 +124,9 @@ const MergeContacts: React.FC = ({ accountListId }: Props) => { (action) => action[1].action === 'merge', ); if (mergeActions.length > 0) { - const winnersAndLosers: { winner_id: string; loser_id: string }[] = + const winnersAndLosers: { winnerId: string; loserId: string }[] = mergeActions.map((action) => { - return { winner_id: action[0], loser_id: action[1].mergeId || '' }; + return { winnerId: action[0], loserId: action[1].mergeId || '' }; }); await contactsMerge({ variables: { @@ -112,7 +137,7 @@ const MergeContacts: React.FC = ({ accountListId }: Props) => { update: (cache) => { // Delete the loser contacts and remove dangling references to them winnersAndLosers.forEach((contact) => { - cache.evict({ id: `Contact:${contact.loser_id}` }); + cache.evict({ id: `Contact:${contact.loserId}` }); }); cache.gc(); }, @@ -143,7 +168,7 @@ const MergeContacts: React.FC = ({ accountListId }: Props) => { {t('Merge Contacts')} - {data?.contactDuplicates.nodes.length > 0 ? ( + {showing > 0 ? ( <> = ({ accountListId }: Props) => { data-testid="ContactMergeDescription" > - {t( - 'You have {{totalCount}} 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. ', - { - totalCount: data?.contactDuplicates.totalCount, + }} + /> {t('This cannot be undone.')} - - {data?.contactDuplicates.nodes.map((duplicate) => ( - - ))} - - - + + + + , i: }} + /> + + + - - - {t('OR')} - - - - - - - }} + + + {data?.contactDuplicates.nodes + .map((duplicate) => ( + - - + )) + .reverse()} ) : ( From 7c65ab5ccc6459dacbbe9394de1dee33f1ed52c2 Mon Sep 17 00:00:00 2001 From: Caleb Alldrin Date: Wed, 19 Jun 2024 17:22:44 -0700 Subject: [PATCH 04/13] During mutation show loading spinner and prevent changes --- .../{Contact.tsx => ContactPair.tsx} | 44 ++++++++++------- .../Tool/MergeContacts/MergeContacts.tsx | 49 +++++++++++-------- 2 files changed, 55 insertions(+), 38 deletions(-) rename src/components/Tool/MergeContacts/{Contact.tsx => ContactPair.tsx} (92%) diff --git a/src/components/Tool/MergeContacts/Contact.tsx b/src/components/Tool/MergeContacts/ContactPair.tsx similarity index 92% rename from src/components/Tool/MergeContacts/Contact.tsx rename to src/components/Tool/MergeContacts/ContactPair.tsx index e2db573fc..4ff5d3933 100644 --- a/src/components/Tool/MergeContacts/Contact.tsx +++ b/src/components/Tool/MergeContacts/ContactPair.tsx @@ -215,9 +215,15 @@ interface Props { contact1: RecordInfoFragment; contact2: RecordInfoFragment; update: (id1: string, id2: string, action: string) => void; + updating: boolean; } -const Contact: React.FC = ({ contact1, contact2, update }) => { +const ContactPair: React.FC = ({ + contact1, + contact2, + update, + updating, +}) => { const [selected, setSelected] = useState('none'); const { t } = useTranslation(); const matches = useMediaQuery('(max-width:600px)'); @@ -226,22 +232,24 @@ const Contact: React.FC = ({ contact1, contact2, update }) => { const rightSelected = selected === 'right'; 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'); + 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'); + } } }; @@ -327,4 +335,4 @@ const Contact: React.FC = ({ contact1, contact2, update }) => { ); }; -export default Contact; +export default ContactPair; diff --git a/src/components/Tool/MergeContacts/MergeContacts.tsx b/src/components/Tool/MergeContacts/MergeContacts.tsx index 5e49031c0..9c6d7cf38 100644 --- a/src/components/Tool/MergeContacts/MergeContacts.tsx +++ b/src/components/Tool/MergeContacts/MergeContacts.tsx @@ -12,10 +12,11 @@ import { useSnackbar } from 'notistack'; import { Trans, useTranslation } from 'react-i18next'; import { makeStyles } from 'tss-react/mui'; 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()(() => ({ @@ -23,7 +24,7 @@ const useStyles = makeStyles()(() => ({ padding: theme.spacing(3), width: '80%', display: 'flex', - height: '100dvh', + height: 'auto', [theme.breakpoints.down('lg')]: { width: '100%', }, @@ -57,7 +58,8 @@ const ButtonHeaderBox = styled(Box)(() => ({ paddingBottom: theme.spacing(2), marginBottom: theme.spacing(2), position: 'sticky', - top: '0', + top: '64px', + // height: '50px', zIndex: '100', borderBottom: '1px solid', borderBottomColor: theme.palette.cruGrayLight.main, @@ -95,28 +97,31 @@ const MergeContacts: React.FC = ({ accountListId }: Props) => { 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`), - ); + await mergeContacts().then(() => { + window.location.href = `${process.env.SITE_URL}/accountLists/${accountListId}/tools`; + setActions({}); + }); }; const mergeContacts = async () => { @@ -162,6 +167,9 @@ const MergeContacts: React.FC = ({ accountListId }: Props) => { flexDirection="column" data-testid="Home" > + {(loading || updating) && ( + + )} {!loading && data ? ( @@ -226,11 +234,12 @@ const MergeContacts: React.FC = ({ accountListId }: Props) => { {data?.contactDuplicates.nodes .map((duplicate) => ( - )) .reverse()} From 4fc484eaabb3b207e6736b1b77cd69436148bde9 Mon Sep 17 00:00:00 2001 From: Caleb Alldrin Date: Wed, 19 Jun 2024 17:24:30 -0700 Subject: [PATCH 05/13] Allow for sticky submit button div --- src/components/GlobalStyles/GlobalStyles.tsx | 2 +- src/components/Layouts/Primary/Primary.tsx | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) 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..0a7eda970 100644 --- a/src/components/Layouts/Primary/Primary.tsx +++ b/src/components/Layouts/Primary/Primary.tsx @@ -15,13 +15,11 @@ const RootContainer = styled('div')(({ theme }) => ({ const ContentContainer = styled('div')(() => ({ display: 'flex', - overflow: 'hidden', })); const Content = styled('div')(() => ({ flex: '1 1 auto', height: '100%', - overflow: 'auto', })); interface Props { From 6a81bdadbf51592eb9f1b83af7fb65f52173f7fe Mon Sep 17 00:00:00 2001 From: Caleb Alldrin Date: Wed, 19 Jun 2024 17:55:23 -0700 Subject: [PATCH 06/13] Add test and adjust styling --- .../Tool/MergeContacts/ContactPair.tsx | 13 +++++----- .../Tool/MergeContacts/MergeContacts.test.tsx | 25 ++++++++++++++++--- .../Tool/MergeContacts/MergeContacts.tsx | 2 +- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/components/Tool/MergeContacts/ContactPair.tsx b/src/components/Tool/MergeContacts/ContactPair.tsx index 4ff5d3933..052a33871 100644 --- a/src/components/Tool/MergeContacts/ContactPair.tsx +++ b/src/components/Tool/MergeContacts/ContactPair.tsx @@ -181,13 +181,11 @@ const ContactItem: React.FC = ({ className={classes.minimalPadding} /> - {contact.primaryAddress ? ( + {contact.primaryAddress && ( {`${contact?.primaryAddress?.street} ${contact?.primaryAddress?.city}, ${contact?.primaryAddress?.state} ${contact?.primaryAddress?.postalCode}`} - ) : ( - '' )} = ({ updateState('left')} className={leftSelected ? classes.green : classes.grey} + data-testid="leftButton" > @@ -300,10 +299,11 @@ const ContactPair: React.FC = ({ updateState('right')} className={rightSelected ? classes.green : classes.grey} + data-testid="rightButton" > @@ -313,8 +313,9 @@ const ContactPair: React.FC = ({ className={ selected === 'cancel' ? classes.red : classes.grey } + data-testid="ignoreButton" > - + diff --git a/src/components/Tool/MergeContacts/MergeContacts.test.tsx b/src/components/Tool/MergeContacts/MergeContacts.test.tsx index 1a85ba5fc..b63f35a88 100644 --- a/src/components/Tool/MergeContacts/MergeContacts.test.tsx +++ b/src/components/Tool/MergeContacts/MergeContacts.test.tsx @@ -186,8 +186,27 @@ describe('Tools - MergeContacts', () => { ], }, }); - // await waitFor(() => - // expect(queryByText('Doe, John and Nancy')).not.toBeInTheDocument(), - // ); + }); + + 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(); }); }); diff --git a/src/components/Tool/MergeContacts/MergeContacts.tsx b/src/components/Tool/MergeContacts/MergeContacts.tsx index 9c6d7cf38..7fe0c6c6b 100644 --- a/src/components/Tool/MergeContacts/MergeContacts.tsx +++ b/src/components/Tool/MergeContacts/MergeContacts.tsx @@ -59,13 +59,13 @@ const ButtonHeaderBox = styled(Box)(() => ({ marginBottom: theme.spacing(2), position: 'sticky', top: '64px', - // height: '50px', zIndex: '100', borderBottom: '1px solid', borderBottomColor: theme.palette.cruGrayLight.main, [theme.breakpoints.down('sm')]: { flexDirection: 'column', alignItems: 'start', + top: '56px', }, })); From 52ee2ef7e61deeb0f73b7fe6a9d1aa0605503174 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Thu, 20 Jun 2024 14:38:17 -0400 Subject: [PATCH 07/13] Adding styles to the ToolWrapper so pages can customise styles for whole page. --- pages/accountLists/[accountListId]/tools/ToolsWrapper.tsx | 3 +++ .../tools/mergeContacts/[[...contactId]].page.tsx | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/pages/accountLists/[accountListId]/tools/ToolsWrapper.tsx b/pages/accountLists/[accountListId]/tools/ToolsWrapper.tsx index 89ff1df59..ae5693b84 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 ? ( { pageTitle={t('Merge Contacts')} pageUrl={pageUrl} selectedMenuId="mergeContacts" + Styles={ + + } > Date: Thu, 20 Jun 2024 12:39:47 -0700 Subject: [PATCH 08/13] Add contact link test --- .../[[...contactId]].page.test.tsx | 92 ++++++++++++++++ .../Tool/MergeContacts/ContactPair.tsx | 5 +- .../Tool/MergeContacts/MergeContacts.test.tsx | 102 ++++-------------- .../Tool/MergeContacts/MergeContactsMock.ts | 77 +++++++++++++ 4 files changed, 195 insertions(+), 81 deletions(-) create mode 100644 pages/accountLists/[accountListId]/tools/mergeContacts/[[...contactId]].page.test.tsx create mode 100644 src/components/Tool/MergeContacts/MergeContactsMock.ts diff --git a/pages/accountLists/[accountListId]/tools/mergeContacts/[[...contactId]].page.test.tsx b/pages/accountLists/[accountListId]/tools/mergeContacts/[[...contactId]].page.test.tsx new file mode 100644 index 000000000..bc3778043 --- /dev/null +++ b/pages/accountLists/[accountListId]/tools/mergeContacts/[[...contactId]].page.test.tsx @@ -0,0 +1,92 @@ +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 { getSession } from 'next-auth/react'; +import { SnackbarProvider } from 'notistack'; +import { I18nextProvider } from 'react-i18next'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { GetContactDuplicatesQuery } from 'src/components/Tool/MergeContacts/GetContactDuplicates.generated'; +import { getContactDuplicatesMocks } from 'src/components/Tool/MergeContacts/MergeContactsMock'; +import i18n from 'src/lib/i18n'; +import theme from 'src/theme'; +import MergeContactsPage from './[[...contactId]].page'; + +jest.mock('next-auth/react'); +jest.mock('next/router', () => ({ + 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/src/components/Tool/MergeContacts/ContactPair.tsx b/src/components/Tool/MergeContacts/ContactPair.tsx index 5a1d536c2..f37f6e127 100644 --- a/src/components/Tool/MergeContacts/ContactPair.tsx +++ b/src/components/Tool/MergeContacts/ContactPair.tsx @@ -174,7 +174,10 @@ const ContactItem: React.FC = ({ <> handleContactNameClick(contact.id)} + onClick={(e) => { + e.stopPropagation(); + handleContactNameClick(contact.id); + }} > {contact.name} {' '} diff --git a/src/components/Tool/MergeContacts/MergeContacts.test.tsx b/src/components/Tool/MergeContacts/MergeContacts.test.tsx index 9f8b51a68..b7861f628 100644 --- a/src/components/Tool/MergeContacts/MergeContacts.test.tsx +++ b/src/components/Tool/MergeContacts/MergeContacts.test.tsx @@ -6,91 +6,14 @@ 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 { StatusEnum } from 'src/graphql/types.generated'; 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 mocks = { - 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', - }, - }, - }, - ], - }, - }, -}; - const mockEnqueue = jest.fn(); jest.mock('notistack', () => ({ @@ -117,7 +40,7 @@ const MergeContactsWrapper: React.FC = ({ - mocks={mocks} + mocks={getContactDuplicatesMocks} onCall={mutationSpy} > { const confirmButton = getByRole('button', { name: 'Confirm and Continue' }); expect(confirmButton).toBeDisabled(); - userEvent.click(getByText('Doe, John and Nancy')); + userEvent.click(getByText('123 John St Orlando, FL 32832')); expect(await findByText('Use this one')).toBeInTheDocument(); expect(confirmButton).not.toBeDisabled(); @@ -214,4 +137,23 @@ describe('Tools - MergeContacts', () => { 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/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', + }, + }, + }, + ], + }, + }, +}; From acaafb1ee868374a29313f154fc44ca520bf0176 Mon Sep 17 00:00:00 2001 From: Caleb Alldrin Date: Thu, 20 Jun 2024 12:40:44 -0700 Subject: [PATCH 09/13] Fix sticky header overflow --- .../tools/mergeContacts/[[...contactId]].page.tsx | 3 ++- src/components/Layouts/Primary/Primary.tsx | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pages/accountLists/[accountListId]/tools/mergeContacts/[[...contactId]].page.tsx b/pages/accountLists/[accountListId]/tools/mergeContacts/[[...contactId]].page.tsx index c59e80103..edbe20f61 100644 --- a/pages/accountLists/[accountListId]/tools/mergeContacts/[[...contactId]].page.tsx +++ b/pages/accountLists/[accountListId]/tools/mergeContacts/[[...contactId]].page.tsx @@ -22,7 +22,8 @@ const MergeContactsPage: React.FC = () => { Styles={ } diff --git a/src/components/Layouts/Primary/Primary.tsx b/src/components/Layouts/Primary/Primary.tsx index 0a7eda970..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'; @@ -17,9 +18,10 @@ const ContentContainer = styled('div')(() => ({ display: 'flex', })); -const Content = styled('div')(() => ({ +const Content = styled(Box)(() => ({ flex: '1 1 auto', height: '100%', + overflow: 'auto', })); interface Props { From 427a3314c9693c5caa47fa8e0a7087a5bbd49a35 Mon Sep 17 00:00:00 2001 From: Caleb Alldrin Date: Thu, 20 Jun 2024 13:21:17 -0700 Subject: [PATCH 10/13] Update loading spinner location and snackbar message --- .../Tool/MergeContacts/MergeContacts.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/components/Tool/MergeContacts/MergeContacts.tsx b/src/components/Tool/MergeContacts/MergeContacts.tsx index 06512de76..e4a705ca4 100644 --- a/src/components/Tool/MergeContacts/MergeContacts.tsx +++ b/src/components/Tool/MergeContacts/MergeContacts.tsx @@ -152,7 +152,7 @@ const MergeContacts: React.FC = ({ cache.gc(); }, onCompleted: () => { - enqueueSnackbar(t('Contacts merged!'), { + enqueueSnackbar(t('Success!'), { variant: 'success', }); }, @@ -172,9 +172,6 @@ const MergeContacts: React.FC = ({ flexDirection="column" data-testid="Home" > - {(loading || updating) && ( - - )} {!loading && data ? ( @@ -204,6 +201,9 @@ const MergeContacts: React.FC = ({ + {(loading || updating) && ( + + )} @@ -218,6 +218,12 @@ const MergeContacts: React.FC = ({ /> + {(loading || updating) && ( + + )}