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/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/uploads/upload-tnt-connect-import.page.ts b/pages/api/uploads/upload-tnt-connect-import.page.ts new file mode 100644 index 000000000..b2031798d --- /dev/null +++ b/pages/api/uploads/upload-tnt-connect-import.page.ts @@ -0,0 +1,89 @@ +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 = { + 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 jwt = await getToken({ + req, + secret: process.env.JWT_SECRET, + }); + const apiToken = jwt?.apiToken; + if (!apiToken) { + res.status(401).send('Unauthorized'); + return; + } + + const { + fields: { override, tag_list, accountListId }, + files: { file }, + } = await parseBody(req); + if (typeof override !== 'string') { + res.status(400).send('Missing override'); + return; + } + if (!file || Array.isArray(file)) { + res.status(400).send('Missing file'); + return; + } + + const fileUpload = new File( + [await readFile(file.filepath)], + file.originalFilename ?? 'tntConnectUpload', + ); + const form = new FormData(); + form.append('data[type]', 'imports'); + form.append('data[attributes][override]', override); + form.append( + 'data[attributes][tag_list]', + Array.isArray(tag_list) ? tag_list.join(',') : tag_list, + ); + 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/pages/api/uploads/upload-tnt-connect-import.test.ts b/pages/api/uploads/upload-tnt-connect-import.test.ts new file mode 100644 index 000000000..fa1c5723a --- /dev/null +++ b/pages/api/uploads/upload-tnt-connect-import.test.ts @@ -0,0 +1,48 @@ +import { getToken } from 'next-auth/jwt'; +import { createMocks } from 'node-mocks-http'; +import uploadTntConnect from './upload-tnt-connect-import.page'; +import 'node-fetch'; + +jest.mock('node-fetch', () => jest.fn()); + +jest.mock('next-auth/jwt', () => ({ getToken: jest.fn() })); +jest.mock('src/lib/apollo/ssrClient', () => jest.fn()); + +const accountListId = 'accountListId'; +const file = new File(['contents1'], 'tnt1.xml', { + type: 'text/xml', +}); +const override = 'false'; +const tag_list = 'tag1'; + +describe('upload-tnt-connect-import', () => { + it('responds with error if unauthorized', async () => { + const { req, res } = createMocks({ + method: 'POST', + body: { + override: override, + file, + tag_list, + accountListId, + }, + }); + + await uploadTntConnect(req, res); + + expect(res._getStatusCode()).toBe(401); + }); + + it('responds with error if not sent with POST', async () => { + (getToken as jest.Mock).mockReturnValue({ + apiToken: 'accessToken', + userID: 'sessionUserID', + }); + const { req, res } = createMocks({ + method: 'GET', + }); + + await uploadTntConnect(req, res); + + expect(res._getStatusCode()).toBe(405); + }); +}); 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/Contacts/ContactDetails/ContactDetailsTab/Tags/ContactTags.tsx b/src/components/Contacts/ContactDetails/ContactDetailsTab/Tags/ContactTags.tsx index 159a4b4e6..69ccda8e7 100644 --- a/src/components/Contacts/ContactDetails/ContactDetailsTab/Tags/ContactTags.tsx +++ b/src/components/Contacts/ContactDetails/ContactDetailsTab/Tags/ContactTags.tsx @@ -150,7 +150,6 @@ export const ContactTags: React.FC = ({ autoHighlight fullWidth loading={loading} - popupIcon={} 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', diff --git a/src/components/Tool/TntConnect/TntConnect.test.tsx b/src/components/Tool/TntConnect/TntConnect.test.tsx new file mode 100644 index 000000000..f5046c820 --- /dev/null +++ b/src/components/Tool/TntConnect/TntConnect.test.tsx @@ -0,0 +1,239 @@ +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 './uploads/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('./uploads/uploadTntConnect'); + +jest.mock('notistack', () => ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...jest.requireActual('notistack'), + useSnackbar: () => { + return { + enqueueSnackbar: mockEnqueue, + }; + }, +})); + +describe('TntConnect Import', () => { + 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 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 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, + 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', + }); + }); + }); + 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 new file mode 100644 index 000000000..2a78fd638 --- /dev/null +++ b/src/components/Tool/TntConnect/TntConnect.tsx @@ -0,0 +1,389 @@ +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 { 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 './uploads/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 [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, + accountListId, + }); + } catch (err) { + enqueueSnackbar( + err instanceof Error ? err.message : t('File 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 file 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; diff --git a/src/components/Tool/TntConnect/uploads/uploadTntConnect.test.ts b/src/components/Tool/TntConnect/uploads/uploadTntConnect.test.ts new file mode 100644 index 000000000..5025d5a17 --- /dev/null +++ b/src/components/Tool/TntConnect/uploads/uploadTntConnect.test.ts @@ -0,0 +1,92 @@ +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, + accountListId, + file, + t, + }), + ).resolves.toBeUndefined(); + }); + + it('rejects files that are not xml files', () => { + return expect( + uploadTnt({ + override: 'false', + selectedTags, + 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, + 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, + accountListId, + file, + t, + }), + ).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, + accountListId, + file, + t, + }), + ).rejects.toThrow('Cannot upload file: server not successful'); + }); +}); diff --git a/src/components/Tool/TntConnect/uploads/uploadTntConnect.ts b/src/components/Tool/TntConnect/uploads/uploadTntConnect.ts new file mode 100644 index 000000000..aaa7273e3 --- /dev/null +++ b/src/components/Tool/TntConnect/uploads/uploadTntConnect.ts @@ -0,0 +1,63 @@ +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, + accountListId, +}: { + selectedTags: string[]; + override: string; + file: File; + t: TFunction; + 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('accountListId', accountListId); + + const res = await fetch(`/api/uploads/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 not successful') + data); + } +};