diff --git a/apps/web/package.json b/apps/web/package.json index 484659740..71b480000 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,6 +36,7 @@ "next-axiom": "^1.1.1", "next-plausible": "^3.10.1", "next-themes": "^0.2.1", + "papaparse": "^5.4.1", "perfect-freehand": "^1.2.0", "posthog-js": "^1.75.3", "posthog-node": "^3.1.1", @@ -59,6 +60,7 @@ "@types/formidable": "^2.0.6", "@types/luxon": "^3.3.1", "@types/node": "20.1.0", + "@types/papaparse": "^5.3.14", "@types/react": "18.2.18", "@types/react-dom": "18.2.7", "@types/ua-parser-js": "^0.7.39", diff --git a/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx b/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx index 482142c99..4adceda3d 100644 --- a/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx +++ b/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx @@ -1,19 +1,22 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import type * as DialogPrimitive from '@radix-ui/react-dialog'; -import { Mail, PlusCircle, Trash } from 'lucide-react'; +import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react'; +import Papa, { type ParseResult } from 'papaparse'; import { useFieldArray, useForm } from 'react-hook-form'; import { z } from 'zod'; +import { downloadFile } from '@documenso/lib/client-only/download-file'; import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; import { TeamMemberRole } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Dialog, DialogContent, @@ -39,6 +42,7 @@ import { SelectTrigger, SelectValue, } from '@documenso/ui/primitives/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; import { useToast } from '@documenso/ui/primitives/use-toast'; export type InviteTeamMembersDialogProps = { @@ -51,18 +55,45 @@ const ZInviteTeamMembersFormSchema = z .object({ invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations, }) - .refine( - (schema) => { - const emails = schema.invitations.map((invitation) => invitation.email.toLowerCase()); + // Display exactly which rows are duplicates. + .superRefine((items, ctx) => { + const uniqueEmails = new Map(); - return new Set(emails).size === emails.length; - }, - // Dirty hack to handle errors when .root is populated for an array type - { message: 'Members must have unique emails', path: ['members__root'] }, - ); + for (const [index, invitation] of items.invitations.entries()) { + const email = invitation.email.toLowerCase(); + + const firstFoundIndex = uniqueEmails.get(email); + + if (firstFoundIndex === undefined) { + uniqueEmails.set(email, index); + continue; + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Emails must be unique', + path: ['invitations', index, 'email'], + }); + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Emails must be unique', + path: ['invitations', firstFoundIndex, 'email'], + }); + } + }); type TInviteTeamMembersFormSchema = z.infer; +type TabTypes = 'INDIVIDUAL' | 'BULK'; + +const ZImportTeamMemberSchema = z.array( + z.object({ + email: z.string().email(), + role: z.nativeEnum(TeamMemberRole), + }), +); + export const InviteTeamMembersDialog = ({ currentUserTeamRole, teamId, @@ -70,6 +101,8 @@ export const InviteTeamMembersDialog = ({ ...props }: InviteTeamMembersDialogProps) => { const [open, setOpen] = useState(false); + const fileInputRef = useRef(null); + const [invitationType, setInvitationType] = useState('INDIVIDUAL'); const { toast } = useToast(); @@ -130,9 +163,75 @@ export const InviteTeamMembersDialog = ({ useEffect(() => { if (!open) { form.reset(); + setInvitationType('INDIVIDUAL'); } }, [open, form]); + const onFileInputChange = (e: React.ChangeEvent) => { + if (!e.target.files?.length) { + return; + } + + const csvFile = e.target.files[0]; + + Papa.parse(csvFile, { + skipEmptyLines: true, + comments: 'Work email,Job title', + complete: (results: ParseResult) => { + const members = results.data.map((row) => { + const [email, role] = row; + + return { + email: email.trim(), + role: role.trim().toUpperCase(), + }; + }); + + // Remove the first row if it contains the headers. + if (members.length > 1 && members[0].role.toUpperCase() === 'ROLE') { + members.shift(); + } + + try { + const importedInvitations = ZImportTeamMemberSchema.parse(members); + + form.setValue('invitations', importedInvitations); + form.clearErrors('invitations'); + + setInvitationType('INDIVIDUAL'); + } catch (err) { + console.error(err.message); + + toast({ + variant: 'destructive', + title: 'Something went wrong', + description: 'Please check the CSV file and make sure it is according to our format', + }); + } + }, + }); + }; + + const downloadTemplate = () => { + const data = [ + { email: 'admin@documenso.com', role: 'Admin' }, + { email: 'manager@documenso.com', role: 'Manager' }, + { email: 'member@documenso.com', role: 'Member' }, + ]; + + const csvContent = + 'Email address,Role\n' + data.map((row) => `${row.email},${row.role}`).join('\n'); + + const blob = new Blob([csvContent], { + type: 'text/csv', + }); + + downloadFile({ + filename: 'documenso-team-member-invites-template.csv', + data: blob, + }); + }; + return ( -
- -
- {teamMemberInvites.map((teamMemberInvite, index) => ( -
- ( - - {index === 0 && Email address} - - - - - - )} - /> + setInvitationType(value as TabTypes)} + > + + + + Invite Members + - ( - - {index === 0 && Role} - - - - - - )} - /> + + + +
+
+ {teamMemberInvites.map((teamMemberInvite, index) => ( +
+ ( + + {index === 0 && Email address} + + + + + + )} + /> - +
+ ))} +
+ + -
- ))} + + Add more + - + + + + + +
+
+ + + + +
+ + fileInputRef.current?.click()} + > + + +

Click here to upload

+ + +
+
- - - - - - +
+
+
); diff --git a/package-lock.json b/package-lock.json index 5b1f8f0be..1d8663908 100644 --- a/package-lock.json +++ b/package-lock.json @@ -115,6 +115,7 @@ "next-axiom": "^1.1.1", "next-plausible": "^3.10.1", "next-themes": "^0.2.1", + "papaparse": "^5.4.1", "perfect-freehand": "^1.2.0", "posthog-js": "^1.75.3", "posthog-node": "^3.1.1", @@ -138,6 +139,7 @@ "@types/formidable": "^2.0.6", "@types/luxon": "^3.3.1", "@types/node": "20.1.0", + "@types/papaparse": "^5.3.14", "@types/react": "18.2.18", "@types/react-dom": "18.2.7", "@types/ua-parser-js": "^0.7.39", @@ -8081,6 +8083,15 @@ "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==" }, + "node_modules/@types/papaparse": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.14.tgz", + "integrity": "sha512-LxJ4iEFcpqc6METwp9f6BV6VVc43m6MfH0VqFosHvrUgfXiFe6ww7R3itkOQ+TCK6Y+Iv/+RnnvtRZnkc5Kc9g==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse5": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-6.0.3.tgz", @@ -17254,6 +17265,11 @@ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" }, + "node_modules/papaparse": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz", + "integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",