diff --git a/pages/accountLists/[accountListId]/tools/csv.page.test.tsx b/pages/accountLists/[accountListId]/tools/import/csv.page.test.tsx similarity index 100% rename from pages/accountLists/[accountListId]/tools/csv.page.test.tsx rename to pages/accountLists/[accountListId]/tools/import/csv.page.test.tsx diff --git a/pages/accountLists/[accountListId]/tools/csv.page.tsx b/pages/accountLists/[accountListId]/tools/import/csv.page.tsx similarity index 100% rename from pages/accountLists/[accountListId]/tools/csv.page.tsx rename to pages/accountLists/[accountListId]/tools/import/csv.page.tsx diff --git a/pages/accountLists/[accountListId]/tools/import/google.page.test.tsx b/pages/accountLists/[accountListId]/tools/import/google.page.test.tsx new file mode 100644 index 000000000..8349b9823 --- /dev/null +++ b/pages/accountLists/[accountListId]/tools/import/google.page.test.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import { render } from '@testing-library/react'; +import TestRouter from '__tests__/util/TestRouter'; +import TestWrapper from '__tests__/util/TestWrapper'; +import theme from 'src/theme'; +import GoogleImportPage from './google.page'; + +const accountListId = 'accountListId'; +const router = { + query: { accountListId }, + isReady: true, +}; + +const RenderGoogleImportPage = () => ( + + + + + + + +); +describe('render', () => { + it('google import page', async () => { + const { findByText } = render(); + + expect(await findByText('Import from Google')).toBeVisible(); + }); +}); diff --git a/pages/accountLists/[accountListId]/tools/import/google.page.tsx b/pages/accountLists/[accountListId]/tools/import/google.page.tsx new file mode 100644 index 000000000..4e1e3cb49 --- /dev/null +++ b/pages/accountLists/[accountListId]/tools/import/google.page.tsx @@ -0,0 +1,33 @@ +import Head from 'next/head'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { loadSession } from 'pages/api/utils/pagePropsHelpers'; +import Loading from 'src/components/Loading'; +import GoogleImport from 'src/components/Tool/GoogleImport/GoogleImport'; +import { useAccountListId } from 'src/hooks/useAccountListId'; +import useGetAppSettings from 'src/hooks/useGetAppSettings'; + +const GoogleImportPage: React.FC = () => { + const { t } = useTranslation(); + const accountListId = useAccountListId(); + const { appName } = useGetAppSettings(); + + return ( + <> + + + {appName} | {t('Import from Google')} + + + {accountListId ? ( + + ) : ( + + )} + + ); +}; + +export const getServerSideProps = loadSession; + +export default GoogleImportPage; diff --git a/pages/accountLists/[accountListId]/tools/tntConnect.page.tsx b/pages/accountLists/[accountListId]/tools/import/tnt.page.tsx similarity index 100% rename from pages/accountLists/[accountListId]/tools/tntConnect.page.tsx rename to pages/accountLists/[accountListId]/tools/import/tnt.page.tsx diff --git a/pages/api/Schema/Settings/Integrations/Google/googleAccounts/datahandler.ts b/pages/api/Schema/Settings/Integrations/Google/googleAccounts/datahandler.ts index f19c4a147..fe04d953d 100644 --- a/pages/api/Schema/Settings/Integrations/Google/googleAccounts/datahandler.ts +++ b/pages/api/Schema/Settings/Integrations/Google/googleAccounts/datahandler.ts @@ -1,14 +1,16 @@ -import { snakeToCamel } from 'src/lib/snakeToCamel'; +import { fetchAllData } from 'src/lib/deserializeJsonApi'; export interface GoogleAccountsResponse { - attributes: Omit; - id: string; - relationships: { - contact_groups: { - data: unknown[]; + data: { + attributes: Omit; + id: string; + relationships: { + contact_groups: { + data: unknown[]; + }; }; + type: string; }; - type: string; } export interface GoogleAccountAttributes { @@ -37,17 +39,24 @@ interface GoogleAccountAttributesCamel { tokenExpired: boolean; updatedAt: string; updatedInDbAt: string; + contactGroups: ContactGroupCamel[]; } -export const GoogleAccounts = ( - data: GoogleAccountsResponse[], -): GoogleAccountAttributesCamel[] => { - return data.map((accounts) => { - const attributes = {} as Omit; - Object.keys(accounts.attributes).map((key) => { - attributes[snakeToCamel(key)] = accounts.attributes[key]; - }); +type ContactGroupCamel = { + id: string; + createdAt: string; + tag: string; + title: string; + updatedAt: string; + updatedInDbAt: string; +}; - return { id: accounts.id, ...attributes }; +export const GoogleAccounts = (response): GoogleAccountAttributesCamel[] => { + return response.data.map((account) => { + const attributes = fetchAllData(account, response.included) as Omit< + GoogleAccountAttributesCamel, + 'id' + >; + return { id: account.id, ...attributes }; }); }; diff --git a/pages/api/Schema/Settings/Integrations/Google/googleAccounts/googleAccounts.graphql b/pages/api/Schema/Settings/Integrations/Google/googleAccounts/googleAccounts.graphql index 38ae1cc19..7d7b50be8 100644 --- a/pages/api/Schema/Settings/Integrations/Google/googleAccounts/googleAccounts.graphql +++ b/pages/api/Schema/Settings/Integrations/Google/googleAccounts/googleAccounts.graphql @@ -14,4 +14,14 @@ type GoogleAccountAttributes { tokenExpired: Boolean! updatedAt: String! updatedInDbAt: String! + contactGroups: [ContactGroup]! +} + +type ContactGroup { + id: String! + createdAt: String + tag: String + title: String + updatedAt: String + updatedInDbAt: String } diff --git a/pages/api/graphql-rest.page.ts b/pages/api/graphql-rest.page.ts index 2e3bc3fdc..2ddffbdeb 100644 --- a/pages/api/graphql-rest.page.ts +++ b/pages/api/graphql-rest.page.ts @@ -900,14 +900,14 @@ class MpdxRestApi extends RESTDataSource { // async googleAccounts() { - const { data }: { data: GoogleAccountsResponse[] } = await this.get( + const response: GoogleAccountsResponse[] = await this.get( 'user/google_accounts', { sort: 'created_at', include: 'contact_groups', }, ); - return GoogleAccounts(data); + return GoogleAccounts(response); } async googleAccountIntegrations( diff --git a/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx index d01a82266..9875fdfdb 100644 --- a/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx +++ b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx @@ -8,6 +8,7 @@ import { SubmitButton, } from 'src/components/common/Modal/ActionButtons/ActionButtons'; import Modal from 'src/components/common/Modal/Modal'; +import useGetAppSettings from 'src/hooks/useGetAppSettings'; import { GoogleAccountAttributesSlimmed } from '../GoogleAccordion'; import { useDeleteGoogleAccountMutation } from '../GoogleAccounts.generated'; @@ -24,6 +25,7 @@ export const DeleteGoogleAccountModal: React.FC< DeleteGoogleAccountModalProps > = ({ account, handleClose }) => { const { t } = useTranslation(); + const { appName } = useGetAppSettings(); const [isSubmitting, setIsSubmitting] = useState(false); const { enqueueSnackbar } = useSnackbar(); @@ -45,7 +47,7 @@ export const DeleteGoogleAccountModal: React.FC< }, onCompleted: () => { enqueueSnackbar( - t('{{appName}} removed your integration with Google.'), + t('{{appName}} removed your integration with Google.', { appName }), { variant: 'success', }, @@ -54,7 +56,10 @@ export const DeleteGoogleAccountModal: React.FC< }, onError: () => { enqueueSnackbar( - t("{{appName}} couldn't save your configuration changes for Google."), + t( + "{{appName}} couldn't save your configuration changes for Google.", + { appName }, + ), { variant: 'error', }, diff --git a/src/components/Tool/GoogleImport/GoogleImport.test.tsx b/src/components/Tool/GoogleImport/GoogleImport.test.tsx new file mode 100644 index 000000000..fe0d38e58 --- /dev/null +++ b/src/components/Tool/GoogleImport/GoogleImport.test.tsx @@ -0,0 +1,362 @@ +import { ApolloCache } from '@apollo/client'; +import { ThemeProvider } from '@mui/material/styles'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ApolloErgonoMockMap } from 'graphql-ergonomock'; +import { MockLinkCallHandler } from 'graphql-ergonomock/dist/apollo/MockLink'; +import { getSession } from 'next-auth/react'; +import { SnackbarProvider } from 'notistack'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { fetchAllData } from 'src/lib/deserializeJsonApi'; +import theme from 'src/theme'; +import GoogleImport from './GoogleImport'; +import { mockGoogleContactGroupsResponse } from './GoogleImportMocks'; +import { GoogleContactGroupsQuery } from './googleContactGroups.generated'; + +const mockEnqueue = jest.fn(); +jest.mock('notistack', () => ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...jest.requireActual('notistack'), + useSnackbar: () => { + return { + enqueueSnackbar: mockEnqueue, + }; + }, +})); + +jest.mock('src/lib/deserializeJsonApi'); + +const accountListId = 'account-id'; +const router = { + pathname: `/accountLists/${accountListId}/tools`, + push: jest.fn(), + isReady: true, +}; + +const TestComponent = ({ + mocks, + cache, + onCall, +}: { + mocks: ApolloErgonoMockMap; + cache?: ApolloCache; + onCall?: MockLinkCallHandler; +}) => ( + + + + + mocks={mocks} + cache={cache} + onCall={onCall} + > + + + + + +); + +describe('Google Import', () => { + const googleAccounts = + mockGoogleContactGroupsResponse.GoogleContactGroups.googleAccounts; + const account1Email = googleAccounts[0].email; + const account2Email = googleAccounts[1].email; + const account3Email = googleAccounts[2].email; + const account1GroupName = googleAccounts[0].contactGroups[0].title; + const account2GroupName = googleAccounts[1].contactGroups[0].title; + const account2GroupTag = googleAccounts[1].contactGroups[0].tag; + + describe('render', () => { + it('multiple Google accounts', async () => { + const { + getByText, + findByText, + getAllByText, + getAllByRole, + queryByText, + getByRole, + } = render(); + + expect(await findByText('Account to Import From')).toBeVisible(); + expect(await findByText(account1GroupName)).toBeVisible(); + + await waitFor(() => { + expect(getAllByText(account1Email)[0]).toBeVisible(); + }); + const importButton = getByRole('button', { name: 'Import' }); + + //Switch to a different google account + const accountDropdown = getAllByRole('combobox')[0]; + userEvent.click(accountDropdown); + userEvent.click(getByText(account2Email)); + + expect(await findByText(account2GroupName)).toBeVisible(); + expect(queryByText(account1GroupName)).not.toBeInTheDocument(); + + //Hides the contact groups when selecting All Contacts + userEvent.click(getByText('Import all contacts')); + expect(queryByText(account2GroupName)).not.toBeVisible(); + userEvent.click(importButton); + expect(await findByText('Confirm Import All')).toBeVisible(); + userEvent.click(getByRole('button', { name: 'No' })); + + //Check that the Import Button is disabled when no groups are checked + userEvent.click(getByText('Only import contacts from certain groups')); + expect(importButton).toBeDisabled(); + userEvent.click(getByText(account2GroupName)); + expect(importButton).not.toBeDisabled(); + }); + + it('single Google account', async () => { + const { getByText, getAllByText, queryByText } = render( + , + ); + await waitFor(() => { + expect(queryByText('Account to Import From')).not.toBeInTheDocument(); + expect(getAllByText(account1Email)[0]).toBeVisible(); + expect(getByText(account1GroupName)).toBeVisible(); + }); + }); + + it('single Google account with no groups/labels', async () => { + const { getByText, getByRole, findByText } = render( + , + ); + + expect(await findByText(account3Email)).toBeVisible(); + + expect( + getByText('You have no Google Contact groups/labels'), + ).toBeVisible(); + + //Check that the Import Button is disabled when no groups exist + const importButton = getByRole('button', { name: 'Import' }); + expect(importButton).toBeDisabled(); + + //Is not disabled when choosing to import all contacts + userEvent.click(getByText('Import all contacts')); + expect(importButton).not.toBeDisabled(); + }); + + it('no Google accounts', async () => { + const { findByText } = render( + , + ); + + expect( + await findByText("You haven't connected a Google account yet"), + ).toBeVisible(); + }); + }); + + describe('Imports contacts', () => { + const fetch = jest.fn(); + beforeEach(() => { + (fetchAllData as jest.Mock).mockReturnValue({}); + (getSession as jest.Mock).mockReturnValue({ + user: { apiToken: 'token' }, + }); + fetch.mockResolvedValue({ + json: () => Promise.resolve({ success: true, data: { id: '1' } }), + }); + window.fetch = fetch; + }); + + it('checks and unchecks all', async () => { + const { getAllByRole, getByRole, getByText } = render( + , + ); + await waitFor(() => { + expect(getByText(account1GroupName)).toBeInTheDocument(); + }); + const importButton = getByRole('button', { name: 'Import' }); + expect(importButton).toBeDisabled(); + + userEvent.click(getByRole('button', { name: 'Check All' })); + const checkboxes = getAllByRole('checkbox'); + expect(checkboxes[0]).toBeChecked(); + expect(checkboxes[1]).toBeChecked(); + expect(checkboxes[2]).toBeChecked(); + expect(checkboxes[3]).toBeChecked(); + + expect(importButton).not.toBeDisabled(); + + userEvent.click(getByRole('button', { name: 'Uncheck All' })); + expect(checkboxes[0]).not.toBeChecked(); + expect(checkboxes[1]).not.toBeChecked(); + expect(checkboxes[2]).not.toBeChecked(); + expect(checkboxes[3]).not.toBeChecked(); + + expect(importButton).toBeDisabled(); + }); + + it('makes fetch call', async () => { + const { getAllByRole, findByText, getByRole, getByText, queryByText } = + render( + , + ); + await waitFor(() => { + expect(getByText(account2GroupName)).toBeInTheDocument(); + }); + + // Select checkbox + userEvent.click(getByText(account2GroupName)); + + // Adds tags by group + const contactGroupTagAutocomplete = getAllByRole( + 'combobox', + )[0] as HTMLInputElement; + expect(await findByText(account2GroupTag)).toBeInTheDocument(), + userEvent.type(contactGroupTagAutocomplete, 'hello-world'); + expect(contactGroupTagAutocomplete.value).toBe('hello-world'), + userEvent.type(contactGroupTagAutocomplete, '{enter}'); + + //Add tags for all + const allTagAutocomplete = getAllByRole( + 'combobox', + )[1] as HTMLInputElement; + userEvent.type(allTagAutocomplete, 'googleImport{enter}'); + + const importButton = getByRole('button', { name: 'Import' }); + userEvent.click(importButton); + + await waitFor(() => { + expect(fetch.mock.calls[0][1]).toMatchObject({ + body: JSON.stringify({ + data: { + attributes: { + groups: ['contactGroups/asdf'], + import_by_group: 'true', + override: 'false', + source: 'google', + tag_list: 'googleImport', + group_tags: { + 'contactGroups/asdf': 'account-two-group,hello-world', + }, + }, + relationships: { + source_account: { + data: { + type: 'google_accounts', + id: '2', + }, + }, + }, + type: 'imports', + }, + }), + headers: { + authorization: 'Bearer token', + 'content-type': 'application/vnd.api+json', + }, + method: 'POST', + }); + }); + + expect(getByText('Good Work!')).toBeInTheDocument(); + userEvent.click(getByRole('button', { name: 'Ok' })); + + await waitFor(() => { + expect(queryByText('Good Work!')).not.toBeInTheDocument(); + expect(mockEnqueue).toHaveBeenCalledWith(`Import has started`, { + variant: 'success', + }); + }); + + expect(router.push).toHaveBeenCalledWith( + `/accountLists/${accountListId}/tools`, + ); + }); + }); + + describe('Handles errors', () => { + const fetch = jest.fn(); + beforeEach(() => { + (fetchAllData as jest.Mock).mockReturnValue({}); + (getSession as jest.Mock).mockReturnValue({ + user: { apiToken: 'token' }, + }); + fetch.mockRejectedValue({ + json: () => Promise.reject({ success: false, data: { id: '1' } }), + }); + window.fetch = fetch; + }); + + it('shows error message', async () => { + const { getByRole, getByText } = render( + , + ); + await waitFor(() => { + expect(getByText(account2GroupName)).toBeInTheDocument(); + }); + + // Select checkbox + userEvent.click(getByText(account2GroupName)); + + const importButton = getByRole('button', { name: 'Import' }); + userEvent.click(importButton); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith(`Import has failed`, { + variant: 'error', + }); + }); + }); + }); +}); diff --git a/src/components/Tool/GoogleImport/GoogleImport.tsx b/src/components/Tool/GoogleImport/GoogleImport.tsx new file mode 100644 index 000000000..1a545cfc1 --- /dev/null +++ b/src/components/Tool/GoogleImport/GoogleImport.tsx @@ -0,0 +1,615 @@ +import { useRouter } from 'next/router'; +import React, { ReactElement, useEffect, useMemo, useState } from 'react'; +import { + Alert, + AlertTitle, + Autocomplete, + Box, + Button, + ButtonGroup, + Card, + CardActions, + CardContent, + CardHeader, + Checkbox, + DialogActions, + DialogContent, + DialogContentText, + Divider, + FormControl, + FormControlLabel, + Grid, + Link, + MenuItem, + Radio, + RadioGroup, + Select, + Typography, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { Formik } from 'formik'; +import { getSession } from 'next-auth/react'; +import { useSnackbar } from 'notistack'; +import { Trans, useTranslation } from 'react-i18next'; +import * as yup from 'yup'; +import { useGetContactTagListQuery } from 'src/components/Contacts/ContactDetails/ContactDetailsTab/Tags/ContactTags.generated'; +import { LoadingSpinner } from 'src/components/Settings/Organization/LoadingSpinner'; +import { ContactTagInput } from 'src/components/Tags/Tags'; +import { Confirmation } from 'src/components/common/Modal/Confirmation/Confirmation'; +import Modal from 'src/components/common/Modal/Modal'; +import useGetAppSettings from 'src/hooks/useGetAppSettings'; +import theme from 'src/theme'; +import NoData from '../NoData'; +import { useGoogleContactGroupsQuery } from './googleContactGroups.generated'; + +const BoldTypography = styled(Typography)(() => ({ + fontWeight: 'bold', +})); + +const ContainerBox = styled(Grid)(({ theme }) => ({ + padding: theme.spacing(3), + width: '70%', + display: 'flex', + minWidth: '450px', + [theme.breakpoints.down('lg')]: { + width: '100%', + }, +})); + +const OuterBox = styled(Box)(() => ({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + width: '100%', +})); + +const HeaderBox = styled(Box)(({ theme }) => ({ + backgroundColor: theme.palette.cruGrayLight.main, + width: '100%', + padding: theme.spacing(1), + display: 'flex', + justifyContent: 'end', + borderTopRightRadius: '7px', + borderTopLeftRadius: '7px', + [theme.breakpoints.down('sm')]: { + paddingTop: theme.spacing(2), + }, +})); + +const BorderBox = styled(Box)(({ theme }) => ({ + marginTop: theme.spacing(2), + marginBottom: theme.spacing(2), + border: `1px solid ${theme.palette.cruGrayLight.main}`, + width: '100%', + borderRadius: '7px', +})); + +const Section = styled(Box)(({ theme }) => ({ + marginTop: theme.spacing(2), +})); + +const googleImportSchema = yup.object({ + tagsForAllList: yup.array().of(yup.string()).default([]), + override: yup.string(), + importByGroup: yup.string(), + groupTags: yup.object(), + groups: yup.array().of(yup.string()).default([]), +}); + +type Attributes = yup.InferType; + +interface Props { + accountListId: string; +} + +const GoogleImport: React.FC = ({ accountListId }: Props) => { + const { appName } = useGetAppSettings(); + const { t } = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); + const router = useRouter(); + const [showModal, setShowModal] = useState(false); + const [showConfirmAllModal, setShowConfirmAllModal] = useState(false); + const [redirecting, setRedirecting] = useState(false); + const [selectedAccountId, setSelectedAccountId] = useState(''); + + const { data: contactTagsList, loading: contactTagsListLoading } = + useGetContactTagListQuery({ + variables: { + accountListId, + }, + }); + + const { data, loading } = useGoogleContactGroupsQuery(); + const googleAccounts = data?.googleAccounts; + + const selectedAccount = useMemo(() => { + return googleAccounts?.find( + (selectedAccount) => selectedAccount?.id === selectedAccountId, + ); + }, [googleAccounts, selectedAccountId]); + + const initialValues = useMemo(() => { + const initialGroupTags = + selectedAccount?.contactGroups.reduce((acc, group) => { + if (group?.id) { + acc[group?.id] = [group?.tag]; + } + return acc; + }, {}) || {}; + return { + tagsForAllList: [], + override: 'false', + importByGroup: 'true', + groupTags: initialGroupTags, + groups: [] as string[], + }; + }, [selectedAccount]); + + useEffect(() => { + googleAccounts?.length && setSelectedAccountId(googleAccounts[0]?.id || ''); + }, [googleAccounts]); + + const handleCloseModal = () => { + setShowModal(false); + setRedirecting(true); + router.push(`/accountLists/${accountListId}/tools`); + }; + + const onSubmit = async ( + attributes: Attributes, + { resetForm }, + ): Promise => { + setShowConfirmAllModal(false); + const session = await getSession(); + const apiToken = session?.user?.apiToken; + + const groupTags = Object.entries(attributes.groupTags).reduce( + (result, [groupId, tags]: [string, string[]]) => { + result[groupId] = tags.join(','); + return result; + }, + {}, + ); + + const importData = { + data: { + attributes: { + groups: attributes.groups, + import_by_group: attributes.importByGroup, + override: attributes.override, + source: 'google', + tag_list: attributes.tagsForAllList.join(','), + group_tags: groupTags, + }, + relationships: { + source_account: { + data: { + type: 'google_accounts', + id: selectedAccountId, + }, + }, + }, + type: 'imports', + }, + }; + await fetch( + `${process.env.REST_API_URL}account_lists/${accountListId}/imports/google`, + { + method: 'POST', + body: JSON.stringify(importData), + headers: { + authorization: `Bearer ${apiToken}`, + 'content-type': 'application/vnd.api+json', + }, + }, + ).catch((err) => { + enqueueSnackbar(t('Import has failed'), { + variant: 'error', + }); + throw err; + }); + + enqueueSnackbar(t('Import has started'), { + variant: 'success', + }); + resetForm(); + setShowModal(true); + }; + + return ( + + {redirecting && ( + + )} + + <> + + {t('Import from Google')} + + + {loading && !data && ( + + )} + + {!loading && data && ( + <> + {!data?.googleAccounts.length && ( + + {t('Connect Google Account')} + + } + /> + )} + {!!data?.googleAccounts.length && ( + <> + {data?.googleAccounts.length > 1 && ( + + + {t('Account to Import From')} + + + + )} + + + {({ + values: { + tagsForAllList, + override, + importByGroup, + groupTags, + groups, + }, + handleSubmit, + submitForm, + isSubmitting, + setFieldValue, + handleChange, + }): ReactElement => ( +
+ + }} + /> + } + action={ + + } + titleTypographyProps={{ + fontSize: '1rem! important', + }} + > + + + + } + label={t('Import all contacts')} + /> + } + label={t( + 'Only import contacts from certain groups', + )} + /> + + + {!!selectedAccount?.contactGroups.length ? ( + + + + + + + {t('Contact Group')} + + + + + {t('Tags for Group')} + + + + + + + + + + + + {selectedAccount?.contactGroups.map( + (group, idx) => ( + + + + } + label={group?.title} + /> + + + ( + + )} + onChange={(_, value): void => + setFieldValue( + `groupTags.${group?.id}`, + value, + ) + } + /> + + + ), + )} + + + + ) : importByGroup === 'true' ? ( + + + {t( + 'You have no Google Contact groups/labels', + )} + + + {t( + "If you'd like to import contacts by group/label, please add labels here: ", + )} + + + {t('contacts.google.com')} + + + ) : null} + +
+ + {t('Tags for all imported Google contacts')} + + ( + + )} + onChange={(_, tagsForAllList): void => + setFieldValue( + 'tagsForAllList', + tagsForAllList, + ) + } + /> + + + } + label={t( + 'This import should only fill blank fields in current contacts and/or add new contacts.', + )} + /> + } + label={t( + 'This import should override all fields in current contacts (contact info, notes) and add new contacts.', + )} + /> + + +
+
+ + + +
+
+ setShowConfirmAllModal(false)} + mutation={submitForm} + /> + + )} +
+ + )} + + )} + +
+ + <> + + + {t( + 'Your Google import has started and your contacts will be in {{appName}} shortly. We will email you when your import is complete.', + { appName }, + )} + + + + + + + +
+ ); +}; + +export default GoogleImport; diff --git a/src/components/Tool/GoogleImport/GoogleImportMocks.ts b/src/components/Tool/GoogleImport/GoogleImportMocks.ts new file mode 100644 index 000000000..31de35fd2 --- /dev/null +++ b/src/components/Tool/GoogleImport/GoogleImportMocks.ts @@ -0,0 +1,80 @@ +export const mockGoogleContactGroupsResponse = { + GoogleContactGroups: { + googleAccounts: [ + { + email: 'account.one@cru.org', + primary: false, + remoteId: '1', + id: '1', + tokenExpired: false, + contactGroups: [ + { + id: 'contactGroups/85c9f6b082b24f4', + createdAt: '2019-08-26T21:01:45.473Z', + tag: 'account-one-group', + title: 'Account One Group', + updatedAt: '2019-08-26T21:01:45.473Z', + updatedInDbAt: '2019-08-26T21:01:45.473Z', + __typename: 'ContactGroup', + }, + { + id: 'contactGroups/90429ea8f236f31', + createdAt: '2019-08-24T19:29:35.713Z', + tag: 'f19-test', + title: 'F19 Test', + updatedAt: '2019-08-24T19:29:35.713Z', + updatedInDbAt: '2019-08-24T19:29:35.713Z', + __typename: 'ContactGroup', + }, + { + id: 'contactGroups/90cf0528c7f2b4a', + createdAt: '2019-08-24T19:29:35.713Z', + tag: 'f19-caleb', + title: 'F19 Caleb', + updatedAt: '2019-08-24T19:29:35.713Z', + updatedInDbAt: '2019-08-24T19:29:35.713Z', + __typename: 'ContactGroup', + }, + { + id: 'contactGroups/ff046810dd99a40', + createdAt: '2020-07-01T00:15:52.389Z', + tag: '2020-test', + title: '2020 test', + updatedAt: '2020-07-01T00:15:52.389Z', + updatedInDbAt: '2020-07-01T00:15:52.389Z', + __typename: 'ContactGroup', + }, + ], + __typename: 'GoogleAccountAttributes', + }, + { + email: 'account.two@cru.org', + primary: false, + remoteId: '2', + id: '2', + tokenExpired: false, + contactGroups: [ + { + id: 'contactGroups/asdf', + createdAt: '2019-08-26T21:02:45.473Z', + tag: 'account-two-group', + title: 'Account Two Group', + updatedAt: '2019-08-26T21:01:45.473Z', + updatedInDbAt: '2019-08-26T21:01:45.473Z', + __typename: 'ContactGroup', + }, + ], + __typename: 'GoogleAccountAttributes', + }, + { + email: 'account.three@cru.org', + primary: false, + remoteId: '3', + id: '3', + tokenExpired: false, + contactGroups: [], + __typename: 'GoogleAccountAttributes', + }, + ], + }, +}; diff --git a/src/components/Tool/GoogleImport/googleContactGroups.graphql b/src/components/Tool/GoogleImport/googleContactGroups.graphql new file mode 100644 index 000000000..b5d301626 --- /dev/null +++ b/src/components/Tool/GoogleImport/googleContactGroups.graphql @@ -0,0 +1,17 @@ +query GoogleContactGroups { + googleAccounts { + email + primary + remoteId + id + tokenExpired + contactGroups { + id + createdAt + tag + title + updatedAt + updatedInDbAt + } + } +} diff --git a/src/components/Tool/Home/ToolList.ts b/src/components/Tool/Home/ToolList.ts index d99a2baa4..742ed6187 100644 --- a/src/components/Tool/Home/ToolList.ts +++ b/src/components/Tool/Home/ToolList.ts @@ -104,21 +104,21 @@ export const ToolsList: ToolsGroup[] = [ desc: 'Import your contact information from your Google account', icon: mdiGoogle, - id: 'google', + id: 'import/google', }, { tool: 'Import from TntConnect', desc: 'Import your contacts from your TntConnect database', icon: mdiCloudUpload, - id: 'tntConnect', + id: 'import/tnt', }, { tool: 'Import from CSV', desc: 'Import contacts you have saved in a CSV file', icon: mdiTable, - id: 'csv', + id: 'import/csv', }, ], }, diff --git a/src/components/Tool/NoData.tsx b/src/components/Tool/NoData.tsx index b2e95f3a9..c26e9ca29 100644 --- a/src/components/Tool/NoData.tsx +++ b/src/components/Tool/NoData.tsx @@ -1,8 +1,9 @@ -import React from 'react'; +import React, { ReactElement } from 'react'; import { mdiAccountGroup, mdiCurrencyUsd, mdiEmailOutline, + mdiGoogle, mdiHome, mdiMap, mdiNewspaperVariantOutline, @@ -15,6 +16,7 @@ import { NullStateBox } from '../Shared/Filters/NullState/NullStateBox'; interface Props { tool: string; + button?: ReactElement; } interface ToolText { @@ -73,14 +75,22 @@ const textMap: { [key: string]: ToolText } = { secondaryText: i18n.t('People with similar names will appear here.'), icon: mdiAccountGroup, }, + googleImport: { + primaryText: i18n.t("You haven't connected a Google account yet"), + secondaryText: i18n.t( + 'Add a Google account then try to import from Google.', + ), + icon: mdiGoogle, + }, }; -const NoData: React.FC = ({ tool }: Props) => { +const NoData: React.FC = ({ tool, button }: Props) => { return ( {textMap[tool].primaryText} - {textMap[tool].secondaryText} + {textMap[tool].secondaryText} + {button} ); };