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',
+ },
+ },
+ },
+ ],
+ },
+ },
+};