From b89785bfec226cf947465d9f21f74a4922c54476 Mon Sep 17 00:00:00 2001 From: Caleb Alldrin Date: Thu, 13 Jun 2024 10:12:42 -0700 Subject: [PATCH 01/10] Fix loading and tag autocomplete --- .../ContactDetailsTab/Tags/ContactTags.tsx | 2 -- src/components/Loading/Loading.tsx | 2 +- src/components/Tags/Tags.tsx | 13 +------------ 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/src/components/Contacts/ContactDetails/ContactDetailsTab/Tags/ContactTags.tsx b/src/components/Contacts/ContactDetails/ContactDetailsTab/Tags/ContactTags.tsx index 159a4b4e6..1d9f30a7c 100644 --- a/src/components/Contacts/ContactDetails/ContactDetailsTab/Tags/ContactTags.tsx +++ b/src/components/Contacts/ContactDetails/ContactDetailsTab/Tags/ContactTags.tsx @@ -146,11 +146,9 @@ export const ContactTags: React.FC = ({ } filterSelectedOptions value={tagList} options={unusedTags || []} diff --git a/src/components/Loading/Loading.tsx b/src/components/Loading/Loading.tsx index 8384774a0..ce33c6ab3 100644 --- a/src/components/Loading/Loading.tsx +++ b/src/components/Loading/Loading.tsx @@ -11,7 +11,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ left: '50%', marginLeft: '-28px', marginTop: '-28px', - zIndex: 10, + zIndex: 1000, opacity: 0, transition: theme.transitions.create(['opacity', 'visibility'], { duration: theme.transitions.duration.short, diff --git a/src/components/Tags/Tags.tsx b/src/components/Tags/Tags.tsx index 2ac8b5fdc..1a90edf12 100644 --- a/src/components/Tags/Tags.tsx +++ b/src/components/Tags/Tags.tsx @@ -15,18 +15,7 @@ export const ContactTagInput = styled(TextField)(({ theme }) => ({ borderBottom: `2px solid ${theme.palette.divider}`, }, '&& .MuiInputBase-input': { - minWidth: '200px', - }, - '& ::placeholder': { - color: theme.palette.info.main, - opacity: 1, - }, - '& :hover::placeholder': { - textDecoration: 'underline', - }, - '& :focus::placeholder': { - textDecoration: 'none', - color: theme.palette.cruGrayMedium.main, + minWidth: '150px', }, margin: theme.spacing(1), marginLeft: '0', From ce03bf432719aed1a96a2a86530f37a2ac565621 Mon Sep 17 00:00:00 2001 From: Caleb Alldrin Date: Thu, 13 Jun 2024 14:35:27 -0700 Subject: [PATCH 02/10] Create Tnt Import page UI --- .../[accountListId]/tools/tntConnect.page.tsx | 33 ++ src/components/Tool/TntConnect/TntConnect.tsx | 394 ++++++++++++++++++ 2 files changed, 427 insertions(+) create mode 100644 pages/accountLists/[accountListId]/tools/tntConnect.page.tsx create mode 100644 src/components/Tool/TntConnect/TntConnect.tsx diff --git a/pages/accountLists/[accountListId]/tools/tntConnect.page.tsx b/pages/accountLists/[accountListId]/tools/tntConnect.page.tsx new file mode 100644 index 000000000..7710056b7 --- /dev/null +++ b/pages/accountLists/[accountListId]/tools/tntConnect.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 TntConnect from 'src/components/Tool/TntConnect/TntConnect'; +import { useAccountListId } from 'src/hooks/useAccountListId'; +import useGetAppSettings from 'src/hooks/useGetAppSettings'; + +const TntConnectPage: React.FC = () => { + const { t } = useTranslation(); + const accountListId = useAccountListId(); + const { appName } = useGetAppSettings(); + + return ( + <> + + + {appName} | {t('Import Tnt')} + + + {accountListId ? ( + + ) : ( + + )} + + ); +}; + +export const getServerSideProps = loadSession; + +export default TntConnectPage; diff --git a/src/components/Tool/TntConnect/TntConnect.tsx b/src/components/Tool/TntConnect/TntConnect.tsx new file mode 100644 index 000000000..25bf46c20 --- /dev/null +++ b/src/components/Tool/TntConnect/TntConnect.tsx @@ -0,0 +1,394 @@ +import React, { ReactElement, useEffect, useState } from 'react'; +import CloudUploadIcon from '@mui/icons-material/CloudUpload'; +import { + Alert, + AlertTitle, + Autocomplete, + Box, + Button, + DialogActions, + DialogContent, + DialogContentText, + Divider, + FormControl, + FormControlLabel, + Grid, + LinearProgress, + Link, + Radio, + RadioGroup, + Typography, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { Formik } from 'formik'; +import { useSession } from 'next-auth/react'; +import { useSnackbar } from 'notistack'; +import { useTranslation } from 'react-i18next'; +import { makeStyles } from 'tss-react/mui'; +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 Modal from 'src/components/common/Modal/Modal'; +import useGetAppSettings from 'src/hooks/useGetAppSettings'; +import theme from 'src/theme'; +import { uploadTnt, validateTnt } from './uploadTntConnect'; + +const BoldTypography = styled(Typography)(() => ({ + fontWeight: 'bold', +})); + +const ContainerBox = styled(Grid)(({ theme }) => ({ + padding: theme.spacing(3), + marginTop: theme.spacing(1), + width: '70%', + display: 'flex', + [theme.breakpoints.down('lg')]: { + width: '100%', + }, +})); + +const BottomBox = styled(Box)(({ theme }) => ({ + backgroundColor: theme.palette.cruGrayLight.main, + width: '100%', + padding: theme.spacing(1), + display: 'flex', + justifyContent: 'end', + [theme.breakpoints.down('sm')]: { + paddingTop: theme.spacing(2), + }, +})); + +const OuterBox = styled(Box)(({ theme }) => ({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + width: '100%', + padding: theme.spacing(3), +})); + +const BorderBox = styled(Box)(({ theme }) => ({ + marginTop: theme.spacing(2), + marginBottom: theme.spacing(2), + border: `1px solid ${theme.palette.cruGrayMedium.main}`, + width: '100%', +})); + +const Section = styled(Box)(({ theme }) => ({ + marginTop: theme.spacing(2), +})); + +const VisuallyHiddenInput = styled('input')({ + clip: 'rect(0 0 0 0)', + clipPath: 'inset(50%)', + height: 1, + overflow: 'hidden', + position: 'absolute', + bottom: 0, + left: 0, + whiteSpace: 'nowrap', + width: 1, +}); + +const useStyles = makeStyles()(() => ({ + bulletList: { + margin: '0 0 10px 15px', + '> li': { + margin: '5px', + }, + }, + divider: { + margin: theme.spacing(2, 0), + }, +})); + +const tntSchema = yup.object({ + selectedTags: yup.array().of(yup.string()).default([]), + override: yup.string().required(), +}); + +type Attributes = yup.InferType; + +interface Props { + accountListId: string; +} + +const TntConnect: React.FC = ({ accountListId }: Props) => { + const { classes } = useStyles(); + const { appName } = useGetAppSettings(); + const { t } = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); + const { data: sessionData } = useSession(); + const [showModal, setShowModal] = useState(false); + const [loading, setLoading] = useState(false); + const [tntFile, setTntFile] = useState<{ + file: File; + blobUrl: string; + } | null>(null); + + const { data: contactTagsList, loading: contactTagsListLoading } = + useGetContactTagListQuery({ + variables: { + accountListId, + }, + }); + + const handleCloseModal = () => { + setShowModal(false); + window.location.href = `${process.env.SITE_URL}/accountLists/${accountListId}/tools`; + setLoading(true); + }; + + const onSubmit = async ( + attributes: Attributes, + { resetForm }, + ): Promise => { + const file = tntFile?.file; + if (file) { + try { + await uploadTnt({ + override: attributes.override, + selectedTags: attributes.selectedTags, + file, + t, + apiToken: sessionData?.user?.apiToken || '', + accountListId, + }); + } catch (err) { + enqueueSnackbar( + err instanceof Error + ? err.message + : t('Avatar could not be uploaded'), + { + variant: 'error', + }, + ); + return; + } + } + resetForm(); + setTntFile(null); + enqueueSnackbar(t('Upload Complete'), { + variant: 'success', + }); + setShowModal(true); + }; + + const handleFileChange: React.ChangeEventHandler = ( + event, + ) => { + const f = event.target.files?.[0]; + if (f) { + updateTntFile(f); + } + }; + + useEffect(() => { + return () => { + if (tntFile) { + URL.revokeObjectURL(tntFile.blobUrl); + } + }; + }, [tntFile]); + const updateTntFile = (file: File) => { + const validationResult = validateTnt({ file, t }); + if (!validationResult.success) { + enqueueSnackbar(validationResult.message, { + variant: 'error', + }); + return; + } + + if (tntFile) { + // Release the previous avatar blob + URL.revokeObjectURL(tntFile.blobUrl); + } + setTntFile({ file, blobUrl: URL.createObjectURL(file) }); + }; + + return ( + + {loading && ( + + )} + + + {t('Import from TntConnect')} + + + + {t( + "You can migrate all your contact information and history from TntConnect into {{appName}}. Most of your information will import straight into {{appName}}, including contact info, task history with notes, notes, user groups, and appeals. {{appName}} hides contacts with any of the not interested statuses, including 'Not Interested' and 'Never Ask' in {{appName}} (these contacts are imported, but will only show up if you search for hidden contacts).", + { appName }, + )} + + + + {({ + values: { selectedTags, override }, + handleSubmit, + isSubmitting, + setFieldValue, + handleChange, + isValid, + }): ReactElement => ( +
+ + + + {t('You must have at least TntConnect 3.2')} + + + {t('Get the Latest Version')} + + +
+ + {t('Export your Database from TntConnect:')} + +
    +
  • + {t( + 'Click on "File" choose "Utilities" from the list. Then "Maintenance".', + )} +
  • +
  • + {t( + 'In the popup box, choose the top button, "Export Database to XML".', + )} +
  • +
  • {t('Then save to your computer.')}
  • +
+
+ + + {tntFile && ( + + {tntFile.file.name} + + )} + +
+ + {t('Tags for all imported TntConnect contacts')} + + ( + + )} + onChange={(_, selectedTags): void => + setFieldValue('selectedTags', selectedTags) + } + /> + + + } + 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.', + )} + /> + + +
+
+ {isSubmitting && ( + + )} +
+
+ + + +
+ )} +
+
+
+ + <> + + + {t( + "Your TntConnect data is importing. We'll send you an email as soon as it's all done and ready! Please be aware that it could take up to 12 hours.", + )} + + + + + + + +
+ ); +}; + +export default TntConnect; From bb73f1d9fdf511bbac866413c8ff129cf013a9d3 Mon Sep 17 00:00:00 2001 From: Caleb Alldrin Date: Thu, 13 Jun 2024 14:36:54 -0700 Subject: [PATCH 03/10] Add Rest API upload --- pages/api/upload-tnt-connect-import.page.ts | 82 +++++++++++++++++++ .../Tool/TntConnect/uploadTntConnect.ts | 66 +++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 pages/api/upload-tnt-connect-import.page.ts create mode 100644 src/components/Tool/TntConnect/uploadTntConnect.ts diff --git a/pages/api/upload-tnt-connect-import.page.ts b/pages/api/upload-tnt-connect-import.page.ts new file mode 100644 index 000000000..1f62b7f8b --- /dev/null +++ b/pages/api/upload-tnt-connect-import.page.ts @@ -0,0 +1,82 @@ +import { readFile } from 'fs/promises'; +import { NextApiRequest, NextApiResponse } from 'next'; +import formidable, { IncomingForm } from 'formidable'; +import fetch, { File, FormData } from 'node-fetch'; + +export const config = { + api: { + bodyParser: false, + }, +}; + +const parseBody = async ( + req: NextApiRequest, +): Promise<{ fields: formidable.Fields; files: formidable.Files }> => { + return new Promise((resolve, reject) => { + const form = new IncomingForm(); + form.parse(req, (err, fields, files) => { + if (err) { + reject(err); + } else { + resolve({ fields, files }); + } + }); + }); +}; + +const uploadTntConnect = async ( + req: NextApiRequest, + res: NextApiResponse, +): Promise => { + try { + if (req.method !== 'POST') { + res.status(405).send('Method Not Found'); + return; + } + + const { + fields: { override, selectedTags, accountListId, apiToken }, + files: { file }, + } = await parseBody(req); + if (typeof override !== 'string') { + res.status(400).send('Missing override'); + return; + } + if (!apiToken) { + res.status(401).send('Unauthorized'); + return; + } + if (!file || Array.isArray(file)) { + res.status(400).send('Missing file'); + return; + } + + const fileUpload = new File( + [await readFile(file.filepath)], + file.originalFilename ?? 'avatar', + ); + const form = new FormData(); + form.append('data[type]', 'imports'); + form.append('data[attributes][override]', override); + form.append( + 'data[attributes][tag_list]', + Array.isArray(selectedTags) ? selectedTags.join(',') : selectedTags, + ); + form.append('data[attributes][file]', fileUpload); + const fetchRes = await fetch( + `${process.env.REST_API_URL}account_lists/${accountListId}/imports/tnt`, + { + method: 'POST', + headers: { + authorization: `Bearer ${apiToken}`, + }, + body: form, + }, + ); + res.status(fetchRes.status).json({ success: fetchRes.status === 201 }); + } catch (err) { + res.status(500).json({ success: false, error: err }); + } +}; + +export default uploadTntConnect; diff --git a/src/components/Tool/TntConnect/uploadTntConnect.ts b/src/components/Tool/TntConnect/uploadTntConnect.ts new file mode 100644 index 000000000..9f1fda3c1 --- /dev/null +++ b/src/components/Tool/TntConnect/uploadTntConnect.ts @@ -0,0 +1,66 @@ +import { TFunction } from 'react-i18next'; + +export const validateTnt = ({ + file, + t, +}: { + file: File; + t: TFunction; +}): { success: true } | { success: false; message: string } => { + if (!file.type.includes('xml')) { + return { + success: false, + message: t('Cannot upload file: file must be a xml file'), + }; + } + // The /api lambda appears to truncate the source body at 2^20 bytes + // Conservatively set the limit at 1MB, which is a little lower than 1MiB because of the + // overhead of encoding multipart/form-data and the other fields in the POST body + if (file.size > 1_000_000) { + return { + success: false, + message: t('Cannot upload file: file size cannot exceed 1MB'), + }; + } + + return { success: true }; +}; + +export const uploadTnt = async ({ + selectedTags, + override, + file, + t, + apiToken, + accountListId, +}: { + selectedTags: string[]; + override: string; + file: File; + t: TFunction; + apiToken: string; + accountListId: string; +}): Promise => { + const validationResult = validateTnt({ file, t }); + if (!validationResult.success) { + throw new Error(validationResult.message); + } + + const form = new FormData(); + form.append('override', override); + form.append('tag_list', selectedTags.join(',')); + form.append('file', file); + form.append('apiToken', apiToken); + form.append('accountListId', accountListId); + + const res = await fetch(`/api/upload-tnt-connect-import`, { + method: 'POST', + body: form, + }).catch((err) => { + throw new Error(t('Cannot upload file: server error') + err); + }); + const data: { success: boolean } = await res.json(); + if (!data.success) { + throw new Error(t('Cannot upload file: server error') + data); + } +}; From 4c57e3fca7f27d0ee0dcd810418d249f5d482df0 Mon Sep 17 00:00:00 2001 From: Caleb Alldrin Date: Thu, 13 Jun 2024 14:38:01 -0700 Subject: [PATCH 04/10] Add tests --- .../Tool/TntConnect/TntConnect.test.tsx | 213 ++++++++++++++++++ .../Tool/TntConnect/uploadTntConnect.test.ts | 79 +++++++ 2 files changed, 292 insertions(+) create mode 100644 src/components/Tool/TntConnect/TntConnect.test.tsx create mode 100644 src/components/Tool/TntConnect/uploadTntConnect.test.ts diff --git a/src/components/Tool/TntConnect/TntConnect.test.tsx b/src/components/Tool/TntConnect/TntConnect.test.tsx new file mode 100644 index 000000000..6160a6ef2 --- /dev/null +++ b/src/components/Tool/TntConnect/TntConnect.test.tsx @@ -0,0 +1,213 @@ +import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { cleanup, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SnackbarProvider } from 'notistack'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { ContactDetailProvider } from 'src/components/Contacts/ContactDetails/ContactDetailContext'; +import theme from 'src/theme'; +import TntConnect from './TntConnect'; +import { uploadTnt, validateTnt } from './uploadTntConnect'; + +const mockEnqueue = jest.fn(); + +jest.mock('./uploadTntConnect'); + +const accountListId = '123'; + +jest.mock('notistack', () => ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...jest.requireActual('notistack'), + useSnackbar: () => { + return { + enqueueSnackbar: mockEnqueue, + }; + }, +})); + +const file1 = new File(['contents1'], 'tnt1.xml', { + type: 'text/xml', +}); +const file2 = new File(['contents2'], 'tnt2.xml', { + type: 'application/xml', +}); + +describe('Updating', () => { + const createObjectURL = jest + .fn() + .mockReturnValueOnce('blob:1') + .mockReturnValueOnce('blob:2'); + const revokeObjectURL = jest.fn(); + beforeEach(() => { + (uploadTnt as jest.Mock).mockResolvedValue(undefined); + (validateTnt as jest.Mock).mockReturnValue({ success: true }); + window.URL.createObjectURL = createObjectURL; + window.URL.revokeObjectURL = revokeObjectURL; + }); + + it('should save tags and radio buttons', async () => { + const { getByRole, getByText, getByTestId } = render( + + + + + + + + + , + ); + + const input = getByRole('combobox') as HTMLInputElement; + userEvent.type(input, 'tag123'); + expect(input.value).toBe('tag123'); + userEvent.type(input, '{enter}'); + + userEvent.click( + getByText( + 'This import should override all fields in current contacts (contact info, notes) and add new contacts.', + ), + ); + + userEvent.upload(getByTestId('TntUpload'), file2); + userEvent.click(getByRole('button', { name: 'Import' })); + + await waitFor(() => + expect(uploadTnt).toHaveBeenCalledWith( + expect.objectContaining({ + accountListId, + selectedTags: ['tag123'], + override: 'true', + file: file2, + }), + ), + ); + await waitFor(() => + expect(mockEnqueue).toHaveBeenCalledWith('Upload Complete', { + variant: 'success', + }), + ); + }); + + it('should handle uploading a file', async () => { + const { + getByRole, + queryByText, + getByTestId, + findByText, + getByText, + queryByTestId, + } = render( + + + + + + + + + , + ); + + const importButton = getByRole('button', { name: 'Import' }); + const fileInput = getByTestId('TntUpload'); + + expect(importButton).toBeDisabled(); + expect(queryByTestId('LinearProgress')).not.toBeInTheDocument(); + + userEvent.upload(fileInput, file1); + expect(revokeObjectURL).not.toHaveBeenCalledWith('blob:1'); + expect(getByText('tnt1.xml')).toBeInTheDocument(); + + userEvent.upload(fileInput, file2); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:1'); + + userEvent.click(importButton); + expect(queryByTestId('LinearProgress')).toBeInTheDocument(); + expect(await findByText('Good Work!')).toBeInTheDocument(); + + userEvent.click(getByRole('button', { name: 'Ok' })); + await waitFor(() => + expect(queryByText('Good Work!')).not.toBeInTheDocument(), + ); + + await waitFor(() => + expect(uploadTnt).toHaveBeenCalledWith( + expect.objectContaining({ + accountListId, + selectedTags: [], + override: 'false', + file: file2, + }), + ), + ); + + await waitFor(() => + expect(mockEnqueue).toHaveBeenCalledWith('Upload Complete', { + variant: 'success', + }), + ); + + cleanup(); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:2'); + }); + + it('should notify the user about validation errors', () => { + (validateTnt as jest.Mock).mockReturnValue({ + success: false, + message: 'Invalid file', + }); + + const { getByTestId } = render( + + + + + + + + + + + , + ); + + userEvent.upload(getByTestId('TntUpload'), file1); + + expect(mockEnqueue).toHaveBeenCalledWith('Invalid file', { + variant: 'error', + }); + }); + + it('should notify the user about upload errors', async () => { + (uploadTnt as jest.Mock).mockRejectedValue( + new Error('File could not be uploaded'), + ); + + const { getByRole, getByTestId } = render( + + + + + + + + + + + , + ); + + userEvent.upload(getByTestId('TntUpload'), file1); + userEvent.click(getByRole('button', { name: 'Import' })); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith('File could not be uploaded', { + variant: 'error', + }); + }); + }); +}); diff --git a/src/components/Tool/TntConnect/uploadTntConnect.test.ts b/src/components/Tool/TntConnect/uploadTntConnect.test.ts new file mode 100644 index 000000000..9b268b6e7 --- /dev/null +++ b/src/components/Tool/TntConnect/uploadTntConnect.test.ts @@ -0,0 +1,79 @@ +import { uploadTnt } from './uploadTntConnect'; + +describe('uploadTnt', () => { + const fetch = jest.fn().mockResolvedValue({ + json: () => Promise.resolve({ success: true }), + }); + beforeEach(() => { + window.fetch = fetch; + }); + + const t = (message: string) => message; + + const file = new File(['contents'], 'tnt.xml', { + type: 'text/xml', + }); + const textFile = new File(['contents'], 'file.txt', { + type: 'text/plain', + }); + const largeFile = new File([new ArrayBuffer(2_000_000)], 'large.xml', { + type: 'text/xml', + }); + + const selectedTags = ['test', 'tag1']; + const accountListId = '1234'; + + it('uploads the image', () => { + return expect( + uploadTnt({ + override: 'false', + selectedTags, + apiToken: '', + accountListId, + file, + t, + }), + ).resolves.toBeUndefined(); + }); + + it('rejects files that are not xml files', () => { + return expect( + uploadTnt({ + override: 'false', + selectedTags, + apiToken: '', + accountListId, + file: textFile, + t, + }), + ).rejects.toThrow('Cannot upload file: file must be a xml file'); + }); + + it('rejects files that are too large', () => { + return expect( + uploadTnt({ + override: 'false', + selectedTags, + apiToken: '', + accountListId, + file: largeFile, + t, + }), + ).rejects.toThrow('Cannot upload file: file size cannot exceed 1MB'); + }); + + it('handles server errors', () => { + fetch.mockRejectedValue(new Error('Network error')); + + return expect( + uploadTnt({ + override: 'false', + selectedTags, + apiToken: '', + accountListId, + file, + t, + }), + ).rejects.toThrow('Cannot upload file: server error'); + }); +}); From fa2cd94b4e7fe481b3289e5e4af106032c7f493f Mon Sep 17 00:00:00 2001 From: Caleb Alldrin Date: Thu, 13 Jun 2024 16:53:05 -0700 Subject: [PATCH 05/10] Add tests --- .../Tool/TntConnect/TntConnect.test.tsx | 134 +++++++++++------- src/components/Tool/TntConnect/TntConnect.tsx | 6 +- .../Tool/TntConnect/uploadTntConnect.test.ts | 18 +++ .../Tool/TntConnect/uploadTntConnect.ts | 2 +- 4 files changed, 101 insertions(+), 59 deletions(-) diff --git a/src/components/Tool/TntConnect/TntConnect.test.tsx b/src/components/Tool/TntConnect/TntConnect.test.tsx index 6160a6ef2..e51bfab72 100644 --- a/src/components/Tool/TntConnect/TntConnect.test.tsx +++ b/src/components/Tool/TntConnect/TntConnect.test.tsx @@ -12,11 +12,16 @@ import TntConnect from './TntConnect'; import { uploadTnt, validateTnt } from './uploadTntConnect'; const mockEnqueue = jest.fn(); +const accountListId = '123'; +const file1 = new File(['contents1'], 'tnt1.xml', { + type: 'text/xml', +}); +const file2 = new File(['contents2'], 'tnt2.xml', { + type: 'application/xml', +}); jest.mock('./uploadTntConnect'); -const accountListId = '123'; - jest.mock('notistack', () => ({ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -28,14 +33,7 @@ jest.mock('notistack', () => ({ }, })); -const file1 = new File(['contents1'], 'tnt1.xml', { - type: 'text/xml', -}); -const file2 = new File(['contents2'], 'tnt2.xml', { - type: 'application/xml', -}); - -describe('Updating', () => { +describe('TntConnect Import', () => { const createObjectURL = jest .fn() .mockReturnValueOnce('blob:1') @@ -48,50 +46,6 @@ describe('Updating', () => { window.URL.revokeObjectURL = revokeObjectURL; }); - it('should save tags and radio buttons', async () => { - const { getByRole, getByText, getByTestId } = render( - - - - - - - - - , - ); - - const input = getByRole('combobox') as HTMLInputElement; - userEvent.type(input, 'tag123'); - expect(input.value).toBe('tag123'); - userEvent.type(input, '{enter}'); - - userEvent.click( - getByText( - 'This import should override all fields in current contacts (contact info, notes) and add new contacts.', - ), - ); - - userEvent.upload(getByTestId('TntUpload'), file2); - userEvent.click(getByRole('button', { name: 'Import' })); - - await waitFor(() => - expect(uploadTnt).toHaveBeenCalledWith( - expect.objectContaining({ - accountListId, - selectedTags: ['tag123'], - override: 'true', - file: file2, - }), - ), - ); - await waitFor(() => - expect(mockEnqueue).toHaveBeenCalledWith('Upload Complete', { - variant: 'success', - }), - ); - }); - it('should handle uploading a file', async () => { const { getByRole, @@ -155,6 +109,50 @@ describe('Updating', () => { expect(revokeObjectURL).toHaveBeenCalledWith('blob:2'); }); + it('should save tags and radio buttons', async () => { + const { getByRole, getByText, getByTestId } = render( + + + + + + + + + , + ); + + const input = getByRole('combobox') as HTMLInputElement; + userEvent.type(input, 'tag123'); + expect(input.value).toBe('tag123'); + userEvent.type(input, '{enter}'); + + userEvent.click( + getByText( + 'This import should override all fields in current contacts (contact info, notes) and add new contacts.', + ), + ); + + userEvent.upload(getByTestId('TntUpload'), file1); + userEvent.click(getByRole('button', { name: 'Import' })); + + await waitFor(() => + expect(uploadTnt).toHaveBeenCalledWith( + expect.objectContaining({ + accountListId, + selectedTags: ['tag123'], + override: 'true', + file: file1, + }), + ), + ); + await waitFor(() => + expect(mockEnqueue).toHaveBeenCalledWith('Upload Complete', { + variant: 'success', + }), + ); + }); + it('should notify the user about validation errors', () => { (validateTnt as jest.Mock).mockReturnValue({ success: false, @@ -204,6 +202,34 @@ describe('Updating', () => { userEvent.upload(getByTestId('TntUpload'), file1); userEvent.click(getByRole('button', { name: 'Import' })); + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith('File could not be uploaded', { + variant: 'error', + }); + }); + }); + it('should show default error message', async () => { + (uploadTnt as jest.Mock).mockRejectedValue(500); + + const { getByTestId, getByRole } = render( + + + + + + + + + + + , + ); + + userEvent.upload(getByTestId('TntUpload'), file1); + await waitFor(() => { + userEvent.click(getByRole('button', { name: 'Import' })); + }); + await waitFor(() => { expect(mockEnqueue).toHaveBeenCalledWith('File could not be uploaded', { variant: 'error', diff --git a/src/components/Tool/TntConnect/TntConnect.tsx b/src/components/Tool/TntConnect/TntConnect.tsx index 25bf46c20..5296ba006 100644 --- a/src/components/Tool/TntConnect/TntConnect.tsx +++ b/src/components/Tool/TntConnect/TntConnect.tsx @@ -156,9 +156,7 @@ const TntConnect: React.FC = ({ accountListId }: Props) => { }); } catch (err) { enqueueSnackbar( - err instanceof Error - ? err.message - : t('Avatar could not be uploaded'), + err instanceof Error ? err.message : t('File could not be uploaded'), { variant: 'error', }, @@ -200,7 +198,7 @@ const TntConnect: React.FC = ({ accountListId }: Props) => { } if (tntFile) { - // Release the previous avatar blob + // Release the previous file blob URL.revokeObjectURL(tntFile.blobUrl); } setTntFile({ file, blobUrl: URL.createObjectURL(file) }); diff --git a/src/components/Tool/TntConnect/uploadTntConnect.test.ts b/src/components/Tool/TntConnect/uploadTntConnect.test.ts index 9b268b6e7..6cf7f8414 100644 --- a/src/components/Tool/TntConnect/uploadTntConnect.test.ts +++ b/src/components/Tool/TntConnect/uploadTntConnect.test.ts @@ -76,4 +76,22 @@ describe('uploadTnt', () => { }), ).rejects.toThrow('Cannot upload file: server error'); }); + + it('handles success being false', () => { + const fetch = jest.fn().mockResolvedValue({ + json: () => Promise.resolve({ success: false }), + }); + window.fetch = fetch; + + return expect( + uploadTnt({ + override: 'false', + selectedTags, + apiToken: '', + accountListId, + file, + t, + }), + ).rejects.toThrow('Cannot upload file: server not successful'); + }); }); diff --git a/src/components/Tool/TntConnect/uploadTntConnect.ts b/src/components/Tool/TntConnect/uploadTntConnect.ts index 9f1fda3c1..2fb9447c1 100644 --- a/src/components/Tool/TntConnect/uploadTntConnect.ts +++ b/src/components/Tool/TntConnect/uploadTntConnect.ts @@ -61,6 +61,6 @@ export const uploadTnt = async ({ }); const data: { success: boolean } = await res.json(); if (!data.success) { - throw new Error(t('Cannot upload file: server error') + data); + throw new Error(t('Cannot upload file: server not successful') + data); } }; From b16fa1d5c4f01f513483a50d3cae642a266a27af Mon Sep 17 00:00:00 2001 From: Caleb Alldrin Date: Fri, 14 Jun 2024 09:41:03 -0700 Subject: [PATCH 06/10] Move files to folders and remove useSession --- .../upload-person-avatar.page.ts | 0 .../upload-tnt-connect-import.page.ts | 19 +++++++++++++------ .../People/Items/PersonModal/uploadAvatar.ts | 2 +- src/components/Tool/TntConnect/TntConnect.tsx | 6 +----- .../{ => uploads}/uploadTntConnect.test.ts | 5 ----- .../{ => uploads}/uploadTntConnect.ts | 5 +---- 6 files changed, 16 insertions(+), 21 deletions(-) rename pages/api/{ => uploads}/upload-person-avatar.page.ts (100%) rename pages/api/{ => uploads}/upload-tnt-connect-import.page.ts (88%) rename src/components/Tool/TntConnect/{ => uploads}/uploadTntConnect.test.ts (95%) rename src/components/Tool/TntConnect/{ => uploads}/uploadTntConnect.ts (92%) diff --git a/pages/api/upload-person-avatar.page.ts b/pages/api/uploads/upload-person-avatar.page.ts similarity index 100% rename from pages/api/upload-person-avatar.page.ts rename to pages/api/uploads/upload-person-avatar.page.ts diff --git a/pages/api/upload-tnt-connect-import.page.ts b/pages/api/uploads/upload-tnt-connect-import.page.ts similarity index 88% rename from pages/api/upload-tnt-connect-import.page.ts rename to pages/api/uploads/upload-tnt-connect-import.page.ts index 1f62b7f8b..c831e3337 100644 --- a/pages/api/upload-tnt-connect-import.page.ts +++ b/pages/api/uploads/upload-tnt-connect-import.page.ts @@ -1,6 +1,7 @@ import { readFile } from 'fs/promises'; import { NextApiRequest, NextApiResponse } from 'next'; import formidable, { IncomingForm } from 'formidable'; +import { getToken } from 'next-auth/jwt'; import fetch, { File, FormData } from 'node-fetch'; export const config = { @@ -34,18 +35,24 @@ const uploadTntConnect = async ( return; } + const jwt = await getToken({ + req, + secret: process.env.JWT_SECRET, + }); + const apiToken = jwt?.apiToken; + if (!apiToken) { + res.status(401).send('Unauthorized'); + return; + } + const { - fields: { override, selectedTags, accountListId, apiToken }, + fields: { override, selectedTags, accountListId }, files: { file }, } = await parseBody(req); if (typeof override !== 'string') { res.status(400).send('Missing override'); return; } - if (!apiToken) { - res.status(401).send('Unauthorized'); - return; - } if (!file || Array.isArray(file)) { res.status(400).send('Missing file'); return; @@ -53,7 +60,7 @@ const uploadTntConnect = async ( const fileUpload = new File( [await readFile(file.filepath)], - file.originalFilename ?? 'avatar', + file.originalFilename ?? 'tntConnectUpload', ); const form = new FormData(); form.append('data[type]', 'imports'); diff --git a/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/uploadAvatar.ts b/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/uploadAvatar.ts index d88f350d0..5bb1ea29f 100644 --- a/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/uploadAvatar.ts +++ b/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/uploadAvatar.ts @@ -52,7 +52,7 @@ export const uploadAvatar = async ({ form.append('personId', personId); form.append('avatar', file); - const res = await fetch(`/api/upload-person-avatar`, { + const res = await fetch(`/api/uploads/upload-person-avatar`, { method: 'POST', body: form, }).catch(() => { diff --git a/src/components/Tool/TntConnect/TntConnect.tsx b/src/components/Tool/TntConnect/TntConnect.tsx index 5296ba006..a2cf12782 100644 --- a/src/components/Tool/TntConnect/TntConnect.tsx +++ b/src/components/Tool/TntConnect/TntConnect.tsx @@ -21,7 +21,6 @@ import { } from '@mui/material'; import { styled } from '@mui/material/styles'; import { Formik } from 'formik'; -import { useSession } from 'next-auth/react'; import { useSnackbar } from 'notistack'; import { useTranslation } from 'react-i18next'; import { makeStyles } from 'tss-react/mui'; @@ -32,7 +31,7 @@ import { ContactTagInput } from 'src/components/Tags/Tags'; import Modal from 'src/components/common/Modal/Modal'; import useGetAppSettings from 'src/hooks/useGetAppSettings'; import theme from 'src/theme'; -import { uploadTnt, validateTnt } from './uploadTntConnect'; +import { uploadTnt, validateTnt } from './uploads/uploadTntConnect'; const BoldTypography = styled(Typography)(() => ({ fontWeight: 'bold', @@ -118,7 +117,6 @@ const TntConnect: React.FC = ({ accountListId }: Props) => { const { appName } = useGetAppSettings(); const { t } = useTranslation(); const { enqueueSnackbar } = useSnackbar(); - const { data: sessionData } = useSession(); const [showModal, setShowModal] = useState(false); const [loading, setLoading] = useState(false); const [tntFile, setTntFile] = useState<{ @@ -151,7 +149,6 @@ const TntConnect: React.FC = ({ accountListId }: Props) => { selectedTags: attributes.selectedTags, file, t, - apiToken: sessionData?.user?.apiToken || '', accountListId, }); } catch (err) { @@ -270,7 +267,6 @@ const TntConnect: React.FC = ({ accountListId }: Props) => {