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(
+ "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);
+ }
+};