Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add contact importer + add delete contact functionality #37

Merged
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { ColumnDef } from '@tanstack/react-table';
import { ColumnDef, Table } from '@tanstack/react-table';
import { Download, MoreHorizontal, Trash } from 'lucide-react';
import { DataTableColumnHeader } from '@/components/data-table/column-header';
import { Checkbox } from '@/components/ui/checkbox';
Expand All @@ -27,10 +27,15 @@ const colorConfig = {
red: 'bg-red-100 text-red-900',
};

export type TableProps = {
id: string;
} & CustomerAttributes;

export const getContactColumns = (
attributeConfig: Attribute[]
): ColumnDef<CustomerAttributes>[] => {
let columns: ColumnDef<CustomerAttributes>[] = [];
attributeConfig: Attribute[],
deleteContacts: (_: Table<TableProps>) => void
): ColumnDef<TableProps>[] => {
let columns: ColumnDef<TableProps>[] = [];

columns.push({
id: 'select',
Expand Down Expand Up @@ -171,7 +176,7 @@ export const getContactColumns = (
Export to CSV
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled>
<DropdownMenuItem onClick={() => deleteContacts(table)}>
<Trash className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ImportContactsModal } from './import-contacts';

interface HeaderActionsProps {
organization: string;
list: string;
}

export const HeaderActions = ({ organization, list }: HeaderActionsProps) => {
return (
<div className="flex flex-row items-center gap-4">
<ImportContactsModal organization={organization} list={list} />
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
'use client';

import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { PlusIcon } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import Papa, { ParseResult } from 'papaparse';
import { LinkedInImportContact } from '@/lib/types/import-contacts';
import { useState } from 'react';
import { useToast } from '@/components/ui/use-toast';
import Link from 'next/link';
import { importContacts } from '@/app/actions';
import * as Sentry from '@sentry/nextjs';

interface ImportContactsModalProps {
organization: string;
list: string;
}

export const ImportContactsModal = ({
organization,
list,
}: ImportContactsModalProps) => {
const [importedContacts, setImportedContacts] = useState<
LinkedInImportContact[]
>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const { toast } = useToast();

const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
'text/csv': [],
},
onDropAccepted: acceptedFiles => {
const file = acceptedFiles[0];
if (!file) return;
const reader = new FileReader();
reader.onabort = () => {
Sentry.captureMessage(
'file reading was aborted when importing contacts'
);
toast({
title: 'File reading aborted',
});
};
reader.onerror = () => {
Sentry.captureMessage(
'file reading has failed when importing contacts'
);
toast({
title: 'File reading failed',
description:
'Please try again. If the issue persists, please contact the support.',
});
};
reader.onload = () => {
try {
// Get the binary string
const binaryStr = reader.result;
// Convert to text
const parsedText = new TextDecoder().decode(binaryStr as ArrayBuffer);
// Parse the CSV
Papa.parse(parsedText, {
header: true,
skipEmptyLines: true,
complete: function (results: ParseResult<LinkedInImportContact>) {
setImportedContacts(results.data);
},
});
} catch (error) {
Sentry.captureException(error);
toast({
title: 'Could not parse the file',
description:
'An error occurred while parsing the file. Make sure the format is correct and try again.',
});
}
};
reader.readAsArrayBuffer(file);
},
onDropRejected: () => {
toast({
title: 'Invalid file type',
description: 'Please upload a .csv file',
});
},
maxFiles: 1,
});

const importUploadedContacts = async () => {
try {
setLoading(true);
const { error } = await importContacts(
importedContacts,
organization,
list
);
if (error) {
throw new Error(error.message);
}
setImportedContacts([]);
setLoading(false);
setModalOpen(false);
toast({
title: 'Contacts imported',
description: 'The contacts were successfully imported',
});
} catch (error) {
console.error(error);
setLoading(false);
toast({
title: 'Could not import contacts',
description: 'An error occurred while importing the contacts',
});
}
};

return (
<Dialog open={modalOpen} onOpenChange={open => setModalOpen(open)}>
<DialogTrigger asChild>
<Button size="xs" variant="outline" className="text-sm">
<PlusIcon className="mr-2 h-4 w-4" />
Import Contacts
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Upload Contacts</DialogTitle>
<DialogDescription>
Import your existing contacts by providing your LinkedIn CSV Export.
<br />
<strong>Hint:</strong> You can export your LinkedIn contacts{' '}
<Link
className="underline underline-offset-2"
href="https://www.linkedin.com/mypreferences/d/download-my-data"
target="_blank"
rel="noopener noreferrer"
>
here
</Link>
</DialogDescription>
</DialogHeader>
{importedContacts.length > 0 && (
<>
<h2 className="font-bold">Found Contacts:</h2>
<div className="max-h-48 !overflow-y-auto truncate rounded-lg bg-gray-50 p-2.5">
<ul className="flex flex-col gap-1">
{importedContacts.map((contact, index) => (
<li key={index}>
{contact['First Name']} {contact['Last Name']}
</li>
))}
</ul>
</div>
</>
)}
{!importedContacts.length && (
<>
<div
{...getRootProps()}
className="flex h-48 w-full flex-col items-center justify-center rounded-md border-2 border-dashed border-gray-300 p-6 text-center"
>
<input {...getInputProps()} />
{isDragActive ? (
<p>Drop the files here ...</p>
) : (
<p>Drag 'n' drop a .csv file here, or click to select files</p>
)}
</div>
<Alert variant="warning">
<ExclamationTriangleIcon className="h-4 w-4" />
<AlertTitle>Heads up!</AlertTitle>
<AlertDescription>
Before you import your contacts CSV file, make sure to clean it
up and remove the two note rows at the top of the file.
Otherwise, the import will fail.
</AlertDescription>
</Alert>
</>
)}
<DialogFooter>
{importedContacts.length > 0 && (
<Button
variant="outline"
onClick={() => setImportedContacts([])}
disabled={loading}
>
Clear
</Button>
)}
<Button
type="submit"
disabled={!importedContacts.length || loading}
onClick={importUploadedContacts}
>
Import
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export default async function ListPage({
<div className="flex-1 space-y-4">
<div className="space-y-2">
<ContactTable
list={list}
organization={organization}
contacts={contactsResult.contacts}
attributes={attributesResult.attributes}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,70 @@

import { DataTable } from '@/components/data-table/data-table';
import { Attribute, Contact } from '@prisma/client';
import { getContactColumns } from './columns';
import { TableProps, getContactColumns } from './columns';
import { CustomerAttributes } from '@/lib/types/data-columns';
import { HeaderActions } from './header-actions';
import { useToast } from '@/components/ui/use-toast';
import { Table } from '@tanstack/react-table';
import { deleteContacts } from '@/app/actions';

type ContactTableProps = {
contacts: Contact[];
attributes: Attribute[];
organization: string;
list: string;
};

export const ContactTable = ({ contacts, attributes }: ContactTableProps) => {
const columns = getContactColumns(attributes);
export const ContactTable = ({
contacts,
attributes,
organization,
list,
}: ContactTableProps) => {
const { toast } = useToast();

const deleteSelectedContacts = async (table: Table<TableProps>) => {
// get all selected contacts
const selectedRows = table.getFilteredSelectedRowModel().rows;
const contactIds = selectedRows.map(row => row.original.id);

const { error, data } = await deleteContacts(
contactIds,
organization,
list
);

if (!error) {
toast({
title: 'Contacts deleted',
description: `${data.count} contacts were successfully deleted`,
});
table.resetRowSelection();
return;
}

toast({
title: 'Error',
description: 'There was an error deleting the contacts',
});
};

const columns = getContactColumns(attributes, deleteSelectedContacts);

return (
<DataTable
columns={columns}
data={contacts.map(contact => {
return contact.attributes as CustomerAttributes;
return {
...(contact.attributes as CustomerAttributes),
id: contact.externalId,
} as TableProps;
})}
withPagination
withColumnToggle
additionalHeaderActions={
<HeaderActions organization={organization} list={list} />
}
/>
);
};
Loading