diff --git a/pages/accountLists/[accountListId]/tools/mergeContacts/[[...contactId]].page.tsx b/pages/accountLists/[accountListId]/tools/mergeContacts/[[...contactId]].page.tsx index 1984a548d..256bd9857 100644 --- a/pages/accountLists/[accountListId]/tools/mergeContacts/[[...contactId]].page.tsx +++ b/pages/accountLists/[accountListId]/tools/mergeContacts/[[...contactId]].page.tsx @@ -24,7 +24,10 @@ const MergeContactsPage: React.FC = () => { div.MuiBox-root { overflow-x: visible; overflow-y: visible; - }, + } + div.MuiBox-root#scrollOverride { + overflow-y: auto; + } `} } > diff --git a/pages/accountLists/[accountListId]/tools/mergePeople/[[...contactId]].page.test.tsx b/pages/accountLists/[accountListId]/tools/mergePeople/[[...contactId]].page.test.tsx new file mode 100644 index 000000000..449d809f5 --- /dev/null +++ b/pages/accountLists/[accountListId]/tools/mergePeople/[[...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 { GetPersonDuplicatesQuery } from 'src/components/Tool/MergePeople/GetPersonDuplicates.generated'; +import { getPersonDuplicatesMocks } from 'src/components/Tool/MergePeople/PersonDuplicatesMock'; +import i18n from 'src/lib/i18n'; +import theme from 'src/theme'; +import MergePeoplePage 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={getPersonDuplicatesMocks} + > + + + + + + + + +); + +describe('MergePeoplePage', () => { + 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('John Doe'); + + expect(contactName).toBeInTheDocument(); + userEvent.click(contactName); + + await waitFor(() => { + expect(pushFn).toHaveBeenCalledWith( + `/accountLists/${accountListId}/tools/mergePeople/${'contact-1'}`, + ); + }); + }); +}); diff --git a/pages/accountLists/[accountListId]/tools/mergePeople/[[...contactId]].page.tsx b/pages/accountLists/[accountListId]/tools/mergePeople/[[...contactId]].page.tsx index 251c96f74..752ab21d1 100644 --- a/pages/accountLists/[accountListId]/tools/mergePeople/[[...contactId]].page.tsx +++ b/pages/accountLists/[accountListId]/tools/mergePeople/[[...contactId]].page.tsx @@ -19,6 +19,17 @@ const MergePeoplePage: React.FC = () => { pageTitle={t('Merge People')} pageUrl={pageUrl} selectedMenuId="mergePeople" + styles={ + + } > { + return dataSources.mpdxRestApi.mergePeopleBulk(winnersAndLosers); + }, + }, +}; + +export { MergePeopleBulkResolvers }; diff --git a/pages/api/Schema/index.ts b/pages/api/Schema/index.ts index 73c0d3e3a..4ccf32fe1 100644 --- a/pages/api/Schema/index.ts +++ b/pages/api/Schema/index.ts @@ -15,6 +15,8 @@ import ExportContactsTypeDefs from './ExportContacts/exportContacts.graphql'; import { ExportContactsResolvers } from './ExportContacts/resolvers'; import MergeContactsTypeDefs from './MergeContacts/mergeContacts.graphql'; import { MergeContactsResolvers } from './MergeContacts/resolvers'; +import MergePeopleBulkTypeDefs from './MergePeople/mergePeopleBulk.graphql'; +import { MergePeopleBulkResolvers } from './MergePeople/resolvers'; import { integrationSchema } from './SubgraphSchema/Integrations'; import { organizationSchema } from './SubgraphSchema/Organizations'; import { preferencesSchema } from './SubgraphSchema/Preferences'; @@ -76,6 +78,7 @@ const schema = buildSubgraphSchema([ }, { typeDefs: ExportContactsTypeDefs, resolvers: ExportContactsResolvers }, { typeDefs: MergeContactsTypeDefs, resolvers: MergeContactsResolvers }, + { typeDefs: MergePeopleBulkTypeDefs, resolvers: MergePeopleBulkResolvers }, { typeDefs: FourteenMonthReportTypeDefs, resolvers: FourteenMonthReportResolvers, diff --git a/pages/api/graphql-rest.page.ts b/pages/api/graphql-rest.page.ts index fedc116d3..2e3bc3fdc 100644 --- a/pages/api/graphql-rest.page.ts +++ b/pages/api/graphql-rest.page.ts @@ -12,6 +12,7 @@ import { ExportLabelTypeEnum, ExportSortEnum, MergeContactsInput, + MergePeopleBulkInput, } from 'src/graphql/types.generated'; import schema from './Schema'; import { getAccountListAnalytics } from './Schema/AccountListAnalytics/dataHandler'; @@ -221,6 +222,23 @@ class MpdxRestApi extends RESTDataSource { return response.map((contact) => contact.data.id); } + async mergePeopleBulk( + winnersAndLosers: MergePeopleBulkInput['winnersAndLosers'], + ) { + const response = await this.post('contacts/people/merges/bulk', { + data: winnersAndLosers.map((person) => ({ + data: { + type: 'people', + attributes: { + loser_id: person.loserId, + winner_id: person.winnerId, + }, + }, + })), + }); + return response.map((person) => person.data.id); + } + async getAccountListAnalytics( accountListId: string, dateRange?: string | null, diff --git a/src/components/Layouts/SidePanelsLayout.tsx b/src/components/Layouts/SidePanelsLayout.tsx index 6ae7716b2..772b522da 100644 --- a/src/components/Layouts/SidePanelsLayout.tsx +++ b/src/components/Layouts/SidePanelsLayout.tsx @@ -126,6 +126,7 @@ export const SidePanelsLayout: FC = ({ style={{ transform: rightOpen ? 'none' : 'translate(100%)', }} + id="scrollOverride" > {rightOpen && rightPanel} diff --git a/src/components/Tool/MergeContacts/ContactPair.tsx b/src/components/Tool/MergeContacts/ContactPair.tsx index dc95123f1..cab5c1598 100644 --- a/src/components/Tool/MergeContacts/ContactPair.tsx +++ b/src/components/Tool/MergeContacts/ContactPair.tsx @@ -29,6 +29,7 @@ import { useLocale } from 'src/hooks/useLocale'; import { dateFormatShort } from 'src/lib/intlFormat'; import { contactPartnershipStatus } from 'src/utils/contacts/contactPartnershipStatus'; import theme from '../../../theme'; +import { PersonInfoFragment } from '../MergePeople/GetPersonDuplicates.generated'; import { RecordInfoFragment } from './GetContactDuplicates.generated'; const useStyles = makeStyles()(() => ({ @@ -83,7 +84,7 @@ const ContactAvatar = styled(Avatar)(() => ({ height: theme.spacing(4), })); interface ContactItemProps { - contact: RecordInfoFragment; + contact: RecordInfoFragment | PersonInfoFragment; side: string; updateState: (side: string) => void; selected: boolean; @@ -145,11 +146,16 @@ const ContactItem: React.FC = ({ }, }, })); + const InlineTypography = styled(Typography)(() => ({ + display: 'inline', + })); const { classes } = useStyles(); const locale = useLocale(); const handleContactNameClick = (contactId) => { setContactFocus(contactId); }; + const isPersonType = contact.__typename === 'Person'; + const isContactType = contact.__typename === 'Contact'; return ( = ({ underline="hover" onClick={(e) => { e.stopPropagation(); - handleContactNameClick(contact.id); + handleContactNameClick( + isPersonType + ? contact.contactId + : isContactType + ? contact.id + : null, + ); }} > - - {contact.name} - + + {isPersonType + ? `${contact.firstName} ${contact.lastName}` + : isContactType + ? contact.name + : null} + {' '} {selected && ( @@ -191,44 +207,81 @@ const ContactItem: React.FC = ({ } subheader={ - - {contact.status && contactPartnershipStatus[contact.status]} - + isContactType && ( + + {contact?.status && contactPartnershipStatus[contact?.status]} + + ) } className={classes.minimalPadding} /> - {contact.primaryAddress && ( + {isContactType && contact.primaryAddress && ( {`${contact?.primaryAddress?.street} ${contact?.primaryAddress?.city}, ${contact?.primaryAddress?.state} ${contact?.primaryAddress?.postalCode}`} )} - - }} - /> - - - {t('Created:')}{' '} - - - {dateFormatShort(DateTime.fromISO(contact.createdAt), locale)} - + {isContactType && ( + + }} + /> + + )} + {isPersonType && contact.primaryPhoneNumber && ( + + + {`${contact?.primaryPhoneNumber?.number}`} + + + + }} + /> + + + + )} + {isPersonType && contact.primaryEmailAddress && ( + + + {`${contact?.primaryEmailAddress?.email}`} + + + + }} + /> + + + + )} + + + {t('Created:')}{' '} + + + {dateFormatShort(DateTime.fromISO(contact.createdAt), locale)} + + ); }; interface Props { - contact1: RecordInfoFragment; - contact2: RecordInfoFragment; + contact1: RecordInfoFragment | PersonInfoFragment; + contact2: RecordInfoFragment | PersonInfoFragment; update: (id1: string, id2: string, action: string) => void; updating: boolean; setContactFocus: SetContactFocus; diff --git a/src/components/Tool/MergeContacts/MergeContacts.test.tsx b/src/components/Tool/MergeContacts/MergeContacts.test.tsx index a458f546b..ffe524e35 100644 --- a/src/components/Tool/MergeContacts/MergeContacts.test.tsx +++ b/src/components/Tool/MergeContacts/MergeContacts.test.tsx @@ -91,9 +91,11 @@ describe('Tools - MergeContacts', () => { expect(confirmButton).toBeDisabled(); userEvent.click(getByText('123 John St Orlando, FL 32832')); expect(await findByText('Use this one')).toBeInTheDocument(); - expect(confirmButton).not.toBeDisabled(); + expect( + getByRole('button', { name: 'Confirm and Continue' }), + ).not.toBeDisabled(); - userEvent.click(confirmButton); + userEvent.click(getByRole('button', { name: 'Confirm and Continue' })); await waitFor(() => expect(mockEnqueue).toHaveBeenCalledWith('Success!', { variant: 'success', @@ -135,7 +137,9 @@ describe('Tools - MergeContacts', () => { expect(await findByText('Use this one')).toBeInTheDocument(); userEvent.click(queryAllByTestId('ignoreButton')[0]); expect(queryByText('Use this one')).not.toBeInTheDocument(); - expect(confirmButton).not.toBeDisabled(); + expect( + getByRole('button', { name: 'Confirm and Continue' }), + ).not.toBeDisabled(); }); describe('setContactFocus()', () => { diff --git a/src/components/Tool/MergeContacts/MergeContacts.tsx b/src/components/Tool/MergeContacts/MergeContacts.tsx index 25a1f65fb..d9c8bf2b9 100644 --- a/src/components/Tool/MergeContacts/MergeContacts.tsx +++ b/src/components/Tool/MergeContacts/MergeContacts.tsx @@ -1,8 +1,6 @@ import React, { useMemo, useState } from 'react'; -import styled from '@emotion/styled'; import { Box, - Button, CircularProgress, Divider, Grid, @@ -13,12 +11,12 @@ 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 ContactPair from './ContactPair'; import { useGetContactDuplicatesQuery } from './GetContactDuplicates.generated'; +import { StickyConfirmButtons } from './StickyConfirmButtons'; const useStyles = makeStyles()(() => ({ container: { @@ -43,34 +41,9 @@ const useStyles = makeStyles()(() => ({ descriptionBox: { marginBottom: theme.spacing(1), }, - footer: { - width: '100%', - display: 'flex', - 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: '64px', - zIndex: '100', - borderBottom: '1px solid', - borderBottomColor: theme.palette.cruGrayLight.main, - [theme.breakpoints.down('sm')]: { - flexDirection: 'column', - alignItems: 'start', - top: '56px', - }, })); -interface ActionType { +export interface ActionType { action: string; mergeId?: string; } @@ -93,13 +66,12 @@ const MergeContacts: React.FC = ({ }); const { appName } = useGetAppSettings(); const [contactsMerge, { loading: updating }] = useMassActionsMergeMutation(); - const actionsLength = useMemo( - () => Object.entries(actions).length, - [actions], + const disabled = useMemo( + () => updating || !Object.entries(actions).length, + [actions, updating], ); - const disabled = updating || !actionsLength; const totalCount = data?.contactDuplicates.totalCount || 0; - const showing = data?.contactDuplicates.nodes.length || 0; + const duplicatesDisplayedCount = data?.contactDuplicates.nodes.length || 0; const updateActions = (id1: string, id2: string, action: string): void => { if (!updating) { @@ -118,22 +90,12 @@ const MergeContacts: React.FC = ({ } } }; - const handleConfirmAndContinue = async () => { - await mergeContacts(); - setActions({}); - }; - const handleConfirmAndLeave = async () => { - await mergeContacts().then(() => { - window.location.href = `${process.env.SITE_URL}/accountLists/${accountListId}/tools`; - setActions({}); - }); - }; const mergeContacts = async () => { const mergeActions = Object.entries(actions).filter( (action) => action[1].action === 'merge', ); - if (mergeActions.length > 0) { + if (mergeActions.length) { const winnersAndLosers: { winnerId: string; loserId: string }[] = mergeActions.map((action) => { return { winnerId: action[0], loserId: action[1].mergeId || '' }; @@ -178,7 +140,7 @@ const MergeContacts: React.FC = ({ {t('Merge Contacts')} - {showing > 0 ? ( + {duplicatesDisplayedCount ? ( <> = ({ - - - - , i: }} - /> - - - {(loading || updating) && ( - - )} - - - - - + {data?.contactDuplicates.nodes .map((duplicate) => ( diff --git a/src/components/Tool/MergeContacts/StickyConfirmButtons.tsx b/src/components/Tool/MergeContacts/StickyConfirmButtons.tsx new file mode 100644 index 000000000..54521d6df --- /dev/null +++ b/src/components/Tool/MergeContacts/StickyConfirmButtons.tsx @@ -0,0 +1,97 @@ +import React, { Dispatch, SetStateAction } from 'react'; +import styled from '@emotion/styled'; +import { Box, Button, Typography } from '@mui/material'; +import { Trans, useTranslation } from 'react-i18next'; +import { LoadingSpinner } from 'src/components/Settings/Organization/LoadingSpinner'; +import theme from 'src/theme'; +import { ActionType } from './MergeContacts'; + +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', + }, +})); +interface StickyConfirmButtonsProps { + accountListId: string; + confirmAction: () => void; + disabled: boolean; + loading: boolean; + setActions: Dispatch>>; + duplicatesDisplayedCount: number; + totalCount: number; + updating: boolean; +} +export const StickyConfirmButtons: React.FC = ({ + accountListId, + confirmAction, + disabled, + loading, + setActions, + duplicatesDisplayedCount, + totalCount, + updating, +}) => { + const { t } = useTranslation(); + + const handleConfirmAndContinue = async () => { + await confirmAction(); + setActions({}); + }; + const handleConfirmAndLeave = async () => { + await confirmAction(); + setActions({}); + window.location.href = `${process.env.SITE_URL}/accountLists/${accountListId}/tools`; + }; + return ( + + + + , i: }} + /> + + + {(loading || updating) && ( + + )} + + + + + + ); +}; diff --git a/src/components/Tool/MergePeople/GetPersonDuplicates.graphql b/src/components/Tool/MergePeople/GetPersonDuplicates.graphql index 10241706a..3aaa009ec 100644 --- a/src/components/Tool/MergePeople/GetPersonDuplicates.graphql +++ b/src/components/Tool/MergePeople/GetPersonDuplicates.graphql @@ -1,3 +1,7 @@ +mutation MergePeopleBulk($input: MergePeopleBulkInput!) { + mergePeopleBulk(input: $input) +} + query GetPersonDuplicates($accountListId: ID!) { # TODO: Eventually needs pagination (Jira issue: MPDX-7642) personDuplicates(accountListId: $accountListId, first: 50) { @@ -11,22 +15,27 @@ query GetPersonDuplicates($accountListId: ID!) { ...PersonInfo } } + totalCount } } fragment BasicEmailInfo on EmailAddress { email + source } fragment BasicPhoneNumberInfo on PhoneNumber { number + source } fragment PersonInfo on Person { id + contactId firstName lastName createdAt + avatar primaryPhoneNumber { ...BasicPhoneNumberInfo } diff --git a/src/components/Tool/MergePeople/MergePeople.test.tsx b/src/components/Tool/MergePeople/MergePeople.test.tsx new file mode 100644 index 000000000..4b73561b5 --- /dev/null +++ b/src/components/Tool/MergePeople/MergePeople.test.tsx @@ -0,0 +1,165 @@ +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 { GetPersonDuplicatesQuery } from './GetPersonDuplicates.generated'; +import MergePeople from './MergePeople'; +import { getPersonDuplicatesMocks } from './PersonDuplicatesMock'; + +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 MergePeopleWrapperProps { + mutationSpy?: () => void; +} + +const MergePeopleWrapper: React.FC = ({ + mutationSpy, +}) => { + return ( + + + + mocks={getPersonDuplicatesMocks} + onCall={mutationSpy} + > + {}} + starredFilter={{}} + setStarredFilter={() => {}} + filterPanelOpen={false} + setFilterPanelOpen={() => {}} + contactId={[]} + searchTerm={''} + > + + + + + + ); +}; + +describe('Tools - MergePeople', () => { + it('should render', async () => { + const { findByText, getByTestId } = render(); + + expect(await findByText('Merge People')).toBeInTheDocument(); + expect(getByTestId('PeopleMergeDescription').textContent).toMatch( + 'You have 55 possible duplicate people', + ); + }); + + it('should merge people', async () => { + const mutationSpy = jest.fn(); + + const { getByText, queryAllByTestId, findByText, getByRole } = render( + + + , + ); + + await waitFor(() => + expect(queryAllByTestId('MergeContactPair')).toHaveLength(2), + ); + expect(getByText('(Siebel)')).toBeInTheDocument(); + + expect( + getByRole('button', { name: 'Confirm and Continue' }), + ).toBeDisabled(); + userEvent.click(getByText('555-555-5555')); + expect(await findByText('Use this one')).toBeInTheDocument(); + expect( + getByRole('button', { name: 'Confirm and Continue' }), + ).not.toBeDisabled(); + + userEvent.click(getByRole('button', { name: 'Confirm and Continue' })); + await waitFor(() => + expect(mockEnqueue).toHaveBeenCalledWith('Success!', { + variant: 'success', + }), + ); + + const mergeCalls = mutationSpy.mock.calls + .map(([{ operation }]) => operation) + .filter(({ operationName }) => operationName === 'MergePeopleBulk'); + expect(mergeCalls).toHaveLength(1); + expect(mergeCalls[0].variables).toEqual({ + input: { + winnersAndLosers: [ + { + loserId: 'person-1.5', + winnerId: 'person-1', + }, + ], + }, + }); + }); + + 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( + getByRole('button', { name: 'Confirm and Continue' }), + ).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('Ellie Francisco'); + + expect(contactName).toBeInTheDocument(); + userEvent.click(contactName); + expect(setContactFocus).toHaveBeenCalledWith('contact-2'); + }); + }); +}); diff --git a/src/components/Tool/MergePeople/MergePeople.tsx b/src/components/Tool/MergePeople/MergePeople.tsx index 5ca8deffa..4eab3baed 100644 --- a/src/components/Tool/MergePeople/MergePeople.tsx +++ b/src/components/Tool/MergePeople/MergePeople.tsx @@ -1,30 +1,32 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Box, - Button, CircularProgress, Divider, 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 useGetAppSettings from 'src/hooks/useGetAppSettings'; import theme from '../../../theme'; +import ContactPair from '../MergeContacts/ContactPair'; +import { StickyConfirmButtons } from '../MergeContacts/StickyConfirmButtons'; import NoData from '../NoData'; -import { useGetPersonDuplicatesQuery } from './GetPersonDuplicates.generated'; -import PersonDuplicate from './PersonDuplicates'; +import { + useGetPersonDuplicatesQuery, + useMergePeopleBulkMutation, +} from './GetPersonDuplicates.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,29 +41,15 @@ const useStyles = makeStyles()(() => ({ marginBottom: theme.spacing(2), }, descriptionBox: { - marginBottom: theme.spacing(2), - }, - footer: { - width: '100%', - display: 'flex', - justifyContent: 'center', - }, - confirmButton: { - backgroundColor: theme.palette.mpdxBlue.main, - width: 200, - color: 'white', + marginBottom: theme.spacing(1), }, })); -interface ActionType { +export interface ActionType { action: string; mergeId?: string; } -interface ActionsType { - [key: string]: ActionType; -} - interface Props { accountListId: string; setContactFocus: SetContactFocus; @@ -72,43 +60,72 @@ const MergePeople: React.FC = ({ setContactFocus, }: Props) => { const { classes } = useStyles(); - const [actions, setActions] = useState({}); + const [actions, setActions] = useState>({}); const { t } = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); const { data, loading } = useGetPersonDuplicatesQuery({ variables: { accountListId }, }); const { appName } = useGetAppSettings(); + const [peopleMerge, { loading: updating }] = useMergePeopleBulkMutation(); + const disabled = useMemo( + () => updating || !Object.entries(actions).length, + [actions, updating], + ); + const totalCount = data?.personDuplicates.totalCount || 0; + const duplicatesDisplayedCount = data?.personDuplicates.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 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 mergePeople = async () => { + const mergeActions = Object.entries(actions).filter( + (action) => action[1].action === 'merge', + ); + if (mergeActions.length) { + const winnersAndLosers: { winnerId: string; loserId: string }[] = + mergeActions.map((action) => { + return { winnerId: action[0], loserId: action[1].mergeId || '' }; + }); + await peopleMerge({ + variables: { + input: { + winnersAndLosers, + }, + }, + update: (cache) => { + // Delete the loser people and remove dangling references to them + winnersAndLosers.forEach((person) => { + cache.evict({ id: `Person:${person.loserId}` }); + }); + cache.gc(); + }, + onCompleted: () => { + enqueueSnackbar(t('Success!'), { + variant: 'success', + }); + }, + onError: (err) => { + enqueueSnackbar(t('A server error occurred. {{err}}', { err }), { + variant: 'error', + }); + }, + }); } }; @@ -125,72 +142,53 @@ const MergePeople: React.FC = ({ {t('Merge People')} - {data?.personDuplicates.nodes.length > 0 ? ( + {duplicatesDisplayedCount ? ( <> - - - - {t( - 'You have {{amount}} possible duplicate people. 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?.personDuplicates.nodes.length, - appName, - }, - )} - - - {t('This cannot be undone.')} - - - - - {data?.personDuplicates.nodes.map((duplicate) => ( - - ))} - - - - - {t('OR')} - - - - - - - }} /> + + {t('This cannot be undone.')} + + + + {data?.personDuplicates.nodes + .map((duplicate) => ( + + )) + .reverse()} + ) : ( diff --git a/src/components/Tool/MergePeople/PersonDuplicates.tsx b/src/components/Tool/MergePeople/PersonDuplicates.tsx deleted file mode 100644 index 2ac251edc..000000000 --- a/src/components/Tool/MergePeople/PersonDuplicates.tsx +++ /dev/null @@ -1,370 +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 theme from '../../../theme'; -import { PersonInfoFragment } from './GetPersonDuplicates.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 { - person1: PersonInfoFragment; - person2: PersonInfoFragment; - update: (id1: string, id2: string, action: string) => void; - setContactFocus: SetContactFocus; -} - -const PersonDuplicate: React.FC = ({ - person1, - person2, - update, - // Remove below line when function is being used. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - 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(person1.id, person2.id, 'merge'); - break; - case 'right': - setSelected('right'); - update(person2.id, person1.id, 'merge'); - break; - case 'cancel': - setSelected('cancel'); - update(person1.id, person2.id, 'cancel'); - break; - default: - setSelected(''); - update(person1.id, person2.id, 'cancel'); - } - }; - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const handleContactNameClick = (contactId) => { - // This currently doesn't work as we need to add the contactId onto the person graphQL endpoint. - // I've asked Andrew to add it here: https://cru-main.slack.com/archives/CG47BDCG6/p1718721024211409 - // You'll need that to run the below function - // 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('')} - > - {`${person1.firstName} ${person1.lastName}`} - - - - {person1.primaryPhoneNumber ? ( - <> - - {person1.primaryPhoneNumber.number} - - - {/* {t('From: {{where}}', { where: person1.primaryPhoneNumber.source })} */} - - - ) : ( - '' - )} - {person1.primaryEmailAddress ? ( - <> - - {person1.primaryEmailAddress.email} - - - {/* {t('From: {{where}}', { where: person1.primaryEmailAddress.source })} */} - - - ) : ( - '' - )} - - {t('On: {{when}}', { - when: dateFormatShort( - DateTime.fromISO(person1.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('')} - > - {`${person2.firstName} ${person2.lastName}`} - - - {person1.primaryPhoneNumber ? ( - <> - - {person1.primaryPhoneNumber.number} - - - {/* {t('From: {{where}}', { where: person1.primaryPhoneNumber.source })} */} - - - ) : ( - '' - )} - {person1.primaryEmailAddress ? ( - <> - - {person1.primaryEmailAddress.email} - - - {/* {t('From: {{where}}', { where: person1.primaryEmailAddress.source })} */} - - - ) : ( - '' - )} - - {t('On: {{when}}', { - when: dateFormatShort( - DateTime.fromISO(person2.createdAt), - locale, - ), - })} - - - - - - - - - - ); -}; - -export default PersonDuplicate; diff --git a/src/components/Tool/MergePeople/PersonDuplicatesMock.ts b/src/components/Tool/MergePeople/PersonDuplicatesMock.ts new file mode 100644 index 000000000..21dc5c85b --- /dev/null +++ b/src/components/Tool/MergePeople/PersonDuplicatesMock.ts @@ -0,0 +1,79 @@ +export const getPersonDuplicatesMocks = { + GetPersonDuplicates: { + personDuplicates: { + totalCount: 55, + nodes: [ + { + id: '1', + recordOne: { + id: 'person-1', + contactId: 'contact-1', + avatar: 'https://mpdx.org/images/avatar.png', + firstName: 'John', + lastName: 'Doe', + createdAt: '2022-09-06T00:00:00-05:00', + primaryPhoneNumber: { + number: '555-555-5555', + source: 'MPDX', + }, + primaryEmailAddress: { + email: 'john@cru.org', + source: 'MPDX', + }, + }, + recordTwo: { + id: 'person-1.5', + contactId: 'contact-1', + avatar: 'https://mpdx.org/images/avatar.png', + firstName: 'John Jacob', + lastName: 'Doe', + createdAt: '2021-09-06T00:00:00-05:00', + primaryPhoneNumber: { + number: '444-444-4444', + source: 'MPDX', + }, + primaryEmailAddress: { + email: 'john@cru.org', + source: 'Siebel', + }, + }, + }, + { + id: '2', + recordOne: { + id: 'person-2', + contactId: 'contact-2', + avatar: 'https://mpdx.org/images/avatar.png', + firstName: 'Ellie', + lastName: 'Francisco', + createdAt: '2022-09-06T00:00:00-05:00', + primaryPhoneNumber: { + number: '111-111-1111', + source: 'TntConnect', + }, + primaryEmailAddress: { + email: 'ellie@cru.org', + source: 'TntConnect', + }, + }, + recordTwo: { + id: 'person-2.5', + contactId: 'contact-2', + avatar: 'https://mpdx.org/images/avatar.png', + firstName: 'Ellie May', + lastName: 'Francisco', + createdAt: '2021-09-06T00:00:00-05:00', + primaryPhoneNumber: { + number: '111-111-1111', + source: 'MPDX', + }, + primaryEmailAddress: { + email: 'ellie@cru.org', + source: 'MPDX', + }, + }, + }, + ], + }, + }, +};