mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
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.
450 lines
18 KiB
TypeScript
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>
|
|
);
|
|
};
|