feat: ability to import csv and invite team invitation in bulk (#237)

* feat: ability to import csv and invite team invitation in bulk

* feat: bulk imports

* feat: some minor fixes

* fix: component import

---------

Co-authored-by: Puru D <[email protected]>
This commit is contained in:
Anik Dhabal Babu
2024-04-26 02:51:33 -05:00
committed by GitHub
co-authored by Puru D
parent 0ba19a712f
commit 964beb55ba
10 changed files with 287 additions and 23 deletions
@@ -0,0 +1,3 @@
Full name,Work email,Job title
John Doe,[email protected],Co-Founder & CTO
Jane Doe,[email protected],Lawyer at Law Firm LLP
1 Full name Work email Job title
2 John Doe [email protected] Co-Founder & CTO
3 Jane Doe [email protected] Lawyer at Law Firm LLP
@@ -1,10 +1,18 @@
import MemberBulkImportModal from "@/components/member/member-bulk-import-modal";
import MemberModal from "@/components/member/member-modal";
import MemberTable from "@/components/member/member-table";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { api } from "@/trpc/server";
import { RiAddLine } from "@remixicon/react";
import { RiAddLine, RiUserLine } from "@remixicon/react";
import { type Metadata } from "next";
export const metadata: Metadata = {
@@ -13,7 +21,6 @@ export const metadata: Metadata = {
const TeamMembersPage = async () => {
const members = await api.member.getMembers.query();
return (
<div className="flex flex-col gap-y-3">
<div className="flex items-center justify-between gap-y-3 ">
@@ -25,20 +32,36 @@ const TeamMembersPage = async () => {
</div>
<div>
<MemberModal
title="Invite a team member"
subtitle="Invite a team member to your company."
member={{
name: "",
email: "",
title: "",
}}
>
<Button className="w-full md:w-auto" size="sm">
<RiAddLine className="inline-block h-5 w-5" />
Team member
</Button>
</MemberModal>
<DropdownMenu>
<DropdownMenuTrigger>
<Button className="w-full md:w-auto" size="sm">
<RiAddLine className="inline-block h-5 w-5" />
Invite member
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem asChild>
<MemberModal
title="Invite a team member"
subtitle="Invite a team member to your company."
member={{
name: "",
email: "",
title: "",
}}
>
<div className="flex cursor-default items-center rounded-sm py-1.5 pr-2 text-sm">
<RiUserLine className="mr-2 h-5 w-5" />
Invite a team member
</div>
</MemberModal>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<MemberBulkImportModal />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
+11 -3
View File
@@ -168,21 +168,29 @@ const navigation = [
const company = [
{
id: 1,
name: "Team",
rootPath: "/settings/team",
href: "/settings/team",
icon: RiGroup2Line,
activeIcon: RiGroup2Fill,
},
{
id: 2,
name: "Settings",
rootPath: "/settings/",
rootPath: "/settings/company",
href: "/settings/company",
icon: RiEqualizer2Line,
activeIcon: RiEqualizer2Fill,
},
{
id: 2,
id: 3,
name: "Form 3921",
href: "/3921",
icon: RiFileTextLine,
activeIcon: RiFileTextFill,
},
{
id: 3,
id: 4,
name: "409A Valuation",
href: "/409a",
icon: RiFileTextLine,
@@ -0,0 +1,30 @@
"use client";
import Modal from "@/components/common/modal";
import TeamMemberUploader from "@/components/member/member-uploader";
import { RiGroupLine } from "@remixicon/react";
import { useState } from "react";
export default function MemberBulkImportModal() {
const [open, setOpen] = useState(false);
return (
<Modal
size="xl"
title="Import multiple team members"
subtitle="Import and invite multiple team members to join your company."
dialogProps={{
open,
onOpenChange: (val) => {
setOpen(val);
},
}}
trigger={
<div className="flex cursor-default items-center rounded-sm py-1.5 pr-2 text-sm">
<RiGroupLine className="mr-2 h-5 w-5" />
Invite multiple team members
</div>
}
>
<TeamMemberUploader setOpen={setOpen} />
</Modal>
);
}
+3 -1
View File
@@ -178,7 +178,9 @@ const MemberModal = ({
<Button
loading={isSubmitting}
loadingText="Sending invite..."
loadingText={
rest.isEditMode === true ? "Updating..." : "Sending invite..."
}
className="mt-5"
>
{rest.isEditMode === true ? "Update team member" : "Send an invite"}
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { Button } from "@/components/ui/button";
import { toast } from "@/components/ui/use-toast";
import { parseInviteMembersCSV } from "@/lib/invite-team-members-csv-parser";
import { api } from "@/trpc/react";
import { type TypeZodInviteMemberArrayMutationSchema } from "@/trpc/routers/member-router/schema";
import { RiUploadLine } from "@remixicon/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useRef, useState } from "react";
type TeamMemberUploaderType = {
setOpen: (val: boolean) => void;
};
const TeamMemberUploader = ({ setOpen }: TeamMemberUploaderType) => {
const [csvFile, setCSVFile] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
const inviteMember = api.member.inviteMember.useMutation({
onSuccess: () => {
setOpen(false);
toast({
variant: "default",
title: "🎉 Invited!",
description: "You have successfully invited the stakeholder.",
});
router.refresh();
},
onError: (error) => {
toast({
variant: "destructive",
title: error.message,
description: "",
});
},
});
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.currentTarget.files ?? []);
setCSVFile(files);
};
const onImport = async () => {
try {
if (!csvFile[0]) {
return;
}
const parsedData = (await parseInviteMembersCSV(
csvFile[0],
)) as TypeZodInviteMemberArrayMutationSchema;
await Promise.all(
parsedData.map(async (data) => {
await inviteMember.mutateAsync(data);
}),
);
setOpen(false);
} catch (error) {
console.error((error as Error).message);
toast({
variant: "destructive",
title: "Something went wrong!",
description:
"Please check the CSV file and make sure its according to our format",
});
}
};
return (
<div className="space-y-4">
<div className="text-sm leading-6 text-neutral-600">
Please download the{" "}
<Link
download
href="/sample-csv/captable-team-members-template.csv"
target="_blank"
rel="noopener noreferrer"
className="rounded bg-gray-300/70 px-2 py-1 text-xs font-medium hover:bg-gray-400/50"
>
<span className="mr-1">sample csv file</span>
<span aria-hidden="true"> &darr;</span>
</Link>
, complete and upload it to import your existing or new team members.
</div>
<div
className="flex h-24 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-gray-300"
onClick={() => fileInputRef.current?.click()}
>
<RiUploadLine className="h-7 w-7 text-neutral-500" />
<span className="text-sm text-neutral-500">
{csvFile.length !== 0 ? csvFile[0]?.name : "Click here to import"}
</span>
<input
onChange={onFileInputChange}
type="file"
ref={fileInputRef}
accept=".csv"
hidden
/>
</div>
<div className="text-xs">
<Link
target="_blank"
rel="noopener noreferrer"
href={""}
className="text-teal-700 underline"
>
Learn more
</Link>{" "}
about the sample csv format
</div>
<Button onClick={onImport} className="ml-auto block">
Import
</Button>
</div>
);
};
export default TeamMemberUploader;
@@ -2,7 +2,7 @@
import { Button } from "@/components/ui/button";
import { toast } from "@/components/ui/use-toast";
import { parseCSV } from "@/lib/csv-parser";
import { parseStrakeholdersCSV } from "@/lib/stakeholders-csv-parser";
import { api } from "@/trpc/react";
import { type TypeStakeholderArray } from "@/trpc/routers/stakeholder-router/schema";
import { RiUploadLine } from "@remixicon/react";
@@ -46,7 +46,7 @@ const StakeholderUploader = ({ setOpen }: StakeholderUploaderType) => {
return;
}
const parsedData = await parseCSV(csvFile[0]);
const parsedData = await parseStrakeholdersCSV(csvFile[0]);
await mutateAsync(parsedData as TypeStakeholderArray);
setOpen(false);
+64
View File
@@ -0,0 +1,64 @@
import { ZodInviteMemberMutationSchema } from "@/trpc/routers/member-router/schema";
import Papa, { type ParseResult } from "papaparse";
import { ZodError } from "zod";
export const parseInviteMembersCSV = async (csvFile: File) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function (event) {
const csvData = event.target?.result as string;
const parsed: ParseResult<string[]> = Papa.parse(csvData, {
skipEmptyLines: true,
comments: "Full name,Work email,Job title",
});
const keys = ["name", "email", "title"];
const mappedCSV = parsed.data.map((csv) => {
const values = csv.map((value) => {
value = value.trim();
return value;
});
if (values.length != keys.length) {
reject(
new Error(
`Invalid values, Please make sure you have ${keys.length} values. You can put "" (empty string) for the optional fields.`,
),
);
return;
}
const entry = Object.fromEntries(
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
keys.map((key, index) => [key, values[index] || undefined]),
);
const filtered = Object.fromEntries(
Object.entries(entry).filter(([_, value]) => value !== undefined),
);
return filtered;
});
mappedCSV.forEach((csv) => {
try {
ZodInviteMemberMutationSchema.parse(csv);
} catch (error) {
if (error instanceof ZodError) {
return new Error(error.issues[0]?.message);
}
}
});
resolve(mappedCSV);
};
reader.onerror = function () {
reject(new Error("Error reading the file"));
};
reader.readAsText(csvFile);
});
};
@@ -2,7 +2,7 @@ import { ZodAddStakeholderMutationSchema } from "@/trpc/routers/stakeholder-rout
import Papa, { type ParseResult } from "papaparse";
import { ZodError } from "zod";
export const parseCSV = async (csvFile: File) => {
export const parseStrakeholdersCSV = async (csvFile: File) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
+8
View File
@@ -12,6 +12,14 @@ export type TypeZodInviteMemberMutationSchema = z.infer<
typeof ZodInviteMemberMutationSchema
>;
export const ZodInviteMemberArrayMutationSchema = z.array(
ZodInviteMemberMutationSchema,
);
export type TypeZodInviteMemberArrayMutationSchema = z.infer<
typeof ZodInviteMemberArrayMutationSchema
>;
export const ZodAcceptMemberMutationSchema = z.object({
memberId: z.string().min(1),
name: z.string().min(1, "This field is required"),