Files
documenso/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
David Nguyen 7f09ba72f4 feat: add envelopes (#2025)
This PR is handles the changes required to support envelopes. The new
envelope editor/signing page will be hidden during release.

The core changes here is to migrate the documents and templates model to
a centralized envelopes model.

Even though Documents and Templates are removed, from the user
perspective they will still exist as we remap envelopes to documents and
templates.
2025-10-14 21:56:36 +11:00

450 lines
18 KiB
TypeScript

import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import {
DocumentDistributionMethod,
DocumentStatus,
EnvelopeType,
type Field,
FieldType,
type Recipient,
RecipientRole,
} from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EnvelopeDistributeDialogProps = {
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
recipients: Recipient[];
fields: Field[];
};
trigger?: React.ReactNode;
};
export const ZEnvelopeDistributeFormSchema = z.object({
meta: z.object({
emailId: z.string().nullable(),
emailReplyTo: z.preprocess(
(val) => (val === '' ? undefined : val),
z.string().email().optional(),
),
subject: z.string(),
message: z.string(),
distributionMethod: z
.nativeEnum(DocumentDistributionMethod)
.optional()
.default(DocumentDistributionMethod.EMAIL),
}),
});
export type TEnvelopeDistributeFormSchema = z.infer<typeof ZEnvelopeDistributeFormSchema>;
export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistributeDialogProps) => {
const organisation = useCurrentOrganisation();
const recipients = envelope.recipients;
const { toast } = useToast();
const { t } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: distributeEnvelope } = trpcReact.envelope.distribute.useMutation();
const form = useForm<TEnvelopeDistributeFormSchema>({
defaultValues: {
meta: {
emailId: envelope.documentMeta?.emailId ?? null,
emailReplyTo: envelope.documentMeta?.emailReplyTo || undefined,
subject: envelope.documentMeta?.subject ?? '',
message: envelope.documentMeta?.message ?? '',
distributionMethod:
envelope.documentMeta?.distributionMethod || DocumentDistributionMethod.EMAIL,
},
},
resolver: zodResolver(ZEnvelopeDistributeFormSchema),
});
const {
handleSubmit,
setValue,
watch,
formState: { isSubmitting },
} = form;
const { data: emailData, isLoading: isLoadingEmails } =
trpc.enterprise.organisation.email.find.useQuery({
organisationId: organisation.id,
perPage: 100,
});
const emails = emailData?.data || [];
const distributionMethod = watch('meta.distributionMethod');
const everySignerHasSignature = useMemo(
() =>
envelope.recipients
.filter((recipient) => recipient.role === RecipientRole.SIGNER)
.every((recipient) =>
envelope.fields.some(
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
),
),
[envelope.recipients, envelope.fields],
);
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
try {
await distributeEnvelope({ envelopeId: envelope.id, meta });
toast({
title: t`Envelope distributed`,
description: t`Your envelope has been distributed successfully.`,
duration: 5000,
});
setIsOpen(false);
} catch (err) {
toast({
title: t`Something went wrong`,
description: t`This envelope could not be distributed at this time. Please try again.`,
variant: 'destructive',
duration: 7500,
});
}
};
if (envelope.status !== DocumentStatus.DRAFT || envelope.type !== EnvelopeType.DOCUMENT) {
return null;
}
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="max-w-md" hideClose>
<DialogHeader>
<DialogTitle>
<Trans>Send Document</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Recipients will be able to sign the document once sent</Trans>
</DialogDescription>
</DialogHeader>
{everySignerHasSignature ? (
<Form {...form}>
<form onSubmit={handleSubmit(onFormSubmit)}>
<fieldset disabled={isSubmitting}>
<Tabs
onValueChange={(value) =>
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
setValue('meta.distributionMethod', value as DocumentDistributionMethod)
}
value={distributionMethod}
className="mb-2"
>
<TabsList className="w-full">
<TabsTrigger className="w-full" value={DocumentDistributionMethod.EMAIL}>
Email
</TabsTrigger>
<TabsTrigger className="w-full" value={DocumentDistributionMethod.NONE}>
None
</TabsTrigger>
</TabsList>
</Tabs>
<div className="min-h-72">
<AnimatePresence initial={false} mode="wait">
{distributionMethod === DocumentDistributionMethod.EMAIL && (
<motion.div
key={'Emails'}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
exit={{ opacity: 0, transition: { duration: 0.15 } }}
>
<Form {...form}>
<fieldset
className="mt-2 flex flex-col gap-y-4 rounded-lg"
disabled={form.formState.isSubmitting}
>
{organisation.organisationClaim.flags.emailDomains && (
<FormField
control={form.control}
name="meta.emailId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email Sender</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value}
onValueChange={(value) =>
field.onChange(value === '-1' ? null : value)
}
>
<SelectTrigger
loading={isLoadingEmails}
className="bg-background"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{emails.map((email) => (
<SelectItem key={email.id} value={email.id}>
{email.email}
</SelectItem>
))}
<SelectItem value={'-1'}>Documenso</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="meta.emailReplyTo"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Reply To Email</Trans>{' '}
<span className="text-muted-foreground">(Optional)</span>
</FormLabel>
<FormControl>
<Input {...field} maxLength={254} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.subject"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Subject</Trans>{' '}
<span className="text-muted-foreground">(Optional)</span>
</FormLabel>
<FormControl>
<Input {...field} maxLength={255} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.message"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Message</Trans>{' '}
<span className="text-muted-foreground">(Optional)</span>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground p-4">
<DocumentSendEmailMessageHelper />
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Textarea
className="bg-background mt-2 h-16 resize-none"
{...field}
maxLength={5000}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
</Form>
</motion.div>
)}
{distributionMethod === DocumentDistributionMethod.NONE && (
<motion.div
key={'Links'}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
exit={{ opacity: 0, transition: { duration: 0.15 } }}
className="min-h-60 rounded-lg border"
>
{envelope.status === DocumentStatus.DRAFT ? (
<div className="text-muted-foreground py-24 text-center text-sm">
<p>
<Trans>We won't send anything to notify recipients.</Trans>
</p>
<p className="mt-2">
<Trans>
We will generate signing links for you, which you can send to the
recipients through your method of choice.
</Trans>
</p>
</div>
) : (
<ul className="text-muted-foreground divide-y">
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans>
</li>
)}
{recipients.map((recipient) => (
<li
key={recipient.id}
className="flex items-center justify-between px-4 py-3 text-sm"
>
<AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
primaryText={
<p className="text-muted-foreground text-sm">
{recipient.email}
</p>
}
secondaryText={
<p className="text-muted-foreground/70 text-xs">
{t(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
}
/>
{recipient.role !== RecipientRole.CC && (
<CopyTextButton
value={formatSigningLink(recipient.token)}
onCopySuccess={() => {
toast({
title: t`Copied to clipboard`,
description: t`The signing link has been copied to your clipboard.`,
});
}}
badgeContentUncopied={
<p className="ml-1 text-xs">
<Trans>Copy</Trans>
</p>
}
badgeContentCopied={
<p className="ml-1 text-xs">
<Trans>Copied</Trans>
</p>
}
/>
)}
</li>
))}
</ul>
)}
</motion.div>
)}
</AnimatePresence>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isSubmitting}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button loading={isSubmitting} type="submit">
{distributionMethod === DocumentDistributionMethod.EMAIL ? (
<Trans>Send</Trans>
) : (
<Trans>Generate Links</Trans>
)}
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
) : (
<>
<Alert variant="warning">
<AlertDescription>
<Trans>
Some signers have not been assigned a signature field. Please assign at least 1
signature field to each signer before proceeding.
</Trans>
</AlertDescription>
</Alert>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
<Trans>Close</Trans>
</Button>
</DialogClose>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
};