import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX, TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX, } from '@documenso/lib/constants/template'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc'; import { AppError } from '@documenso/lib/errors/app-error'; import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { trpc } from '@documenso/trpc/react'; import { DOCUMENT_TITLE_MAX_LENGTH } from '@documenso/trpc/server/document-router/schema'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { Checkbox } from '@documenso/ui/primitives/checkbox'; 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 { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; import { SpinnerBox } from '@documenso/ui/primitives/spinner'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client'; import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import { useFieldArray, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import { match } from 'ts-pattern'; import * as z from 'zod'; import { getTemplateUseErrorMessage } from '~/utils/toast-error-messages'; const DOCUMENT_NAME_SOURCE = { TEMPLATE: 'template', UPLOAD: 'upload', CUSTOM: 'custom', } as const; type TDocumentNameSource = (typeof DOCUMENT_NAME_SOURCE)[keyof typeof DOCUMENT_NAME_SOURCE]; type TCustomDocumentData = { data?: File; uploadSequence?: number; }; const getUploadedDocumentTitle = (file: File) => { return file.name.replace(/\.[^/.]+$/, '').trim(); }; const getLastUploadedFile = (customDocumentData?: TCustomDocumentData[]) => { const uploadedFiles = customDocumentData?.filter( (item): item is Required => item.data !== undefined && item.uploadSequence !== undefined, ); if (!uploadedFiles || uploadedFiles.length === 0) { return undefined; } return uploadedFiles.reduce((lastUploadedFile, uploadedFile) => uploadedFile.uploadSequence > lastUploadedFile.uploadSequence ? uploadedFile : lastUploadedFile, ).data; }; const getTemplateUseDocumentTitle = ({ documentNameSource, customDocumentName, customDocumentData, }: { documentNameSource: TDocumentNameSource; customDocumentName: string; customDocumentData?: TCustomDocumentData[]; }) => match(documentNameSource) .with(DOCUMENT_NAME_SOURCE.UPLOAD, () => { const uploadedFile = getLastUploadedFile(customDocumentData); return uploadedFile ? getUploadedDocumentTitle(uploadedFile) : undefined; }) .with(DOCUMENT_NAME_SOURCE.CUSTOM, () => customDocumentName.trim()) .with(DOCUMENT_NAME_SOURCE.TEMPLATE, () => undefined) .exhaustive(); const ZAddRecipientsForNewDocumentSchema = z .object({ distributeDocument: z.boolean(), useCustomDocument: z.boolean().default(false), documentNameSource: z.enum([ DOCUMENT_NAME_SOURCE.TEMPLATE, DOCUMENT_NAME_SOURCE.UPLOAD, DOCUMENT_NAME_SOURCE.CUSTOM, ]), customDocumentName: z.string(), customDocumentData: z .array( z.object({ title: z.string(), data: z.instanceof(File).optional(), uploadSequence: z.number().optional(), envelopeItemId: z.string(), }), ) .optional(), recipients: z.array( z.object({ id: z.number(), email: ZRecipientEmailSchema, name: z.string(), signingOrder: z.number().optional(), }), ), }) .superRefine((data, ctx) => { if (data.documentNameSource === DOCUMENT_NAME_SOURCE.TEMPLATE) { return; } const title = getTemplateUseDocumentTitle(data); const path = data.documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM ? 'customDocumentName' : 'documentNameSource'; if (!title) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: msg`Document name is required`.id, path: [path], }); return; } if (title.length > DOCUMENT_TITLE_MAX_LENGTH) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: msg`Document name is too long`.id, path: [path], }); } }); type TAddRecipientsForNewDocumentSchema = z.infer; export type TemplateUseDialogProps = { envelopeId: string; templateId: number; templateSigningOrder?: DocumentSigningOrder | null; recipients: TRecipientLite[]; documentDistributionMethod?: DocumentDistributionMethod; documentRootPath: string; trigger?: React.ReactNode; }; export function TemplateUseDialog({ recipients, documentDistributionMethod = DocumentDistributionMethod.EMAIL, documentRootPath, envelopeId, templateId, templateSigningOrder, trigger, }: TemplateUseDialogProps) { const { toast } = useToast(); const { _ } = useLingui(); const navigate = useNavigate(); const [open, setOpen] = useState(false); const uploadSequenceRef = useRef(0); const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery( { envelopeId, }, { placeholderData: (previousData) => previousData, ...SKIP_QUERY_BATCH_META, ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, enabled: open, }, ); const envelopeItems = response?.data ?? []; const generateDefaultFormValues = () => { return { distributeDocument: false, useCustomDocument: false, documentNameSource: DOCUMENT_NAME_SOURCE.TEMPLATE, customDocumentName: '', customDocumentData: envelopeItems.map((item) => ({ title: item.title, data: undefined, envelopeItemId: item.id, })), recipients: recipients .sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0)) .map((recipient) => { const isRecipientEmailPlaceholder = recipient.email.match(TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX); const isRecipientNamePlaceholder = recipient.name.match(TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX); return { id: recipient.id, name: !isRecipientNamePlaceholder ? recipient.name : '', email: !isRecipientEmailPlaceholder ? recipient.email : '', signingOrder: recipient.signingOrder ?? undefined, }; }), }; }; const form = useForm({ resolver: zodResolver(ZAddRecipientsForNewDocumentSchema), defaultValues: generateDefaultFormValues(), }); const { replace, fields: localCustomDocumentData } = useFieldArray({ control: form.control, name: 'customDocumentData', }); const { mutateAsync: createDocumentFromTemplate } = trpc.template.createDocumentFromTemplate.useMutation(); const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => { try { const documentTitle = getTemplateUseDocumentTitle(data); const customFilesToUpload = (data.customDocumentData ?? []).filter( (item): item is typeof item & { data: File } => item.data !== undefined, ); const customDocumentData = await Promise.all( customFilesToUpload.map(async (item) => { const customDocumentData = await putPdfFile(item.data); return { documentDataId: customDocumentData.id, envelopeItemId: item.envelopeItemId, }; }), ); const { envelopeId } = await createDocumentFromTemplate({ templateId, recipients: data.recipients, distributeDocument: data.distributeDocument, customDocumentData, ...(documentTitle ? { override: { title: documentTitle } } : {}), }); toast({ title: _(msg`Document created`), description: _(msg`Your document has been created from the template successfully.`), duration: 5000, }); let documentPath = `${documentRootPath}/${envelopeId}`; if (data.distributeDocument && documentDistributionMethod === DocumentDistributionMethod.NONE) { documentPath += '?action=view-signing-links'; } await navigate(documentPath); } catch (err) { const error = AppError.parseError(err); const errorMessage = getTemplateUseErrorMessage(error.code); toast({ title: _(errorMessage.title), description: _(errorMessage.description), variant: 'destructive', }); } }; const { fields: formRecipients } = useFieldArray({ control: form.control, name: 'recipients', }); const useCustomDocument = form.watch('useCustomDocument'); const documentNameSource = form.watch('documentNameSource'); const customDocumentData = form.watch('customDocumentData'); const lastUploadedFile = useCustomDocument ? getLastUploadedFile(customDocumentData) : undefined; const canUseUploadedDocumentName = Boolean(lastUploadedFile); useEffect(() => { if (open) { form.reset(generateDefaultFormValues()); } }, [open, form]); useEffect(() => { if (envelopeItems.length > 0 && localCustomDocumentData.length === 0) { replace( envelopeItems.map((item) => ({ title: item.title, data: undefined, envelopeItemId: item.id, })), ); } }, [envelopeItems, form, open]); useEffect(() => { if (documentNameSource !== DOCUMENT_NAME_SOURCE.UPLOAD || canUseUploadedDocumentName) { return; } form.setValue('documentNameSource', DOCUMENT_NAME_SOURCE.TEMPLATE); form.clearErrors('documentNameSource'); }, [canUseUploadedDocumentName, documentNameSource, form]); return ( !form.formState.isSubmitting && setOpen(value)}> {trigger || ( )} Create document from template {recipients.length === 0 ? ( A draft document will be created ) : ( Add the recipients to create the document with )}
{formRecipients.map((recipient, index) => (
{templateSigningOrder === DocumentSigningOrder.SEQUENTIAL && ( ( )} /> )} ( {index === 0 && ( Email )} )} /> ( {index === 0 && ( Name )} )} />
))} {recipients.length > 0 && (
(
{documentDistributionMethod === DocumentDistributionMethod.EMAIL && ( )} {documentDistributionMethod === DocumentDistributionMethod.NONE && ( )}
)} />
)} (
{ field.onChange(checked); if (!checked) { const customDocumentData = form.getValues('customDocumentData'); form.setValue( 'customDocumentData', customDocumentData?.map((item) => ({ ...item, data: undefined, })), ); form.clearErrors('customDocumentData'); } }} />
)} /> {useCustomDocument && (
{isLoadingEnvelopeItems ? ( ) : ( localCustomDocumentData.map((item, i) => ( (

{field.value ? getUploadedDocumentTitle(field.value) : item.title}

{field.value ? ( Custom {(field.value.size / (1024 * 1024)).toFixed(2)} MB file ) : ( Default file )}

{field.value ? (
) : ( )} { const file = e.target.files?.[0]; if (!file) { field.onChange(undefined); return; } if (file.type !== 'application/pdf') { form.setError(`customDocumentData.${i}.data`, { type: 'manual', message: _(msg`Please select a PDF file`), }); return; } if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) { form.setError(`customDocumentData.${i}.data`, { type: 'manual', message: _( msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`, ), }); return; } field.onChange(file); form.setValue( `customDocumentData.${i}.uploadSequence`, ++uploadSequenceRef.current, ); form.clearErrors(`customDocumentData.${i}.data`); }} />
)} /> )) )}
)} ( Document name
The document name will use the most recently uploaded file name without its extension.
{lastUploadedFile && (

{lastUploadedFile.name}

)} {!canUseUploadedDocumentName && (

Upload a custom document to use its file name.

)}
)} /> {documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM && ( ( )} /> )}
); }