Compare commits

...

12 Commits

Author SHA1 Message Date
David Nguyen 0d22b43c98 chore: remove random file 2026-02-23 15:51:59 +11:00
David Nguyen e2794ec42b fix: add tests 2026-02-23 15:46:38 +11:00
David Nguyen e2b3597c36 fix: wip 2026-02-19 11:36:36 +11:00
David Nguyen 3acc029fef fix: wip 2026-02-17 13:45:09 +11:00
David Nguyen 2a53104644 fix: wip 2026-02-12 18:03:18 +11:00
David Nguyen 6541f2778b fix: wip 2026-02-12 11:14:36 +11:00
David Nguyen ab3e8a4074 fix: pdf viewer scroll elements 2026-02-06 14:59:52 +11:00
David Nguyen cb6d6e46d0 fix: replace etag with hard cache 2026-02-06 13:35:41 +11:00
David Nguyen c20affa286 Merge branch 'main' into feat/add-pdf-image-renderer 2026-02-04 12:50:40 +11:00
David Nguyen a69fe940b5 fix: refactor 2026-01-27 15:42:35 +11:00
David Nguyen 8186d2817f fix: add client side pdf render 2026-01-27 15:12:59 +11:00
David Nguyen 4fb3c2cb0f feat: add pdf image renderer 2026-01-27 14:39:16 +11:00
117 changed files with 8951 additions and 2541 deletions
@@ -1,3 +1,5 @@
import { useRef } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
@@ -5,6 +7,7 @@ import { useNavigate } from 'react-router';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcReact } from '@documenso/trpc/react'; import { trpc as trpcReact } from '@documenso/trpc/react';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@@ -13,7 +16,6 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
@@ -38,6 +40,8 @@ export const DocumentDuplicateDialog = ({
const team = useCurrentTeam(); const team = useCurrentTeam();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } = const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
trpcReact.envelope.item.getManyByToken.useQuery( trpcReact.envelope.item.getManyByToken.useQuery(
{ {
@@ -95,12 +99,13 @@ export const DocumentDuplicateDialog = ({
</h1> </h1>
</div> </div>
) : ( ) : (
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll"> <div ref={scrollContainerRef} className="h-[50vh] overflow-y-scroll p-2">
<PDFViewerLazy <PDFViewer
key={envelopeItems[0].id} key={envelopeItems[0].id}
envelopeItem={envelopeItems[0]} envelopeItem={envelopeItems[0]}
token={undefined} token={undefined}
version="original" version="initial"
scrollParentRef={scrollContainerRef}
/> />
</div> </div>
)} )}
@@ -0,0 +1,215 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { P, match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
type EnvelopeDeleteDialogProps = {
id: string;
type: EnvelopeType;
trigger?: React.ReactNode;
onDelete?: () => Promise<void> | void;
status: DocumentStatus;
title: string;
canManageDocument: boolean;
};
export const EnvelopeDeleteDialog = ({
id,
type,
trigger,
onDelete,
status,
title,
canManageDocument,
}: EnvelopeDeleteDialogProps) => {
const { toast } = useToast();
const { refreshLimits } = useLimits();
const { t } = useLingui();
const deleteMessage = msg`delete`;
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState('');
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
const { mutateAsync: deleteEnvelope, isPending } = trpcReact.envelope.delete.useMutation({
onSuccess: async () => {
void refreshLimits();
toast({
title: t`Document deleted`,
description: t`"${title}" has been successfully deleted`,
duration: 5000,
});
await onDelete?.();
setOpen(false);
},
onError: () => {
toast({
title: t`Something went wrong`,
description: t`This document could not be deleted at this time. Please try again.`,
variant: 'destructive',
duration: 7500,
});
},
});
useEffect(() => {
if (open) {
setInputValue('');
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
}
}, [open, status]);
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
setIsDeleteEnabled(event.target.value === t(deleteMessage));
};
return (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription>
{canManageDocument ? (
<Trans>
You are about to delete <strong>"{title}"</strong>
</Trans>
) : (
<Trans>
You are about to hide <strong>"{title}"</strong>
</Trans>
)}
</DialogDescription>
</DialogHeader>
{canManageDocument ? (
<Alert variant="warning" className="-mt-1">
{match(status)
.with(DocumentStatus.DRAFT, () => (
<AlertDescription>
{type === EnvelopeType.DOCUMENT ? (
<Trans>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this document will be permanently deleted.
</Trans>
) : (
<Trans>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this template will be permanently deleted.
</Trans>
)}
</AlertDescription>
))
.with(DocumentStatus.PENDING, () => (
<AlertDescription>
<p>
<Trans>
Please note that this action is <strong>irreversible</strong>.
</Trans>
</p>
<p className="mt-1">
<Trans>Once confirmed, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>
<Trans>Document will be permanently deleted</Trans>
</li>
<li>
<Trans>Document signing process will be cancelled</Trans>
</li>
<li>
<Trans>All inserted signatures will be voided</Trans>
</li>
<li>
<Trans>All recipients will be notified</Trans>
</li>
</ul>
</AlertDescription>
))
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
<AlertDescription>
<p>
<Trans>By deleting this document, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>
<Trans>The document will be hidden from your account</Trans>
</li>
<li>
<Trans>Recipients will still retain their copy of the document</Trans>
</li>
</ul>
</AlertDescription>
))
.exhaustive()}
</Alert>
) : (
<Alert variant="warning" className="-mt-1">
<AlertDescription>
<Trans>Please contact support if you would like to revert this action.</Trans>
</AlertDescription>
</Alert>
)}
{status !== DocumentStatus.DRAFT && canManageDocument && (
<Input
type="text"
value={inputValue}
onChange={onInputChange}
placeholder={t`Please type ${`'${t(deleteMessage)}'`} to confirm`}
/>
)}
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
type="button"
loading={isPending}
onClick={() => void deleteEnvelope({ envelopeId: id })}
disabled={!isDeleteEnabled && canManageDocument}
variant="destructive"
>
{canManageDocument ? <Trans>Delete</Trans> : <Trans>Hide</Trans>}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -13,6 +13,7 @@ import * as z from 'zod';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients'; import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { trpc, trpc as trpcReact } from '@documenso/trpc/react'; import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
@@ -116,10 +117,15 @@ export const EnvelopeDistributeDialog = ({
} = form; } = form;
const { data: emailData, isLoading: isLoadingEmails } = const { data: emailData, isLoading: isLoadingEmails } =
trpc.enterprise.organisation.email.find.useQuery({ trpc.enterprise.organisation.email.find.useQuery(
organisationId: organisation.id, {
perPage: 100, organisationId: organisation.id,
}); perPage: 100,
},
{
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const emails = emailData?.data || []; const emails = emailData?.data || [];
@@ -8,6 +8,8 @@ import { useDropzone } from 'react-dropzone';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { PDF_IMAGE_RENDER_SCALE } from '@documenso/lib/constants/pdf-viewer';
import { pdfToImagesClientSide } from '@documenso/lib/server-only/ai/pdf-to-images.client';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
@@ -52,12 +54,17 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer); const uint8Array = new Uint8Array(arrayBuffer);
const pdfImages = await pdfToImagesClientSide(uint8Array, {
scale: PDF_IMAGE_RENDER_SCALE,
});
// Store file metadata and UInt8Array in form data // Store file metadata and UInt8Array in form data
form.setValue('documentData', { form.setValue('documentData', {
name: file.name, name: file.name,
type: file.type, type: file.type,
size: file.size, size: file.size,
data: uint8Array, // Store as UInt8Array data: uint8Array, // Store as UInt8Array
images: pdfImages,
}); });
// Auto-populate title if it's empty // Auto-populate title if it's empty
@@ -144,7 +151,7 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
<div <div
{...getRootProps()} {...getRootProps()}
className={cn( className={cn(
'border-border bg-background relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition', 'relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-border bg-background transition',
{ {
'border-primary/50 bg-primary/5': isDragActive, 'border-primary/50 bg-primary/5': isDragActive,
'hover:bg-muted/30': 'hover:bg-muted/30':
@@ -193,21 +200,21 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
</FormControl> </FormControl>
{isLoading && ( {isLoading && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg"> <div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background/50">
<Loader className="text-muted-foreground h-10 w-10 animate-spin" /> <Loader className="h-10 w-10 animate-spin text-muted-foreground" />
</div> </div>
)} )}
</div> </div>
) : ( ) : (
<div className="mt-2 rounded-lg border p-4"> <div className="mt-2 rounded-lg border p-4">
<div className="flex items-center gap-x-4"> <div className="flex items-center gap-x-4">
<div className="bg-primary/10 text-primary flex h-12 w-12 items-center justify-center rounded-md"> <div className="flex h-12 w-12 items-center justify-center rounded-md bg-primary/10 text-primary">
<FileText className="h-6 w-6" /> <FileText className="h-6 w-6" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="text-sm font-medium">{documentData.name}</div> <div className="text-sm font-medium">{documentData.name}</div>
<div className="text-muted-foreground text-xs"> <div className="text-xs text-muted-foreground">
{formatFileSize(documentData.size)} {formatFileSize(documentData.size)}
</div> </div>
</div> </div>
@@ -46,6 +46,13 @@ export const ZConfigureEmbedFormSchema = z.object({
type: z.string(), type: z.string(),
size: z.number(), size: z.number(),
data: z.instanceof(Uint8Array), // UInt8Array can't be directly validated by zod data: z.instanceof(Uint8Array), // UInt8Array can't be directly validated by zod
images: z
.object({
width: z.number(),
height: z.number(),
image: z.string(),
})
.array(),
}) })
.optional(), .optional(),
}); });
@@ -5,7 +5,6 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { EnvelopeItem, FieldType } from '@prisma/client'; import type { EnvelopeItem, FieldType } from '@prisma/client';
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client'; import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
import { base64 } from '@scure/base';
import { ChevronsUpDown } from 'lucide-react'; import { ChevronsUpDown } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
@@ -16,6 +15,7 @@ import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers'; import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors'; import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -24,7 +24,6 @@ import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/type
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { FieldSelector } from '@documenso/ui/primitives/field-selector'; import { FieldSelector } from '@documenso/ui/primitives/field-selector';
import { Form } from '@documenso/ui/primitives/form/form'; import { Form } from '@documenso/ui/primitives/form/form';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector'; import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet'; import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -84,7 +83,7 @@ export const ConfigureFieldsView = ({
}; };
}, []); }, []);
const normalizedDocumentData = useMemo(() => { const overrideImages = useMemo(() => {
if (envelopeItem) { if (envelopeItem) {
return undefined; return undefined;
} }
@@ -93,7 +92,7 @@ export const ConfigureFieldsView = ({
return undefined; return undefined;
} }
return base64.encode(configData.documentData.data); return configData.documentData.images;
}, [configData.documentData]); }, [configData.documentData]);
const normalizedEnvelopeItem = useMemo(() => { const normalizedEnvelopeItem = useMemo(() => {
@@ -546,12 +545,13 @@ export const ConfigureFieldsView = ({
<Form {...form}> <Form {...form}>
<div> <div>
<PDFViewerLazy <PDFViewer
presignToken={presignToken} presignToken={presignToken}
overrideData={normalizedDocumentData} overrideImages={overrideImages}
envelopeItem={normalizedEnvelopeItem} envelopeItem={normalizedEnvelopeItem}
token={undefined} token={undefined}
version="signed" version="current"
scrollParentRef="window"
/> />
<ElementVisible <ElementVisible
@@ -31,11 +31,11 @@ import type {
TSignFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema'; } from '@documenso/trpc/server/field-router/schema';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -341,10 +341,11 @@ export const EmbedDirectTemplateClientPage = ({
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row"> <div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */} {/* Viewer */}
<div className="flex-1"> <div className="flex-1">
<PDFViewerLazy <PDFViewer
envelopeItem={envelopeItems[0]} envelopeItem={envelopeItems[0]}
token={recipient.token} token={recipient.token}
version="signed" version="current"
scrollParentRef="window"
onDocumentLoad={() => setHasDocumentLoaded(true)} onDocumentLoad={() => setHasDocumentLoaded(true)}
/> />
</div> </div>
@@ -19,11 +19,11 @@ import {
DocumentReadOnlyFields, DocumentReadOnlyFields,
} from '@documenso/ui/components/document/document-read-only-fields'; } from '@documenso/ui/components/document/document-read-only-fields';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -287,10 +287,11 @@ export const EmbedSignDocumentV1ClientPage = ({
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row"> <div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */} {/* Viewer */}
<div className="embed--DocumentViewer flex-1"> <div className="embed--DocumentViewer flex-1">
<PDFViewerLazy <PDFViewer
envelopeItem={envelopeItems[0]} envelopeItem={envelopeItems[0]}
token={token} token={token}
version="signed" version="current"
scrollParentRef="window"
onDocumentLoad={() => setHasDocumentLoaded(true)} onDocumentLoad={() => setHasDocumentLoaded(true)}
/> />
</div> </div>
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useRef, useState } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
@@ -17,12 +17,12 @@ import type {
} from '@documenso/trpc/server/field-router/schema'; } from '@documenso/trpc/server/field-router/schema';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields'; import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -66,6 +66,8 @@ export const MultiSignDocumentSigningView = ({
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false); const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false); const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
@@ -179,7 +181,11 @@ export const MultiSignDocumentSigningView = ({
return ( return (
<div className="min-h-screen overflow-hidden bg-background"> <div className="min-h-screen overflow-hidden bg-background">
<div id="document-field-portal-root" className="relative h-full w-full overflow-y-auto p-8"> <div
id="document-field-portal-root"
ref={scrollContainerRef}
className="relative h-full w-full overflow-y-auto p-8"
>
{match({ isLoading, document }) {match({ isLoading, document })
.with({ isLoading: true }, () => ( .with({ isLoading: true }, () => (
<div className="flex min-h-[400px] w-full items-center justify-center"> <div className="flex min-h-[400px] w-full items-center justify-center">
@@ -226,10 +232,11 @@ export const MultiSignDocumentSigningView = ({
'md:mx-auto md:max-w-2xl': document.status === DocumentStatus.COMPLETED, 'md:mx-auto md:max-w-2xl': document.status === DocumentStatus.COMPLETED,
})} })}
> >
<PDFViewerLazy <PDFViewer
envelopeItem={document.envelopeItems[0]} envelopeItem={document.envelopeItems[0]}
token={token} token={token}
version="signed" version="current"
scrollParentRef={scrollContainerRef}
onDocumentLoad={() => { onDocumentLoad={() => {
setHasDocumentLoaded(true); setHasDocumentLoaded(true);
onDocumentReady?.(); onDocumentReady?.();
@@ -10,10 +10,10 @@ import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-
import type { TTemplate } from '@documenso/lib/types/template'; import type { TTemplate } from '@documenso/lib/types/template';
import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { Stepper } from '@documenso/ui/primitives/stepper'; import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -151,11 +151,12 @@ export const DirectTemplatePageView = ({
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<PDFViewerLazy <PDFViewer
key={template.id} key={template.id}
envelopeItem={template.envelopeItems[0]} envelopeItem={template.envelopeItems[0]}
token={directTemplateRecipient.token} token={directTemplateRecipient.token}
version="signed" version="current"
scrollParentRef="window"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)} onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/> />
</CardContent> </CardContent>
@@ -27,10 +27,10 @@ import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/fie
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields'; import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover'; import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign'; import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
@@ -274,11 +274,12 @@ export const DocumentSigningPageViewV1 = ({
<div className="flex-1"> <div className="flex-1">
<Card className="rounded-xl before:rounded-xl" gradient> <Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2"> <CardContent className="p-2">
<PDFViewerLazy <PDFViewer
key={document.envelopeItems[0].id} key={document.envelopeItems[0].id}
envelopeItem={document.envelopeItems[0]} envelopeItem={document.envelopeItems[0]}
token={recipient.token} token={recipient.token}
version="signed" version="current"
scrollParentRef="window"
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -1,4 +1,4 @@
import { lazy, useMemo } from 'react'; import { lazy, useMemo, useRef } from 'react';
import { Plural, Trans } from '@lingui/react/macro'; import { Plural, Trans } from '@lingui/react/macro';
import { EnvelopeType, RecipientRole } from '@prisma/client'; import { EnvelopeType, RecipientRole } from '@prisma/client';
@@ -8,8 +8,9 @@ import { Link } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator'; import { Separator } from '@documenso/ui/primitives/separator';
@@ -40,6 +41,8 @@ const EnvelopeSignerPageRenderer = lazy(
export const DocumentSigningPageViewV2 = () => { export const DocumentSigningPageViewV2 = () => {
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender(); const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const { const {
isDirectTemplate, isDirectTemplate,
envelope, envelope,
@@ -199,7 +202,10 @@ export const DocumentSigningPageViewV2 = () => {
</div> </div>
</div> </div>
<div className="embed--DocumentContainer flex-1 overflow-y-auto"> <div
className="embed--DocumentContainer flex-1 overflow-y-auto"
ref={scrollableContainerRef}
>
<div className="flex flex-col"> <div className="flex flex-col">
{/* Horizontal envelope item selector */} {/* Horizontal envelope item selector */}
{envelopeItems.length > 1 && ( {envelopeItems.length > 1 && (
@@ -228,15 +234,16 @@ export const DocumentSigningPageViewV2 = () => {
{/* Document View */} {/* Document View */}
<div className="embed--DocumentViewer flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4"> <div className="embed--DocumentViewer flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
{currentEnvelopeItem ? ( {currentEnvelopeItem ? (
<PDFViewerKonvaLazy <EnvelopePdfViewer
renderer="signing"
key={currentEnvelopeItem.id} key={currentEnvelopeItem.id}
customPageRenderer={EnvelopeSignerPageRenderer} customPageRenderer={EnvelopeSignerPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.signing}
/> />
) : ( ) : (
<div className="flex flex-col items-center justify-center py-32"> <div className="flex flex-col items-center justify-center py-32">
<p className="text-sm text-foreground"> <p className="text-sm text-foreground">
<Trans>No documents found</Trans> <Trans>No document selected</Trans>
</p> </p>
</div> </div>
)} )}
@@ -8,6 +8,7 @@ import { Paperclip, Plus, X } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@@ -49,9 +50,16 @@ export const DocumentAttachmentsPopover = ({
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const { data: attachments } = trpc.envelope.attachment.find.useQuery({ const { data: attachments } = trpc.envelope.attachment.find.useQuery(
envelopeId, {
}); envelopeId,
},
{
// Note: The invalidation of the query is manually handled by the onSuccess
// callbacks below for create and delete mutations.
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const { mutateAsync: createAttachment, isPending: isCreating } = const { mutateAsync: createAttachment, isPending: isCreating } =
trpc.envelope.attachment.create.useMutation({ trpc.envelope.attachment.create.useMutation({
@@ -143,7 +151,7 @@ export const DocumentAttachmentsPopover = ({
<h4 className="font-medium"> <h4 className="font-medium">
<Trans>Attachments</Trans> <Trans>Attachments</Trans>
</h4> </h4>
<p className="text-muted-foreground mt-1 text-sm"> <p className="mt-1 text-sm text-muted-foreground">
<Trans>Add links to relevant documents or resources.</Trans> <Trans>Add links to relevant documents or resources.</Trans>
</p> </p>
</div> </div>
@@ -153,7 +161,7 @@ export const DocumentAttachmentsPopover = ({
{attachments?.data.map((attachment) => ( {attachments?.data.map((attachment) => (
<div <div
key={attachment.id} key={attachment.id}
className="border-border flex items-center justify-between rounded-md border p-2" className="flex items-center justify-between rounded-md border border-border p-2"
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{attachment.label}</p> <p className="truncate text-sm font-medium">{attachment.label}</p>
@@ -161,7 +169,7 @@ export const DocumentAttachmentsPopover = ({
href={attachment.data} href={attachment.data}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground truncate text-xs underline" className="truncate text-xs text-muted-foreground underline hover:text-foreground"
> >
{attachment.data} {attachment.data}
</a> </a>
@@ -1,4 +1,4 @@
import { lazy, useEffect, useState } from 'react'; import { lazy, useEffect, useRef, useState } from 'react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client'; import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client';
@@ -9,9 +9,11 @@ import {
EnvelopeRenderProvider, EnvelopeRenderProvider,
useCurrentEnvelopeRender, useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider'; } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@@ -21,7 +23,6 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog'; import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
@@ -35,7 +36,7 @@ export type DocumentCertificateQRViewProps = {
documentId: number; documentId: number;
title: string; title: string;
internalVersion: number; internalVersion: number;
envelopeItems: (EnvelopeItem & { documentData: DocumentData })[]; envelopeItems: (EnvelopeItem & { documentData: Omit<DocumentData, 'metadata'> })[];
documentTeamUrl: string; documentTeamUrl: string;
recipientCount?: number; recipientCount?: number;
completedDate?: Date; completedDate?: Date;
@@ -104,11 +105,13 @@ export const DocumentCertificateQRView = ({
{internalVersion === 2 ? ( {internalVersion === 2 ? (
<EnvelopeRenderProvider <EnvelopeRenderProvider
version="current"
envelope={{ envelope={{
envelopeItems, id: envelopeItems[0].envelopeId,
status: DocumentStatus.COMPLETED, status: DocumentStatus.COMPLETED,
type: EnvelopeType.DOCUMENT, type: EnvelopeType.DOCUMENT,
}} }}
envelopeItems={envelopeItems}
token={token} token={token}
> >
<DocumentCertificateQrV2 <DocumentCertificateQrV2
@@ -149,11 +152,12 @@ export const DocumentCertificateQRView = ({
</div> </div>
<div className="mt-12 w-full"> <div className="mt-12 w-full">
<PDFViewerLazy <PDFViewer
key={envelopeItems[0].id} key={envelopeItems[0].id}
envelopeItem={envelopeItems[0]} envelopeItem={envelopeItems[0]}
token={token} token={token}
version="signed" version="current"
scrollParentRef="window"
/> />
</div> </div>
</> </>
@@ -175,7 +179,9 @@ const DocumentCertificateQrV2 = ({
formattedDate, formattedDate,
token, token,
}: DocumentCertificateQrV2Props) => { }: DocumentCertificateQrV2Props) => {
const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender(); const { envelopeItems } = useCurrentEnvelopeRender();
const scrollableContainerRef = useRef<HTMLDivElement>(null);
return ( return (
<div className="flex min-h-screen flex-col items-start"> <div className="flex min-h-screen flex-col items-start">
@@ -207,10 +213,14 @@ const DocumentCertificateQrV2 = ({
/> />
</div> </div>
<div className="mt-12 w-full"> <div className="mt-12 max-h-[80vh] w-full overflow-y-auto" ref={scrollableContainerRef}>
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} /> <EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
<PDFViewerKonvaLazy renderer="preview" customPageRenderer={EnvelopeGenericPageRenderer} /> <EnvelopePdfViewer
customPageRenderer={EnvelopeGenericPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
/>
</div> </div>
</div> </div>
); );
@@ -15,6 +15,7 @@ import {
import type { TDocument } from '@documenso/lib/types/document'; import type { TDocument } from '@documenso/lib/types/document';
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth'; import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields'; import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
@@ -27,7 +28,6 @@ import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/ad
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types'; import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { Stepper } from '@documenso/ui/primitives/stepper'; import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -440,11 +440,12 @@ export const DocumentEditForm = ({
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<PDFViewerLazy <PDFViewer
key={document.envelopeItems[0].id} key={document.envelopeItems[0].id}
envelopeItem={document.envelopeItems[0]} envelopeItem={document.envelopeItems[0]}
token={undefined} token={undefined}
version="signed" version="current"
scrollParentRef="window"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)} onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/> />
</CardContent> </CardContent>
@@ -175,15 +175,6 @@ export const EnvelopeEditorFieldDragDrop = ({
const { top, left, height, width } = getBoundingClientRect($page); const { top, left, height, width } = getBoundingClientRect($page);
console.log({
top,
left,
height,
width,
rawPageX: event.pageX,
rawPageY: event.pageY,
});
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10); const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
// Calculate x and y as a percentage of the page width and height // Calculate x and y as a percentage of the page width and height
@@ -278,13 +269,13 @@ export const EnvelopeEditorFieldDragDrop = ({
onMouseDown={() => setSelectedField(field.type)} onMouseDown={() => setSelectedField(field.type)}
data-selected={selectedField === field.type ? true : undefined} data-selected={selectedField === field.type ? true : undefined}
className={cn( className={cn(
'border-border group flex h-12 cursor-pointer items-center justify-center rounded-lg border px-4 transition-colors', 'group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-border px-4 transition-colors',
RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton, RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton,
)} )}
> >
<p <p
className={cn( className={cn(
'text-muted-foreground font-noto group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal', 'flex items-center justify-center gap-x-1.5 font-noto text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
field.className, field.className,
{ {
'group-hover:text-recipient-green': selectedRecipientColor === 'green', 'group-hover:text-recipient-green': selectedRecipientColor === 'green',
@@ -306,7 +297,7 @@ export const EnvelopeEditorFieldDragDrop = ({
{selectedField && ( {selectedField && (
<div <div
className={cn( className={cn(
'text-muted-foreground dark:text-muted-background font-noto pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]', 'dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size]',
RECIPIENT_COLOR_STYLES[selectedRecipientColor].base, RECIPIENT_COLOR_STYLES[selectedRecipientColor].base,
selectedField === FieldType.SIGNATURE && 'font-signature', selectedField === FieldType.SIGNATURE && 'font-signature',
{ {
@@ -10,7 +10,10 @@ import { CopyPlusIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields'; import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer'; import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; import {
type PageRenderData,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta'; import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import { import {
MIN_FIELD_HEIGHT_PX, MIN_FIELD_HEIGHT_PX,
@@ -22,10 +25,15 @@ import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients'; import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { CommandDialog } from '@documenso/ui/primitives/command'; import { CommandDialog } from '@documenso/ui/primitives/command';
import { EnvelopePageImage } from '../envelope/envelope-page-image';
import { fieldButtonList } from './envelope-editor-fields-drag-drop'; import { fieldButtonList } from './envelope-editor-fields-drag-drop';
import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector'; import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector';
export default function EnvelopeEditorFieldsPageRenderer() { export default function EnvelopeEditorFieldsPageRenderer({
pageData,
}: {
pageData: PageRenderData;
}) {
const { t, i18n } = useLingui(); const { t, i18n } = useLingui();
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor(); const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender(); const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
@@ -40,31 +48,24 @@ export default function EnvelopeEditorFieldsPageRenderer() {
const { const {
stage, stage,
pageLayer, pageLayer,
canvasElement,
konvaContainer, konvaContainer,
pageContext,
scaledViewport, scaledViewport,
unscaledViewport, unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer)); renderStatus,
imageProps,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer), pageData);
const { _className, scale } = pageContext; const { scale, pageNumber } = pageData;
const localPageFields = useMemo( const localPageFields = useMemo(
() => () =>
editorFields.localFields.filter( editorFields.localFields.filter(
(field) => (field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
), ),
[editorFields.localFields, pageContext.pageNumber], [editorFields.localFields, pageNumber],
); );
const handleResizeOrMove = (event: KonvaEventObject<Event>) => { const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
const { current: container } = canvasElement;
if (!container) {
return;
}
const isDragEvent = event.type === 'dragend'; const isDragEvent = event.type === 'dragend';
const fieldGroup = event.target as Konva.Group; const fieldGroup = event.target as Konva.Group;
@@ -344,7 +345,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
// Create a field if no items are selected or the size is too small. // Create a field if no items are selected or the size is too small.
if ( if (
selectedFieldGroups.length === 0 && selectedFieldGroups.length === 0 &&
canvasElement.current &&
unscaledBoxWidth > MIN_FIELD_WIDTH_PX && unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX && unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
editorFields.selectedRecipient && editorFields.selectedRecipient &&
@@ -515,7 +515,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
removePendingField(); removePendingField();
if (!canvasElement.current || !currentEnvelopeItem || !editorFields.selectedRecipient) { if (!currentEnvelopeItem || !editorFields.selectedRecipient) {
return; return;
} }
@@ -530,7 +530,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
editorFields.addField({ editorFields.addField({
envelopeItemId: currentEnvelopeItem.id, envelopeItemId: currentEnvelopeItem.id,
page: pageContext.pageNumber, page: pageNumber,
type, type,
positionX: fieldX, positionX: fieldX,
positionY: fieldY, positionY: fieldY,
@@ -559,10 +559,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
} }
return ( return (
<div <div className="relative w-full" key={`${currentEnvelopeItem.id}-renderer-${pageNumber}`}>
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{selectedKonvaFieldGroups.length > 0 && {selectedKonvaFieldGroups.length > 0 &&
interactiveTransformer.current && interactiveTransformer.current &&
!isFieldChanging && ( !isFieldChanging && (
@@ -625,13 +622,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
{/* The element Konva will inject it's canvas into. */} {/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div> <div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */} <EnvelopePageImage renderStatus={renderStatus} imageProps={imageProps} />
<canvas
className={`${_className}__canvas z-0`}
ref={canvasElement}
height={scaledViewport.height}
width={scaledViewport.width}
/>
</div> </div>
); );
} }
@@ -1,4 +1,4 @@
import { lazy, useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core'; import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
@@ -6,12 +6,13 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client'; import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon, SparklesIcon } from 'lucide-react'; import { FileTextIcon, SparklesIcon } from 'lucide-react';
import { Link, useRevalidator, useSearchParams } from 'react-router'; import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda'; import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types'; import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
import { import {
FIELD_META_DEFAULT_VALUES, FIELD_META_DEFAULT_VALUES,
@@ -29,7 +30,7 @@ import {
} from '@documenso/lib/types/field-meta'; } from '@documenso/lib/types/field-meta';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients'; import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator'; import { Separator } from '@documenso/ui/primitives/separator';
@@ -49,13 +50,10 @@ import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop'; import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
import EnvelopeEditorFieldsPageRenderer from './envelope-editor-fields-page-renderer';
import { EnvelopeRendererFileSelector } from './envelope-file-selector'; import { EnvelopeRendererFileSelector } from './envelope-file-selector';
import { EnvelopeRecipientSelector } from './envelope-recipient-selector'; import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
const EnvelopeEditorFieldsPageRenderer = lazy(
async () => import('~/components/general/envelope-editor/envelope-editor-fields-page-renderer'),
);
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = { const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
[FieldType.SIGNATURE]: msg`Signature Settings`, [FieldType.SIGNATURE]: msg`Signature Settings`,
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`, [FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
@@ -75,7 +73,9 @@ export const EnvelopeEditorFieldsPage = () => {
const team = useCurrentTeam(); const team = useCurrentTeam();
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor(); const scrollableContainerRef = useRef<HTMLDivElement>(null);
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender(); const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@@ -97,14 +97,10 @@ export const EnvelopeEditorFieldsPage = () => {
const isMetaSame = isDeepEqual(selectedField.fieldMeta, fieldMeta); const isMetaSame = isDeepEqual(selectedField.fieldMeta, fieldMeta);
// Todo: Envelopes - Clean up console logs.
if (!isMetaSame) { if (!isMetaSame) {
console.log('TRIGGER UPDATE');
editorFields.updateFieldByFormId(selectedField.formId, { editorFields.updateFieldByFormId(selectedField.formId, {
fieldMeta, fieldMeta,
}); });
} else {
console.log('DATA IS SAME, NO UPDATE');
} }
}; };
@@ -156,12 +152,12 @@ export const EnvelopeEditorFieldsPage = () => {
return ( return (
<div className="relative flex h-full"> <div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto"> <div className="flex h-full w-full flex-col overflow-y-auto" ref={scrollableContainerRef}>
{/* Horizontal envelope item selector */} {/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector fields={editorFields.localFields} /> <EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */} {/* Document View */}
<div className="mt-4 flex flex-col items-center justify-center"> <div className="mt-4 flex h-full flex-col items-center justify-center">
{envelope.recipients.length === 0 && ( {envelope.recipients.length === 0 && (
<Alert <Alert
variant="neutral" variant="neutral"
@@ -176,18 +172,17 @@ export const EnvelopeEditorFieldsPage = () => {
</AlertDescription> </AlertDescription>
</div> </div>
<Button asChild variant="outline"> <Button variant="outline" onClick={() => void navigateToStep('upload')}>
<Link to={`${relativePath.editorPath}`}> <Trans>Add Recipients</Trans>
<Trans>Add Recipients</Trans>
</Link>
</Button> </Button>
</Alert> </Alert>
)} )}
{currentEnvelopeItem !== null ? ( {currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy <EnvelopePdfViewer
renderer="editor"
customPageRenderer={EnvelopeEditorFieldsPageRenderer} customPageRenderer={EnvelopeEditorFieldsPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
/> />
) : ( ) : (
<div className="flex flex-col items-center justify-center py-32"> <div className="flex flex-col items-center justify-center py-32">
@@ -249,36 +244,40 @@ export const EnvelopeEditorFieldsPage = () => {
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null} selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/> />
<Button {editorConfig.fields?.allowAIDetection && (
type="button" <>
variant="outline" <Button
size="sm" type="button"
className="mt-4 w-full" variant="outline"
onClick={onDetectClick} size="sm"
disabled={envelope.status !== DocumentStatus.DRAFT} className="mt-4 w-full"
title={ onClick={onDetectClick}
envelope.status !== DocumentStatus.DRAFT disabled={envelope.status !== DocumentStatus.DRAFT}
? _(msg`You can only detect fields in draft envelopes`) title={
: undefined envelope.status !== DocumentStatus.DRAFT
} ? _(msg`You can only detect fields in draft envelopes`)
> : undefined
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" /> }
<Trans>Detect with AI</Trans> >
</Button> <SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog <AiFieldDetectionDialog
open={isAiFieldDialogOpen} open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen} onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete} onComplete={onFieldDetectionComplete}
envelopeId={envelope.id} envelopeId={envelope.id}
teamId={envelope.teamId} teamId={envelope.teamId}
/> />
<AiFeaturesEnableDialog <AiFeaturesEnableDialog
open={isAiEnableDialogOpen} open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen} onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled} onEnabled={onAiFeaturesEnabled}
/> />
</>
)}
</section> </section>
{/* Field details section. */} {/* Field details section. */}
@@ -30,21 +30,56 @@ import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
export default function EnvelopeEditorHeader() { export default function EnvelopeEditorHeader() {
const { t } = useLingui(); const { t } = useLingui();
const { envelope, isDocument, isTemplate, updateEnvelope, autosaveError, relativePath } = const {
useCurrentEnvelopeEditor(); envelope,
isDocument,
isTemplate,
isEmbedded,
updateEnvelope,
autosaveError,
relativePath,
editorConfig,
flushAutosave,
} = useCurrentEnvelopeEditor();
const {
embeded,
general: { allowConfigureEnvelopeTitle },
actions: { allowAttachments, allowDistributing },
} = editorConfig;
const handleCreateEmbeddedEnvelope = async () => {
const latestEnvelope = await flushAutosave();
embeded?.onCreate?.(latestEnvelope);
};
const handleUpdateEmbeddedEnvelope = async () => {
const latestEnvelope = await flushAutosave();
embeded?.onUpdate?.(latestEnvelope);
};
return ( return (
<nav className="w-full border-b border-border bg-background px-4 py-3 md:px-6"> <nav className="w-full border-b border-border bg-background px-4 py-3 md:px-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<Link to="/"> {editorConfig.embeded?.customBrandingLogo ? (
<BrandingLogo className="h-6 w-auto" /> <img
</Link> src={`/api/branding/logo/team/${envelope.teamId}`}
alt="Logo"
className="h-6 w-auto"
/>
) : (
<Link to="/">
<BrandingLogo className="h-6 w-auto" />
</Link>
)}
<Separator orientation="vertical" className="h-6" /> <Separator orientation="vertical" className="h-6" />
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<EnvelopeItemTitleInput <EnvelopeItemTitleInput
disabled={envelope.status !== DocumentStatus.DRAFT} disabled={envelope.status !== DocumentStatus.DRAFT || !allowConfigureEnvelopeTitle}
value={envelope.title} value={envelope.title}
onChange={(title) => { onChange={(title) => {
updateEnvelope({ updateEnvelope({
@@ -127,54 +162,72 @@ export default function EnvelopeEditorHeader() {
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" /> {allowAttachments && (
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
<EnvelopeEditorSettingsDialog
trigger={
<Button variant="outline" size="sm">
<SettingsIcon className="h-4 w-4" />
</Button>
}
/>
{isDocument && (
<>
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send Document</Trans>
</Button>
}
/>
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Resend Document</Trans>
</Button>
}
/>
</>
)} )}
{isTemplate && ( {editorConfig.settings && (
<TemplateUseDialog <EnvelopeEditorSettingsDialog
envelopeId={envelope.id}
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
templateSigningOrder={envelope.documentMeta?.signingOrder}
recipients={envelope.recipients}
documentRootPath={relativePath.documentRootPath}
trigger={ trigger={
<Button size="sm"> <Button variant="outline" size="sm">
<Trans>Use Template</Trans> <SettingsIcon className="h-4 w-4" />
</Button> </Button>
} }
/> />
)} )}
{match({ isEmbedded, isDocument, isTemplate, allowDistributing })
.with({ isEmbedded: false, isDocument: true, allowDistributing: true }, () => (
<>
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send Document</Trans>
</Button>
}
/>
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Resend Document</Trans>
</Button>
}
/>
</>
))
.with({ isEmbedded: false, isTemplate: true, allowDistributing: true }, () => (
<TemplateUseDialog
envelopeId={envelope.id}
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
templateSigningOrder={envelope.documentMeta?.signingOrder}
recipients={envelope.recipients}
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<Trans>Use Template</Trans>
</Button>
}
/>
))
.otherwise(() => null)}
{embeded?.mode === 'create' && (
<Button size="sm" onClick={handleCreateEmbeddedEnvelope}>
{isDocument ? <Trans>Create Document</Trans> : <Trans>Create Template</Trans>}
</Button>
)}
{embeded?.mode === 'edit' && (
<Button size="sm" onClick={handleUpdateEmbeddedEnvelope}>
{isDocument ? <Trans>Update Document</Trans> : <Trans>Update Template</Trans>}
</Button>
)}
</div> </div>
</div> </div>
</nav> </nav>
@@ -1,4 +1,4 @@
import { lazy, useEffect, useMemo, useState } from 'react'; import { lazy, useEffect, useMemo, useRef, useState } from 'react';
import { faker } from '@faker-js/faker/locale/en'; import { faker } from '@faker-js/faker/locale/en';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
@@ -11,12 +11,13 @@ import {
EnvelopeRenderProvider, EnvelopeRenderProvider,
useCurrentEnvelopeRender, useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider'; } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing'; import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
import { toCheckboxCustomText } from '@documenso/lib/utils/fields'; import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector'; import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Separator } from '@documenso/ui/primitives/separator'; import { Separator } from '@documenso/ui/primitives/separator';
@@ -33,6 +34,8 @@ export const EnvelopeEditorPreviewPage = () => {
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender(); const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>( const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
'recipient', 'recipient',
); );
@@ -200,7 +203,9 @@ export const EnvelopeEditorPreviewPage = () => {
// Override the parent renderer provider so we can inject custom fields. // Override the parent renderer provider so we can inject custom fields.
return ( return (
<EnvelopeRenderProvider <EnvelopeRenderProvider
version="current"
envelope={envelope} envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={undefined} token={undefined}
fields={fieldsWithPlaceholders} fields={fieldsWithPlaceholders}
recipients={envelope.recipients.map((recipient) => ({ recipients={envelope.recipients.map((recipient) => ({
@@ -212,12 +217,12 @@ export const EnvelopeEditorPreviewPage = () => {
}} }}
> >
<div className="relative flex h-full"> <div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto"> <div className="flex w-full flex-col overflow-y-auto" ref={scrollableContainerRef}>
{/* Horizontal envelope item selector */} {/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector fields={editorFields.localFields} /> <EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */} {/* Document View */}
<div className="mt-4 flex flex-col items-center justify-center"> <div className="mt-4 flex h-full flex-col items-center justify-center">
<Alert variant="warning" className="mb-4 max-w-[800px]"> <Alert variant="warning" className="mb-4 max-w-[800px]">
<AlertTitle> <AlertTitle>
<Trans>Preview Mode</Trans> <Trans>Preview Mode</Trans>
@@ -228,9 +233,10 @@ export const EnvelopeEditorPreviewPage = () => {
</Alert> </Alert>
{currentEnvelopeItem !== null ? ( {currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy <EnvelopePdfViewer
renderer="editor"
customPageRenderer={EnvelopeGenericPageRenderer} customPageRenderer={EnvelopeGenericPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
/> />
) : ( ) : (
<div className="flex flex-col items-center justify-center py-32"> <div className="flex flex-col items-center justify-center py-32">
@@ -21,7 +21,7 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients'; import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema'; import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
@@ -63,8 +63,14 @@ import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-de
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
export const EnvelopeEditorRecipientForm = () => { export const EnvelopeEditorRecipientForm = () => {
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients } = const {
useCurrentEnvelopeEditor(); envelope,
setRecipientsDebounced,
updateEnvelope,
editorRecipients,
isEmbedded,
editorConfig,
} = useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation(); const organisation = useCurrentOrganisation();
const team = useCurrentTeam(); const team = useCurrentTeam();
@@ -72,7 +78,9 @@ export const EnvelopeEditorRecipientForm = () => {
const { t } = useLingui(); const { t } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { remaining } = useLimits(); const { remaining } = useLimits();
const { user } = useSession(); const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [recipientSearchQuery, setRecipientSearchQuery] = useState(''); const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
@@ -133,6 +141,7 @@ export const EnvelopeEditorRecipientForm = () => {
}, },
{ {
enabled: debouncedRecipientSearchQuery.length > 1, enabled: debouncedRecipientSearchQuery.length > 1,
retry: false,
}, },
); );
@@ -603,37 +612,41 @@ export const EnvelopeEditorRecipientForm = () => {
</div> </div>
<div className="flex flex-row items-center space-x-2"> <div className="flex flex-row items-center space-x-2">
<Tooltip> {editorConfig.recipients?.allowAIDetection && (
<TooltipTrigger asChild> <Tooltip>
<Button <TooltipTrigger asChild>
variant="outline" <Button
type="button" variant="outline"
size="sm" type="button"
disabled={isSubmitting} size="sm"
onClick={onDetectRecipientsClick} disabled={isSubmitting}
> onClick={onDetectRecipientsClick}
<SparklesIcon className="h-4 w-4" /> >
</Button> <SparklesIcon className="h-4 w-4" />
</TooltipTrigger> </Button>
</TooltipTrigger>
<TooltipContent> <TooltipContent>
{team.preferences.aiFeaturesEnabled ? ( {team.preferences.aiFeaturesEnabled ? (
<Trans>Detect recipients with AI</Trans> <Trans>Detect recipients with AI</Trans>
) : ( ) : (
<Trans>Enable AI detection</Trans> <Trans>Enable AI detection</Trans>
)} )}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)}
<Button {!isEmbedded && (
variant="outline" <Button
className="flex flex-row items-center" variant="outline"
size="sm" className="flex flex-row items-center"
disabled={isSubmitting || isUserAlreadyARecipient} size="sm"
onClick={() => onAddSelfSigner()} disabled={isSubmitting || isUserAlreadyARecipient}
> onClick={() => onAddSelfSigner()}
<Trans>Add Myself</Trans> >
</Button> <Trans>Add Myself</Trans>
</Button>
)}
<Button <Button
variant="outline" variant="outline"
@@ -652,7 +665,13 @@ export const EnvelopeEditorRecipientForm = () => {
<CardContent> <CardContent>
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}> <AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}> <Form {...form}>
<div className="-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4"> <div
className={cn('-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4', {
hidden:
!editorConfig.recipients?.allowConfigureSigningOrder &&
!organisation.organisationClaim.flags.cfr21,
})}
>
{organisation.organisationClaim.flags.cfr21 && ( {organisation.organisationClaim.flags.cfr21 && (
<div className="flex flex-row items-center"> <div className="flex flex-row items-center">
<Checkbox <Checkbox
@@ -670,64 +689,66 @@ export const EnvelopeEditorRecipientForm = () => {
</div> </div>
)} )}
<FormField {editorConfig.recipients?.allowConfigureSigningOrder && (
control={form.control} <FormField
name="signingOrder" control={form.control}
render={({ field }) => ( name="signingOrder"
<FormItem className="flex flex-row items-center space-x-2 space-y-0"> render={({ field }) => (
<FormControl> <FormItem className="flex flex-row items-center space-x-2 space-y-0">
<Checkbox <FormControl>
{...field} <Checkbox
id="signingOrder" {...field}
checked={field.value === DocumentSigningOrder.SEQUENTIAL} id="signingOrder"
onCheckedChange={(checked) => { checked={field.value === DocumentSigningOrder.SEQUENTIAL}
if (!checked && hasAssistantRole) { onCheckedChange={(checked) => {
setShowSigningOrderConfirmation(true); if (!checked && hasAssistantRole) {
return; setShowSigningOrderConfirmation(true);
} return;
}
field.onChange( field.onChange(
checked checked
? DocumentSigningOrder.SEQUENTIAL ? DocumentSigningOrder.SEQUENTIAL
: DocumentSigningOrder.PARALLEL, : DocumentSigningOrder.PARALLEL,
); );
// If sequential signing is turned off, disable dictate next signer // If sequential signing is turned off, disable dictate next signer
if (!checked) { if (!checked) {
form.setValue('allowDictateNextSigner', false, { form.setValue('allowDictateNextSigner', false, {
shouldValidate: true, shouldValidate: true,
shouldDirty: true, shouldDirty: true,
}); });
} }
}} }}
disabled={isSubmitting || hasDocumentBeenSent} disabled={isSubmitting || hasDocumentBeenSent}
/> />
</FormControl> </FormControl>
<div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"> <div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
<FormLabel <FormLabel
htmlFor="signingOrder" htmlFor="signingOrder"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
> >
<Trans>Enable signing order</Trans> <Trans>Enable signing order</Trans>
</FormLabel> </FormLabel>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="ml-1 cursor-help text-muted-foreground"> <span className="ml-1 cursor-help text-muted-foreground">
<HelpCircleIcon className="h-3.5 w-3.5" /> <HelpCircleIcon className="h-3.5 w-3.5" />
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="max-w-80 p-4"> <TooltipContent className="max-w-80 p-4">
<p> <p>
<Trans>Add 2 or more signers to enable signing order.</Trans> <Trans>Add 2 or more signers to enable signing order.</Trans>
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
</FormItem> </FormItem>
)} )}
/> />
)}
{isSigningOrderSequential && ( {isSigningOrderSequential && (
<FormField <FormField
@@ -987,6 +1008,16 @@ export const EnvelopeEditorRecipientForm = () => {
<FormControl> <FormControl>
<RecipientRoleSelect <RecipientRoleSelect
{...field} {...field}
hideAssistantRole={
!editorConfig.recipients?.allowAssistantRole
}
hideCCerRole={!editorConfig.recipients?.allowCCerRole}
hideViewerRole={
!editorConfig.recipients?.allowViewerRole
}
hideApproverRole={
!editorConfig.recipients?.allowApproverRole
}
isAssistantEnabled={isSigningOrderSequential} isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) => { onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@@ -1083,13 +1114,15 @@ export const EnvelopeEditorRecipientForm = () => {
onConfirm={handleSigningOrderDisable} onConfirm={handleSigningOrderDisable}
/> />
<AiRecipientDetectionDialog {editorConfig.recipients?.allowAIDetection && (
open={isAiDialogOpen} <AiRecipientDetectionDialog
onOpenChange={onAiDialogOpenChange} open={isAiDialogOpen}
onComplete={onAiDetectionComplete} onOpenChange={onAiDialogOpenChange}
envelopeId={envelope.id} onComplete={onAiDetectionComplete}
teamId={envelope.teamId} envelopeId={envelope.id}
/> teamId={envelope.teamId}
/>
)}
<AiFeaturesEnableDialog <AiFeaturesEnableDialog
open={isAiEnableDialogOpen} open={isAiEnableDialogOpen}
@@ -0,0 +1,28 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
export const EnvelopeEditorRenderProviderWrapper = ({
children,
token,
presignedToken,
}: {
children: React.ReactNode;
token?: string;
presignedToken?: string;
}) => {
const { envelope } = useCurrentEnvelopeEditor();
return (
<EnvelopeRenderProvider
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={token}
presignToken={presignedToken}
version="current"
fields={envelope.fields}
recipients={envelope.recipients}
>
{children}
</EnvelopeRenderProvider>
);
};
@@ -28,6 +28,7 @@ import {
isValidLanguageCode, isValidLanguageCode,
} from '@documenso/lib/constants/i18n'; } from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { import {
ZDocumentAccessAuthTypesSchema, ZDocumentAccessAuthTypesSchema,
@@ -174,7 +175,9 @@ export const EnvelopeEditorSettingsDialog = ({
const { t, i18n } = useLingui(); const { t, i18n } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { envelope, updateEnvelopeAsync } = useCurrentEnvelopeEditor(); const { envelope, updateEnvelopeAsync, editorConfig, isEmbedded } = useCurrentEnvelopeEditor();
const { settings } = editorConfig;
const team = useCurrentTeam(); const team = useCurrentTeam();
const organisation = useCurrentOrganisation(); const organisation = useCurrentOrganisation();
@@ -223,10 +226,15 @@ export const EnvelopeEditorSettingsDialog = ({
const emailSettings = form.watch('meta.emailSettings'); const emailSettings = form.watch('meta.emailSettings');
const { data: emailData, isLoading: isLoadingEmails } = const { data: emailData, isLoading: isLoadingEmails } =
trpc.enterprise.organisation.email.find.useQuery({ trpc.enterprise.organisation.email.find.useQuery(
organisationId: organisation.id, {
perPage: 100, organisationId: organisation.id,
}); perPage: 100,
},
{
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const emails = emailData?.data || []; const emails = emailData?.data || [];
@@ -278,11 +286,13 @@ export const EnvelopeEditorSettingsDialog = ({
setOpen(false); setOpen(false);
toast({ if (!isEmbedded) {
title: t`Success`, toast({
description: t`Envelope updated`, title: t`Success`,
duration: 5000, description: t`Envelope updated`,
}); duration: 5000,
});
}
} catch (err) { } catch (err) {
const error = AppError.parseError(err); const error = AppError.parseError(err);
@@ -319,7 +329,7 @@ export const EnvelopeEditorSettingsDialog = ({
const selectedTab = tabs.find((tab) => tab.id === activeTab); const selectedTab = tabs.find((tab) => tab.id === activeTab);
if (!selectedTab) { if (!selectedTab || !settings) {
return null; return null;
} }
@@ -340,26 +350,32 @@ export const EnvelopeEditorSettingsDialog = ({
<DialogContent className="flex w-full !max-w-5xl flex-row gap-0 p-0"> <DialogContent className="flex w-full !max-w-5xl flex-row gap-0 p-0">
{/* Sidebar. */} {/* Sidebar. */}
<div className="flex w-80 flex-col border-r bg-accent/20"> <div className="flex w-80 flex-col border-r bg-accent/20">
<DialogHeader className="p-6 pb-4"> <DialogHeader className="p-6 pb-4" data-testid="envelope-editor-settings-dialog-header">
<DialogTitle> <DialogTitle>
<Trans>Document Settings</Trans> <Trans>Document Settings</Trans>
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<nav className="col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 px-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2"> <nav className="col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 px-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2">
{tabs.map((tab) => ( {tabs.map((tab) => {
<Button if (tab.id === 'email' && !settings.allowConfigureDistribution) {
key={tab.id} return null;
onClick={() => setActiveTab(tab.id)} }
variant="ghost"
className={cn('w-full justify-start', { return (
'bg-secondary': activeTab === tab.id, <Button
})} key={tab.id}
> onClick={() => setActiveTab(tab.id)}
<tab.icon className="mr-2 h-5 w-5" /> variant="ghost"
{t(tab.title)} className={cn('w-full justify-start', {
</Button> 'bg-secondary': activeTab === tab.id,
))} })}
>
<tab.icon className="mr-2 h-5 w-5" />
{t(tab.title)}
</Button>
);
})}
</nav> </nav>
</div> </div>
@@ -377,137 +393,151 @@ export const EnvelopeEditorSettingsDialog = ({
disabled={form.formState.isSubmitting} disabled={form.formState.isSubmitting}
key={activeTab} key={activeTab}
> >
{match(activeTab) {match({ activeTab, settings })
.with('general', () => ( .with({ activeTab: 'general' }, () => (
<> <>
<FormField {settings.allowConfigureLanguage && (
control={form.control} <FormField
name="meta.language" control={form.control}
render={({ field }) => ( name="meta.language"
<FormItem> render={({ field }) => (
<FormLabel className="inline-flex items-center"> <FormItem>
<Trans>Language</Trans> <FormLabel className="inline-flex items-center">
<Tooltip> <Trans>Language</Trans>
<TooltipTrigger> <Tooltip>
<InfoIcon className="mx-2 h-4 w-4" /> <TooltipTrigger>
</TooltipTrigger> <InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground"> <TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<Trans> <Trans>
Controls the language for the document, including the language Controls the language for the document, including the language
to be used for email notifications, and the final certificate to be used for email notifications, and the final certificate
that is generated and attached to the document. that is generated and attached to the document.
</Trans> </Trans>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Select <Select
value={field.value} value={field.value}
disabled={field.disabled} disabled={field.disabled}
onValueChange={field.onChange} onValueChange={field.onChange}
> >
<SelectTrigger className="bg-background"> <SelectTrigger className="bg-background">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => ( {Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}> <SelectItem key={code} value={code}>
{language.full} {language.full}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField )}
control={form.control}
name="meta.signatureTypes"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Allowed Signature Types</Trans>
<DocumentSignatureSettingsTooltip />
</FormLabel>
<FormControl> {settings.allowConfigureSignatureTypes && (
<MultiSelectCombobox <FormField
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({ control={form.control}
label: t(option.label), name="meta.signatureTypes"
value: option.value, render={({ field }) => (
}))} <FormItem>
selectedValues={field.value} <FormLabel className="flex flex-row items-center">
onChange={field.onChange} <Trans>Allowed Signature Types</Trans>
className="w-full bg-background" <DocumentSignatureSettingsTooltip />
emptySelectionPlaceholder="Select signature types" </FormLabel>
/>
</FormControl>
<FormMessage /> <FormControl>
</FormItem> <MultiSelectCombobox
)} options={Object.values(DOCUMENT_SIGNATURE_TYPES).map(
/> (option) => ({
<FormField label: t(option.label),
control={form.control} value: option.value,
name="meta.dateFormat" }),
render={({ field }) => ( )}
<FormItem> selectedValues={field.value}
<FormLabel> onChange={field.onChange}
<Trans>Date Format</Trans> className="w-full bg-background"
</FormLabel> emptySelectionPlaceholder="Select signature types"
/>
</FormControl>
<FormControl> <FormMessage />
<Select </FormItem>
value={field.value} )}
onValueChange={field.onChange} />
disabled={envelopeHasBeenSent} )}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent> {settings.allowConfigureDateFormat && (
{DATE_FORMATS.map((format) => ( <FormField
<SelectItem key={format.key} value={format.value}> control={form.control}
{format.label} name="meta.dateFormat"
</SelectItem> render={({ field }) => (
))} <FormItem>
</SelectContent> <FormLabel>
</Select> <Trans>Date Format</Trans>
</FormControl> </FormLabel>
<FormMessage /> <FormControl>
</FormItem> <Select
)} value={field.value}
/> onValueChange={field.onChange}
<FormField disabled={envelopeHasBeenSent}
control={form.control} >
name="meta.timezone" <SelectTrigger className="bg-background">
render={({ field }) => ( <SelectValue />
<FormItem> </SelectTrigger>
<FormLabel>
<Trans>Time Zone</Trans>
</FormLabel>
<FormControl> <SelectContent>
<Combobox {DATE_FORMATS.map((format) => (
className="bg-background" <SelectItem key={format.key} value={format.value}>
options={TIME_ZONES} {format.label}
value={field.value} </SelectItem>
onChange={(value) => value && field.onChange(value)} ))}
disabled={envelopeHasBeenSent} </SelectContent>
/> </Select>
</FormControl> </FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{settings.allowConfigureTimezone && (
<FormField
control={form.control}
name="meta.timezone"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Time Zone</Trans>
</FormLabel>
<FormControl>
<Combobox
className="bg-background"
options={TIME_ZONES}
value={field.value}
onChange={(value) => value && field.onChange(value)}
disabled={envelopeHasBeenSent}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="externalId" name="externalId"
@@ -538,143 +568,29 @@ export const EnvelopeEditorSettingsDialog = ({
)} )}
/> />
<FormField {settings.allowConfigureRedirectUrl && (
control={form.control}
name="meta.redirectUrl"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Redirect URL</Trans>{' '}
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
Add a URL to redirect the user to once the document is signed
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.distributionMethod"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Document Distribution Method</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Document Distribution Method</Trans>
</strong>
</h2>
<p>
<Trans>
This is how the document will reach the recipients once the
document is ready for signing.
</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<Trans>
<strong>Email</strong> - The recipient will be emailed the
document to sign, approve, etc.
</Trans>
</li>
<li>
<Trans>
<strong>None</strong> - We will generate links which you can
send to the recipients manually.
</Trans>
</li>
</ul>
<Trans>
<strong>Note</strong> - If you use Links in combination with
direct templates, you will need to manually send the links to
the remaining recipients.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue data-testid="documentDistributionMethodSelectValue" />
</SelectTrigger>
<SelectContent position="popper">
{Object.values(DOCUMENT_DISTRIBUTION_METHODS).map(
({ value, description }) => (
<SelectItem key={value} value={value}>
{i18n._(description)}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</>
))
.with('email', () => (
<>
{organisation.organisationClaim.flags.emailDomains && (
<FormField <FormField
control={form.control} control={form.control}
name="meta.emailId" name="meta.redirectUrl"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel className="flex flex-row items-center">
<Trans>Email Sender</Trans> <Trans>Redirect URL</Trans>{' '}
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
Add a URL to redirect the user to once the document is signed
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Select <Input className="bg-background" {...field} />
{...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> </FormControl>
<FormMessage /> <FormMessage />
@@ -683,82 +599,204 @@ export const EnvelopeEditorSettingsDialog = ({
/> />
)} )}
<FormField {settings.allowConfigureDistribution && (
control={form.control} <FormField
name="meta.emailReplyTo" control={form.control}
render={({ field }) => ( name="meta.distributionMethod"
<FormItem> render={({ field }) => (
<FormLabel> <FormItem>
<Trans> <FormLabel className="flex flex-row items-center">
Reply To Email{' '} <Trans>Document Distribution Method</Trans>
<span className="text-muted-foreground">(Optional)</span> <Tooltip>
</Trans> <TooltipTrigger>
</FormLabel> <InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<FormControl> <TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<Input {...field} /> <h2>
</FormControl> <strong>
<Trans>Document Distribution Method</Trans>
</strong>
</h2>
<FormMessage /> <p>
</FormItem> <Trans>
)} This is how the document will reach the recipients once the
/> document is ready for signing.
</Trans>
</p>
<FormField <ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
control={form.control} <li>
name="meta.subject" <Trans>
render={({ field }) => ( <strong>Email</strong> - The recipient will be emailed the
<FormItem> document to sign, approve, etc.
<FormLabel> </Trans>
<Trans> </li>
Subject <span className="text-muted-foreground">(Optional)</span> <li>
</Trans> <Trans>
</FormLabel> <strong>None</strong> - We will generate links which you
can send to the recipients manually.
</Trans>
</li>
</ul>
<FormControl> <Trans>
<Input {...field} /> <strong>Note</strong> - If you use Links in combination with
</FormControl> direct templates, you will need to manually send the links to
the remaining recipients.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormMessage /> <FormControl>
</FormItem> <Select {...field} onValueChange={field.onChange}>
)} <SelectTrigger className="bg-background text-muted-foreground">
/> <SelectValue data-testid="documentDistributionMethodSelectValue" />
</SelectTrigger>
<FormField <SelectContent position="popper">
control={form.control} {Object.values(DOCUMENT_DISTRIBUTION_METHODS).map(
name="meta.message" ({ value, description }) => (
render={({ field }) => ( <SelectItem key={value} value={value}>
<FormItem> {i18n._(description)}
<FormLabel className="flex flex-row items-center"> </SelectItem>
<Trans> ),
Message <span className="text-muted-foreground">(Optional)</span> )}
</Trans> </SelectContent>
<Tooltip> </Select>
<TooltipTrigger> </FormControl>
<InfoIcon className="mx-2 h-4 w-4" /> </FormItem>
</TooltipTrigger> )}
<TooltipContent className="p-4 text-muted-foreground"> />
<DocumentSendEmailMessageHelper /> )}
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Textarea className="h-16 resize-none bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DocumentEmailCheckboxes
value={emailSettings}
onChange={(value) => form.setValue('meta.emailSettings', value)}
/>
</> </>
)) ))
.with('security', () => ( .with(
{ activeTab: 'email', settings: { allowConfigureDistribution: true } },
() => (
<>
{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{' '}
<span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.subject"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Subject <span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.message"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>
Message <span className="text-muted-foreground">(Optional)</span>
</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="p-4 text-muted-foreground">
<DocumentSendEmailMessageHelper />
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Textarea className="h-16 resize-none bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DocumentEmailCheckboxes
value={emailSettings}
onChange={(value) => form.setValue('meta.emailSettings', value)}
/>
</>
),
)
.with({ activeTab: 'security' }, () => (
<> <>
{organisation.organisationClaim.flags.cfr21 && ( {organisation.organisationClaim.flags.cfr21 && (
<FormField <FormField
@@ -827,7 +865,7 @@ export const EnvelopeEditorSettingsDialog = ({
/> />
</> </>
)) ))
.exhaustive()} .otherwise(() => null)}
</fieldset> </fieldset>
<div className="flex flex-row justify-end gap-4 p-6"> <div className="flex flex-row justify-end gap-4 p-6">
@@ -9,6 +9,7 @@ export type EnvelopeItemTitleInputProps = {
className?: string; className?: string;
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
dataTestId?: string;
}; };
export const EnvelopeItemTitleInput = ({ export const EnvelopeItemTitleInput = ({
@@ -17,6 +18,7 @@ export const EnvelopeItemTitleInput = ({
className, className,
placeholder, placeholder,
disabled, disabled,
dataTestId,
}: EnvelopeItemTitleInputProps) => { }: EnvelopeItemTitleInputProps) => {
const [envelopeItemTitle, setEnvelopeItemTitle] = useState(value); const [envelopeItemTitle, setEnvelopeItemTitle] = useState(value);
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
@@ -63,6 +65,7 @@ export const EnvelopeItemTitleInput = ({
{envelopeItemTitle || placeholder} {envelopeItemTitle || placeholder}
</span> </span>
<input <input
data-testid={dataTestId}
data-1p-ignore data-1p-ignore
autoComplete="off" autoComplete="off"
ref={inputRef} ref={inputRef}
@@ -72,7 +75,7 @@ export const EnvelopeItemTitleInput = ({
disabled={disabled} disabled={disabled}
style={{ width: `${inputWidth}px` }} style={{ width: `${inputWidth}px` }}
className={cn( className={cn(
'text-foreground hover:outline-muted-foreground focus:outline-muted-foreground rounded-sm border-0 bg-transparent p-1 text-sm font-medium outline-none hover:outline hover:outline-1 focus:outline focus:outline-1', 'rounded-sm border-0 bg-transparent p-1 text-sm font-medium text-foreground outline-none hover:outline hover:outline-1 hover:outline-muted-foreground focus:outline focus:outline-1 focus:outline-muted-foreground',
className, className,
{ {
'outline-red-500': isError, 'outline-red-500': isError,
@@ -8,7 +8,6 @@ import { DocumentStatus } from '@prisma/client';
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react'; import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone'; import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
import { Link } from 'react-router';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { import {
@@ -17,7 +16,9 @@ import {
} from '@documenso/lib/client-only/providers/envelope-editor-provider'; } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope'; import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types'; import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
@@ -49,10 +50,14 @@ export const EnvelopeEditorUploadPage = () => {
const organisation = useCurrentOrganisation(); const organisation = useCurrentOrganisation();
const { t } = useLingui(); const { t } = useLingui();
const { envelope, setLocalEnvelope, relativePath, editorFields } = useCurrentEnvelopeEditor();
const { maximumEnvelopeItemCount, remaining } = useLimits(); const { maximumEnvelopeItemCount, remaining } = useLimits();
const { toast } = useToast(); const { toast } = useToast();
const { envelope, setLocalEnvelope, editorFields, editorConfig, isEmbedded, navigateToStep } =
useCurrentEnvelopeEditor();
const { envelopeItems: uploadConfig } = editorConfig;
const [localFiles, setLocalFiles] = useState<LocalFile[]>( const [localFiles, setLocalFiles] = useState<LocalFile[]>(
envelope.envelopeItems envelope.envelopeItems
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
@@ -103,17 +108,45 @@ export const EnvelopeEditorUploadPage = () => {
); );
const onFileDrop = async (files: File[]) => { const onFileDrop = async (files: File[]) => {
const newUploadingFiles: (LocalFile & { file: File })[] = files.map((file) => ({ const newUploadingFiles: (LocalFile & {
id: nanoid(), file: File;
envelopeItemId: null, data: TEditorEnvelope['envelopeItems'][number]['data'] | null;
title: file.name, })[] = await Promise.all(
file, files.map(async (file) => {
isUploading: true, return {
isError: false, id: nanoid(),
})); envelopeItemId: isEmbedded ? `${PRESIGNED_ENVELOPE_ITEM_ID_PREFIX}${nanoid()}` : null,
title: file.name,
file,
isUploading: isEmbedded ? false : true,
// Clone the buffer so it can be read multiple times (File.arrayBuffer() consumes the stream once)
data: isEmbedded ? new Uint8Array((await file.arrayBuffer()).slice(0)) : null,
isError: false,
};
}),
);
setLocalFiles((prev) => [...prev, ...newUploadingFiles]); setLocalFiles((prev) => [...prev, ...newUploadingFiles]);
// Directly commit the files for embedded documents since those are not uploaded
// until the end of the embedded flow.
if (isEmbedded) {
setLocalEnvelope({
envelopeItems: [
...envelope.envelopeItems,
...newUploadingFiles.map((file) => ({
id: file.envelopeItemId!,
title: file.title,
order: envelope.envelopeItems.length + 1,
envelopeId: envelope.id,
data: file.data!,
})),
],
});
return;
}
const payload = { const payload = {
envelopeId: envelope.id, envelopeId: envelope.id,
} satisfies TCreateEnvelopeItemsPayload; } satisfies TCreateEnvelopeItemsPayload;
@@ -163,7 +196,9 @@ export const EnvelopeEditorUploadPage = () => {
* Hide the envelope item from the list on deletion. * Hide the envelope item from the list on deletion.
*/ */
const onFileDelete = (envelopeItemId: string) => { const onFileDelete = (envelopeItemId: string) => {
setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.id !== envelopeItemId)); setLocalFiles((prev) =>
prev.filter((uploadingFile) => uploadingFile.envelopeItemId !== envelopeItemId),
);
const fieldsWithoutDeletedItem = envelope.fields.filter( const fieldsWithoutDeletedItem = envelope.fields.filter(
(field) => field.envelopeItemId !== envelopeItemId, (field) => field.envelopeItemId !== envelopeItemId,
@@ -195,6 +230,30 @@ export const EnvelopeEditorUploadPage = () => {
}; };
const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => { const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => {
if (isEmbedded) {
const nextEnvelopeItems = files
.filter((item) => item.envelopeItemId)
.map((item, index) => {
const originalEnvelopeItem = envelope.envelopeItems.find(
(envelopeItem) => envelopeItem.id === item.envelopeItemId,
);
return {
id: item.envelopeItemId || '',
title: item.title,
order: index + 1,
envelopeId: envelope.id,
data: originalEnvelopeItem?.data,
};
});
setLocalEnvelope({
envelopeItems: nextEnvelopeItems,
});
return;
}
void updateEnvelopeItems({ void updateEnvelopeItems({
envelopeId: envelope.id, envelopeId: envelope.id,
data: files data: files
@@ -277,32 +336,45 @@ export const EnvelopeEditorUploadPage = () => {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<DocumentDropzone {uploadConfig?.allowUpload && (
onDrop={onFileDrop} <DocumentDropzone
allowMultiple data-testid="envelope-item-dropzone"
className="pb-4 pt-6" onDrop={onFileDrop}
disabled={dropzoneDisabledMessage !== null} allowMultiple
disabledMessage={dropzoneDisabledMessage || undefined} className="pb-4 pt-6"
disabledHeading={msg`Upload disabled`} disabled={dropzoneDisabledMessage !== null}
maxFiles={maximumEnvelopeItemCount - localFiles.length} disabledMessage={dropzoneDisabledMessage || undefined}
onDropRejected={onFileDropRejected} disabledHeading={msg`Upload disabled`}
/> maxFiles={maximumEnvelopeItemCount - localFiles.length}
onDropRejected={onFileDropRejected}
/>
)}
{/* Uploaded Files List */} {/* Uploaded Files List */}
<div className="mt-4"> <div className="mt-4">
<DragDropContext onDragEnd={onDragEnd}> <DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="files"> <Droppable droppableId="files">
{(provided) => ( {(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="space-y-2"> <div
data-testid="envelope-items-list"
{...provided.droppableProps}
ref={provided.innerRef}
className="space-y-2"
>
{localFiles.map((localFile, index) => ( {localFiles.map((localFile, index) => (
<Draggable <Draggable
key={localFile.id} key={localFile.id}
isDragDisabled={isCreatingEnvelopeItems || !canItemsBeModified} isDragDisabled={
isCreatingEnvelopeItems ||
!canItemsBeModified ||
!uploadConfig?.allowConfigureOrder
}
draggableId={localFile.id} draggableId={localFile.id}
index={index} index={index}
> >
{(provided, snapshot) => ( {(provided, snapshot) => (
<div <div
data-testid={`envelope-item-row-${localFile.id}`}
ref={provided.innerRef} ref={provided.innerRef}
{...provided.draggableProps} {...provided.draggableProps}
style={provided.draggableProps.style} style={provided.draggableProps.style}
@@ -311,18 +383,25 @@ export const EnvelopeEditorUploadPage = () => {
}`} }`}
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div {uploadConfig?.allowConfigureOrder && (
{...provided.dragHandleProps} <div
className="cursor-grab active:cursor-grabbing" {...provided.dragHandleProps}
> data-testid={`envelope-item-drag-handle-${localFile.id}`}
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" /> className="cursor-grab active:cursor-grabbing"
</div> >
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
</div>
)}
<div> <div>
{localFile.envelopeItemId !== null ? ( {localFile.envelopeItemId !== null ? (
<EnvelopeItemTitleInput <EnvelopeItemTitleInput
disabled={envelope.status !== DocumentStatus.DRAFT} disabled={
envelope.status !== DocumentStatus.DRAFT ||
!uploadConfig?.allowConfigureTitle
}
value={localFile.title} value={localFile.title}
dataTestId={`envelope-item-title-input-${localFile.id}`}
placeholder={t`Document Title`} placeholder={t`Document Title`}
onChange={(title) => { onChange={(title) => {
onEnvelopeItemTitleChange(localFile.envelopeItemId!, title); onEnvelopeItemTitleChange(localFile.envelopeItemId!, title);
@@ -355,20 +434,36 @@ export const EnvelopeEditorUploadPage = () => {
</div> </div>
)} )}
{!localFile.isUploading && localFile.envelopeItemId && ( {!localFile.isUploading &&
<EnvelopeItemDeleteDialog localFile.envelopeItemId &&
canItemBeDeleted={canItemsBeModified} uploadConfig?.allowDelete &&
envelopeId={envelope.id} (isEmbedded ? (
envelopeItemId={localFile.envelopeItemId} <Button
envelopeItemTitle={localFile.title} variant="ghost"
onDelete={onFileDelete} size="sm"
trigger={ data-testid={`envelope-item-remove-button-${localFile.id}`}
<Button variant="ghost" size="sm"> onClick={() => onFileDelete(localFile.envelopeItemId!)}
<X className="h-4 w-4" /> >
</Button> <X className="h-4 w-4" />
} </Button>
/> ) : (
)} <EnvelopeItemDeleteDialog
canItemBeDeleted={canItemsBeModified}
envelopeId={envelope.id}
envelopeItemId={localFile.envelopeItemId}
envelopeItemTitle={localFile.title}
onDelete={onFileDelete}
trigger={
<Button
variant="ghost"
size="sm"
data-testid={`envelope-item-remove-button-${localFile.id}`}
>
<X className="h-4 w-4" />
</Button>
}
/>
))}
</div> </div>
</div> </div>
)} )}
@@ -385,14 +480,13 @@ export const EnvelopeEditorUploadPage = () => {
{/* Recipients Section */} {/* Recipients Section */}
<EnvelopeEditorRecipientForm /> <EnvelopeEditorRecipientForm />
{editorConfig.general.allowAddFieldsStep && (
<div className="flex justify-end"> <div className="flex justify-end">
<Button asChild> <Button type="button" onClick={() => void navigateToStep('addFields')}>
<Link to={`${relativePath.editorPath}?step=addFields`}>
<Trans>Add Fields</Trans> <Trans>Add Fields</Trans>
</Link> </Button>
</Button> </div>
</div> )}
</div> </div>
); );
}; };
@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { import {
ArrowLeftIcon, ArrowLeftIcon,
@@ -9,32 +11,31 @@ import {
DownloadCloudIcon, DownloadCloudIcon,
EyeIcon, EyeIcon,
LinkIcon, LinkIcon,
MousePointer, type LucideIcon,
MousePointerIcon,
SendIcon, SendIcon,
SettingsIcon, SettingsIcon,
Trash2Icon, Trash2Icon,
Upload, UploadIcon,
} from 'lucide-react'; } from 'lucide-react';
import { useNavigate, useSearchParams } from 'react-router'; import { useNavigate, useSearchParams } from 'react-router';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import type { EnvelopeEditorStep } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
mapSecondaryIdToDocumentId,
mapSecondaryIdToTemplateId,
} from '@documenso/lib/utils/envelope';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator'; import { Separator } from '@documenso/ui/primitives/separator';
import { SpinnerBox } from '@documenso/ui/primitives/spinner'; import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog'; import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribute-dialog'; import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribute-dialog';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog'; import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog'; import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog'; import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { TemplateDeleteDialog } from '~/components/dialogs/template-delete-dialog';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog'; import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog'; import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
@@ -43,92 +44,108 @@ import EnvelopeEditorHeader from './envelope-editor-header';
import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page'; import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page';
import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page'; import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page';
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview'; type EnvelopeEditorStepData = {
id: string;
title: MessageDescriptor;
icon: LucideIcon;
description: MessageDescriptor;
};
const envelopeEditorSteps = [ const UPLOAD_STEP = {
{ id: 'upload',
id: 'upload', title: msg`Document & Recipients`,
order: 1, icon: UploadIcon,
title: msg`Document & Recipients`, description: msg`Upload documents and add recipients`,
icon: Upload, };
description: msg`Upload documents and add recipients`,
},
{
id: 'addFields',
order: 2,
title: msg`Add Fields`,
icon: MousePointer,
description: msg`Place and configure form fields in the document`,
},
{
id: 'preview',
order: 3,
title: msg`Preview`,
icon: EyeIcon,
description: msg`Preview the document before sending`,
},
];
export default function EnvelopeEditor() { const ADD_FIELDS_STEP = {
id: 'addFields',
title: msg`Add Fields`,
icon: MousePointerIcon,
description: msg`Place and configure form fields in the document`,
};
const PREVIEW_STEP = {
id: 'preview',
title: msg`Preview`,
icon: EyeIcon,
description: msg`Preview the document before sending`,
};
export const EnvelopeEditor = () => {
const { t } = useLingui(); const { t } = useLingui();
const navigate = useNavigate(); const navigate = useNavigate();
const { const {
envelope, envelope,
editorConfig,
isDocument, isDocument,
isTemplate, isTemplate,
isAutosaving,
flushAutosave,
relativePath, relativePath,
syncEnvelope, syncEnvelope,
navigateToStep,
} = useCurrentEnvelopeEditor(); } = useCurrentEnvelopeEditor();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isStepLoading, setIsStepLoading] = useState(false);
const [currentStep, setCurrentStep] = useState<EnvelopeEditorStep>(() => { const {
const searchParamStep = searchParams.get('step') as EnvelopeEditorStep | undefined; general: {
minimizeLeftSidebar,
allowUploadAndRecipientStep,
allowAddFieldsStep,
allowPreviewStep,
},
actions: {
allowDistributing,
allowDirectLink,
allowDuplication,
allowDownloadPDF,
allowDeletion,
allowReturnToPreviousPage,
},
} = editorConfig;
// Empty URL param equals upload, otherwise use the step URL param const envelopeEditorSteps = useMemo(() => {
if (!searchParamStep) { const steps: EnvelopeEditorStepData[] = [];
return 'upload';
if (allowUploadAndRecipientStep) {
steps.push(UPLOAD_STEP);
} }
const validSteps: EnvelopeEditorStep[] = ['upload', 'addFields', 'preview']; if (allowAddFieldsStep) {
steps.push(ADD_FIELDS_STEP);
if (validSteps.includes(searchParamStep)) {
return searchParamStep;
} }
return 'upload'; if (allowPreviewStep) {
}); steps.push(PREVIEW_STEP);
const navigateToStep = (step: EnvelopeEditorStep) => {
setCurrentStep(step);
void flushAutosave();
if (!isStepLoading && isAutosaving) {
setIsStepLoading(true);
} }
// Update URL params: empty for upload, otherwise set the step return steps.map((step, index) => ({
if (step === 'upload') { ...step,
setSearchParams((prev) => { order: index + 1,
const newParams = new URLSearchParams(prev); }));
newParams.delete('step'); }, [editorConfig]);
return newParams;
}); const [currentStep, setCurrentStep] = useState<{ step: EnvelopeEditorStep; isLoading: boolean }>(
} else { () => {
setSearchParams((prev) => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const newParams = new URLSearchParams(prev); const searchParamStep = searchParams.get('step') as EnvelopeEditorStep | undefined;
newParams.set('step', step);
return newParams; // Empty URL param equals upload, otherwise use the step URL param
}); if (!searchParamStep) {
} return { step: 'upload', isLoading: false };
}; }
const validSteps: EnvelopeEditorStep[] = ['upload', 'addFields', 'preview'];
if (validSteps.includes(searchParamStep)) {
return { step: searchParamStep, isLoading: false };
}
return { step: 'upload', isLoading: false };
},
);
// Watch the URL params and setStep if the step changes. // Watch the URL params and setStep if the step changes.
useEffect(() => { useEffect(() => {
@@ -136,20 +153,19 @@ export default function EnvelopeEditor() {
const foundStep = envelopeEditorSteps.find((step) => step.id === stepParam); const foundStep = envelopeEditorSteps.find((step) => step.id === stepParam);
if (foundStep && foundStep.id !== currentStep) { if (foundStep && foundStep.id !== currentStep.step) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
navigateToStep(foundStep.id as EnvelopeEditorStep); void navigateToStep(foundStep.id as EnvelopeEditorStep).then(() => {
setCurrentStep({
step: foundStep.id as EnvelopeEditorStep,
isLoading: false,
});
});
} }
}, [searchParams]); }, [searchParams]);
useEffect(() => {
if (!isAutosaving) {
setIsStepLoading(false);
}
}, [isAutosaving]);
const currentStepData = const currentStepData =
envelopeEditorSteps.find((step) => step.id === currentStep) || envelopeEditorSteps[0]; envelopeEditorSteps.find((step) => step.id === currentStep.step) || envelopeEditorSteps[0];
return ( return (
<div className="h-screen w-screen bg-gray-50 dark:bg-background"> <div className="h-screen w-screen bg-gray-50 dark:bg-background">
@@ -158,57 +174,124 @@ export default function EnvelopeEditor() {
{/* Main Content Area */} {/* Main Content Area */}
<div className="flex h-[calc(100vh-4rem)] w-screen"> <div className="flex h-[calc(100vh-4rem)] w-screen">
{/* Left Section - Step Navigation */} {/* Left Section - Step Navigation */}
<div className="flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4"> <div
className={cn(
'flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4',
{
'w-14': minimizeLeftSidebar,
},
)}
>
{/* Left section step selector. */} {/* Left section step selector. */}
<div className="px-4"> {minimizeLeftSidebar ? (
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground"> <div className="flex justify-center px-4">
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>} <div className="relative flex h-10 w-10 items-center justify-center">
<svg className="size-10 -rotate-90" viewBox="0 0 40 40" aria-hidden>
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground"> {/* Track circle */}
<Trans context="The step counter"> <circle
Step {currentStepData.order}/{envelopeEditorSteps.length} cx="20"
</Trans> cy="20"
</span> r="16"
</h3> fill="none"
stroke="currentColor"
<div className="relative my-4 h-[4px] rounded-md bg-muted"> strokeWidth="3"
<motion.div className="text-muted"
layout="size" />
layoutId="document-flow-container-step" {/* Progress arc */}
className="absolute inset-y-0 left-0 bg-primary" <motion.circle
style={{ cx="20"
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`, cy="20"
}} r="16"
/> fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
className="text-primary"
strokeDasharray={2 * Math.PI * 16}
initial={false}
animate={{
strokeDashoffset:
2 *
Math.PI *
16 *
(1 - (currentStepData.order ?? 0) / envelopeEditorSteps.length),
}}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-foreground">
<Trans context="The step counter">
{currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span>
</div>
</div> </div>
) : (
<div className="px-4">
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
<div className="space-y-3"> <span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
{envelopeEditorSteps.map((step) => { <Trans context="The step counter">
const Icon = step.icon; Step {currentStepData.order}/{envelopeEditorSteps.length}
const isActive = currentStep === step.id; </Trans>
</span>
</h3>
return ( <div className="relative my-4 h-[4px] rounded-md bg-muted">
<div <motion.div
key={step.id} layout="size"
className={`cursor-pointer rounded-lg p-3 transition-colors ${ layoutId="document-flow-container-step"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
}}
/>
</div>
</div>
)}
<div
className={cn('space-y-3', {
'px-4': !minimizeLeftSidebar,
'mt-4 flex flex-col items-center': minimizeLeftSidebar,
})}
>
{envelopeEditorSteps.map((step) => {
const Icon = step.icon;
const isActive = currentStep.step === step.id;
return (
<button
key={step.id}
data-testid={`envelope-editor-step-${step.id}`}
type="button"
className={cn(
`cursor-pointer rounded-lg text-left transition-colors ${
isActive isActive
? 'border border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10' ? 'border border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
: 'border border-gray-200 hover:bg-gray-50 dark:border-gray-400/20 dark:hover:bg-gray-400/10' : 'border border-gray-200 hover:bg-gray-50 dark:border-gray-400/20 dark:hover:bg-gray-400/10'
}`} }`,
onClick={() => navigateToStep(step.id as EnvelopeEditorStep)} {
> 'p-3': !minimizeLeftSidebar,
<div className="flex items-center space-x-3"> },
<div )}
className={`rounded border p-2 ${ onClick={() => void navigateToStep(step.id as EnvelopeEditorStep)}
isActive >
? 'border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10' <div className="flex items-center space-x-3">
: 'border-gray-100 bg-gray-100 dark:border-gray-400/20 dark:bg-gray-400/10' <div
}`} className={`rounded border p-2 ${
> isActive
<Icon ? 'border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-gray-600'}`} : 'border-gray-100 bg-gray-100 dark:border-gray-400/20 dark:bg-gray-400/10'
/> }`}
</div> >
<Icon
className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-gray-600'}`}
/>
</div>
{!minimizeLeftSidebar && (
<div> <div>
<div <div
className={`text-sm font-medium ${ className={`text-sm font-medium ${
@@ -221,59 +304,101 @@ export default function EnvelopeEditor() {
</div> </div>
<div className="text-xs text-muted-foreground">{t(step.description)}</div> <div className="text-xs text-muted-foreground">{t(step.description)}</div>
</div> </div>
</div> )}
</div> </div>
); </button>
})} );
</div> })}
</div> </div>
<Separator className="my-6" /> <Separator
className={cn('my-6', {
'mx-auto mb-4 w-4/5': minimizeLeftSidebar,
})}
/>
{/* Quick Actions. */} {/* Quick Actions. */}
<div className="space-y-3 px-4"> <div
<h4 className="text-sm font-semibold text-foreground"> className={cn('space-y-3 px-4 [&_.lucide]:text-muted-foreground', {
<Trans>Quick Actions</Trans> 'px-2': minimizeLeftSidebar,
</h4> })}
<EnvelopeEditorSettingsDialog >
trigger={ {!minimizeLeftSidebar && (
<Button variant="ghost" size="sm" className="w-full justify-start"> <h4 className="text-sm font-semibold text-foreground">
<SettingsIcon className="mr-2 h-4 w-4" /> <Trans>Quick Actions</Trans>
{isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>} </h4>
</Button> )}
}
/>
{isDocument && ( {editorConfig.settings && (
<EnvelopeDistributeDialog <EnvelopeEditorSettingsDialog
documentRootPath={relativePath.documentRootPath}
trigger={ trigger={
<Button variant="ghost" size="sm" className="w-full justify-start"> <Button
<SendIcon className="mr-2 h-4 w-4" /> variant="ghost"
<Trans>Send Document</Trans> size="sm"
className="w-full justify-start"
title={t(msg`Settings`)}
>
<SettingsIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? (
<Trans>Document Settings</Trans>
) : (
<Trans>Template Settings</Trans>
)}
</span>
)}
</Button> </Button>
} }
/> />
)} )}
{isDocument && ( {isDocument && allowDistributing && (
<EnvelopeRedistributeDialog <>
envelope={envelope} <EnvelopeDistributeDialog
trigger={ documentRootPath={relativePath.documentRootPath}
<Button variant="ghost" size="sm" className="w-full justify-start"> trigger={
<SendIcon className="mr-2 h-4 w-4" /> <Button
<Trans>Resend Document</Trans> variant="ghost"
</Button> size="sm"
} className="w-full justify-start"
/> title={t(msg`Send Envelope`)}
>
<SendIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Send Document</Trans>
</span>
)}
</Button>
}
/>
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Resend Envelope`)}
>
<SendIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Resend Document</Trans>
</span>
)}
</Button>
}
/>
</>
)} )}
{/* <Button variant="ghost" size="sm" className="w-full justify-start"> {isTemplate && allowDirectLink && (
<FileText className="mr-2 h-4 w-4" />
Save as Template
</Button> */}
{isTemplate && (
<TemplateDirectLinkDialog <TemplateDirectLinkDialog
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)} templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
directLink={envelope.directLink} directLink={envelope.directLink}
@@ -281,100 +406,173 @@ export default function EnvelopeEditor() {
onCreateSuccess={async () => await syncEnvelope()} onCreateSuccess={async () => await syncEnvelope()}
onDeleteSuccess={async () => await syncEnvelope()} onDeleteSuccess={async () => await syncEnvelope()}
trigger={ trigger={
<Button variant="ghost" size="sm" className="w-full justify-start"> <Button
<LinkIcon className="mr-2 h-4 w-4" /> variant="ghost"
<Trans>Direct Link</Trans> size="sm"
className="w-full justify-start"
title={t(msg`Direct Link`)}
>
<LinkIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Direct Link</Trans>
</span>
)}
</Button> </Button>
} }
/> />
)} )}
<EnvelopeDuplicateDialog {allowDuplication && (
envelopeId={envelope.id} <EnvelopeDuplicateDialog
envelopeType={envelope.type} envelopeId={envelope.id}
trigger={ envelopeType={envelope.type}
<Button variant="ghost" size="sm" className="w-full justify-start"> trigger={
<CopyPlusIcon className="mr-2 h-4 w-4" /> <Button
{isDocument ? ( variant="ghost"
<Trans>Duplicate Document</Trans> size="sm"
) : ( className="w-full justify-start"
<Trans>Duplicate Template</Trans> title={t(msg`Duplicate Envelope`)}
)} >
</Button> <CopyPlusIcon className="h-4 w-4" />
}
/>
<EnvelopeDownloadDialog {!minimizeLeftSidebar && (
envelopeId={envelope.id} <span className="ml-2">
envelopeStatus={envelope.status} {isDocument ? (
envelopeItems={envelope.envelopeItems} <Trans>Duplicate Document</Trans>
trigger={ ) : (
<Button variant="ghost" size="sm" className="w-full justify-start"> <Trans>Duplicate Template</Trans>
<DownloadCloudIcon className="mr-2 h-4 w-4" /> )}
<Trans>Download PDF</Trans> </span>
</Button> )}
} </Button>
/> }
/>
)}
<Button {allowDownloadPDF && (
variant="ghost" <EnvelopeDownloadDialog
size="sm" envelopeId={envelope.id}
className="w-full justify-start" envelopeStatus={envelope.status}
onClick={() => setDeleteDialogOpen(true)} envelopeItems={envelope.envelopeItems}
> trigger={
<Trash2Icon className="mr-2 h-4 w-4" /> <Button
{isDocument ? <Trans>Delete Document</Trans> : <Trans>Delete Template</Trans>} variant="ghost"
</Button> size="sm"
className="w-full justify-start"
title={t(msg`Download PDF`)}
>
<DownloadCloudIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Download PDF</Trans>
</span>
)}
</Button>
}
/>
)}
{/* Check envelope ID since it can be in embedded create mode. */}
{allowDeletion && envelope.id && (
<EnvelopeDeleteDialog
id={envelope.id}
type={envelope.type}
status={envelope.status}
title={envelope.title}
canManageDocument={true}
trigger={
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Delete Envelope`)}
>
<Trash2Icon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? (
<Trans>Delete Document</Trans>
) : (
<Trans>Delete Template</Trans>
)}
</span>
)}
</Button>
}
onDelete={async () => {
// Todo: Embed - Where to navigate?
await navigate(
envelope.type === EnvelopeType.DOCUMENT
? relativePath.documentRootPath
: relativePath.templateRootPath,
);
}}
/>
)}
</div> </div>
{isDocument ? (
<DocumentDeleteDialog
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
status={envelope.status}
documentTitle={envelope.title}
canManageDocument={true}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onDelete={async () => {
await navigate(relativePath.documentRootPath);
}}
/>
) : (
<TemplateDeleteDialog
id={mapSecondaryIdToTemplateId(envelope.secondaryId)}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onDelete={async () => {
await navigate(relativePath.templateRootPath);
}}
/>
)}
{/* Footer of left sidebar. */} {/* Footer of left sidebar. */}
<div className="mt-auto px-4"> {allowReturnToPreviousPage && (
<Button variant="ghost" className="w-full justify-start" asChild> <div
<Link to={relativePath.basePath}> className={cn('mt-auto px-4', {
<ArrowLeftIcon className="mr-2 h-4 w-4" /> 'px-2': minimizeLeftSidebar,
{isDocument ? ( })}
<Trans>Return to documents</Trans> >
) : ( <Button
<Trans>Return to templates</Trans> variant="ghost"
)} className={cn('w-full justify-start', {
</Link> 'flex items-center justify-center': minimizeLeftSidebar,
</Button> })}
</div> asChild
>
<Link to={relativePath.basePath}>
<ArrowLeftIcon className="h-4 w-4 flex-shrink-0" />
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? (
<Trans>Return to documents</Trans>
) : (
<Trans>Return to templates</Trans>
)}
</span>
)}
</Link>
</Button>
</div>
)}
</div> </div>
{/* Main Content - Changes based on current step */} {/* Main Content - Changes based on current step */}
<AnimateGenericFadeInOut className="flex-1 overflow-y-auto" key={currentStep}> <AnimateGenericFadeInOut
{match({ currentStep, isStepLoading }) className="flex-1 overflow-y-auto"
key={currentStep.isLoading ? `loading-${currentStep.step}` : currentStep.step}
>
{match({
isStepLoading: currentStep.isLoading,
currentStep: currentStep.step,
allowUploadAndRecipientStep,
allowAddFieldsStep,
allowPreviewStep,
})
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />) .with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
.with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />) .with({ currentStep: 'upload', allowUploadAndRecipientStep: true }, () => (
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />) <EnvelopeEditorUploadPage />
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />) ))
.exhaustive()} .with({ currentStep: 'addFields', allowAddFieldsStep: true }, () => (
<EnvelopeEditorFieldsPage />
))
.with({ currentStep: 'preview', allowPreviewStep: true }, () => (
<EnvelopeEditorPreviewPage />
))
.otherwise(() => null)}
</AnimateGenericFadeInOut> </AnimateGenericFadeInOut>
</div> </div>
</div> </div>
); );
} };
@@ -5,17 +5,22 @@ import { DocumentStatus, type Recipient, SigningStatus } from '@prisma/client';
import type Konva from 'konva'; import type Konva from 'konva';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer'; import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; import {
type PageRenderData,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { TEnvelope } from '@documenso/lib/types/envelope'; import type { TEnvelope } from '@documenso/lib/types/envelope';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field'; import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields'; import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip'; import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
import { EnvelopePageImage } from '../envelope/envelope-page-image';
type GenericLocalField = TEnvelope['fields'][number] & { type GenericLocalField = TEnvelope['fields'][number] & {
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>; recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
}; };
export default function EnvelopeGenericPageRenderer() { export default function EnvelopeGenericPageRenderer({ pageData }: { pageData: PageRenderData }) {
const { i18n } = useLingui(); const { i18n } = useLingui();
const { const {
@@ -28,19 +33,12 @@ export default function EnvelopeGenericPageRenderer() {
overrideSettings, overrideSettings,
} = useCurrentEnvelopeRender(); } = useCurrentEnvelopeRender();
const { const { stage, pageLayer, imageProps, konvaContainer, unscaledViewport, renderStatus } =
stage, usePageRenderer(({ stage, pageLayer }) => {
pageLayer, createPageCanvas(stage, pageLayer);
canvasElement, }, pageData);
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => {
createPageCanvas(stage, pageLayer);
});
const { _className, scale } = pageContext; const { scale, pageNumber } = pageData;
const localPageFields = useMemo((): GenericLocalField[] => { const localPageFields = useMemo((): GenericLocalField[] => {
if (envelopeStatus === DocumentStatus.COMPLETED) { if (envelopeStatus === DocumentStatus.COMPLETED) {
@@ -49,8 +47,7 @@ export default function EnvelopeGenericPageRenderer() {
return fields return fields
.filter( .filter(
(field) => (field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
) )
.map((field) => { .map((field) => {
const recipient = recipients.find((recipient) => recipient.id === field.recipientId); const recipient = recipients.find((recipient) => recipient.id === field.recipientId);
@@ -73,7 +70,7 @@ export default function EnvelopeGenericPageRenderer() {
(recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) || (recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) ||
fieldMeta?.readOnly, fieldMeta?.readOnly,
); );
}, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]); }, [fields, pageNumber, currentEnvelopeItem?.id, recipients]);
const unsafeRenderFieldOnLayer = (field: GenericLocalField) => { const unsafeRenderFieldOnLayer = (field: GenericLocalField) => {
if (!pageLayer.current) { if (!pageLayer.current) {
@@ -160,10 +157,7 @@ export default function EnvelopeGenericPageRenderer() {
} }
return ( return (
<div <div className="relative w-full" key={`${currentEnvelopeItem.id}-renderer-${pageNumber}`}>
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{overrideSettings?.showRecipientTooltip && {overrideSettings?.showRecipientTooltip &&
localPageFields.map((field) => ( localPageFields.map((field) => (
<EnvelopeRecipientFieldTooltip <EnvelopeRecipientFieldTooltip
@@ -177,13 +171,7 @@ export default function EnvelopeGenericPageRenderer() {
{/* The element Konva will inject it's canvas into. */} {/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div> <div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */} <EnvelopePageImage renderStatus={renderStatus} imageProps={imageProps} />
<canvas
className={`${_className}__canvas z-0`}
ref={canvasElement}
height={scaledViewport.height}
width={scaledViewport.width}
/>
</div> </div>
); );
} }
@@ -14,7 +14,10 @@ import type { KonvaEventObject } from 'konva/lib/Node';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer'; import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; import {
type PageRenderData,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates'; import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
import { isBase64Image } from '@documenso/lib/constants/signatures'; import { isBase64Image } from '@documenso/lib/constants/signatures';
@@ -44,12 +47,13 @@ import { handleTextFieldClick } from '~/utils/field-signing/text-field';
import { useRequiredDocumentSigningAuthContext } from '../document-signing/document-signing-auth-provider'; import { useRequiredDocumentSigningAuthContext } from '../document-signing/document-signing-auth-provider';
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider'; import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
import { EnvelopePageImage } from '../envelope/envelope-page-image';
type GenericLocalField = TEnvelope['fields'][number] & { type GenericLocalField = TEnvelope['fields'][number] & {
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>; recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
}; };
export default function EnvelopeSignerPageRenderer() { export default function EnvelopeSignerPageRenderer({ pageData }: { pageData: PageRenderData }) {
const { t, i18n } = useLingui(); const { t, i18n } = useLingui();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender(); const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
const { sessionData } = useOptionalSession(); const { sessionData } = useOptionalSession();
@@ -77,17 +81,10 @@ export default function EnvelopeSignerPageRenderer() {
const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {}; const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {};
const { const { stage, pageLayer, imageProps, konvaContainer, unscaledViewport, renderStatus } =
stage, usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer), pageData);
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
const { _className, scale } = pageContext; const { scale, pageNumber } = pageData;
const { envelope } = envelopeData; const { envelope } = envelopeData;
@@ -99,10 +96,9 @@ export default function EnvelopeSignerPageRenderer() {
} }
return fieldsToRender.filter( return fieldsToRender.filter(
(field) => (field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
); );
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]); }, [recipientFields, selectedAssistantRecipientFields, pageNumber]);
/** /**
* Returns fields that have been fully signed by other recipients for this specific * Returns fields that have been fully signed by other recipients for this specific
@@ -117,7 +113,7 @@ export default function EnvelopeSignerPageRenderer() {
return recipient.fields return recipient.fields
.filter( .filter(
(field) => (field) =>
field.page === pageContext.pageNumber && field.page === pageNumber &&
field.envelopeItemId === currentEnvelopeItem?.id && field.envelopeItemId === currentEnvelopeItem?.id &&
(field.inserted || field.fieldMeta?.readOnly), (field.inserted || field.fieldMeta?.readOnly),
) )
@@ -132,7 +128,7 @@ export default function EnvelopeSignerPageRenderer() {
}, },
})); }));
}); });
}, [envelope.recipients, pageContext.pageNumber]); }, [envelope.recipients, pageNumber]);
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => { const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
if (!pageLayer.current) { if (!pageLayer.current) {
@@ -534,14 +530,11 @@ export default function EnvelopeSignerPageRenderer() {
} }
return ( return (
<div <div className="relative w-full" key={`${currentEnvelopeItem.id}-renderer-${pageNumber}`}>
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{showPendingFieldTooltip && {showPendingFieldTooltip &&
recipientFieldsRemaining.length > 0 && recipientFieldsRemaining.length > 0 &&
recipientFieldsRemaining[0]?.envelopeItemId === currentEnvelopeItem?.id && recipientFieldsRemaining[0]?.envelopeItemId === currentEnvelopeItem?.id &&
recipientFieldsRemaining[0]?.page === pageContext.pageNumber && ( recipientFieldsRemaining[0]?.page === pageNumber && (
<EnvelopeFieldToolTip <EnvelopeFieldToolTip
key={recipientFieldsRemaining[0].id} key={recipientFieldsRemaining[0].id}
field={recipientFieldsRemaining[0]} field={recipientFieldsRemaining[0]}
@@ -563,13 +556,7 @@ export default function EnvelopeSignerPageRenderer() {
{/* The element Konva will inject it's canvas into. */} {/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div> <div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */} <EnvelopePageImage renderStatus={renderStatus} imageProps={imageProps} />
<canvas
className={`${_className}__canvas z-0`}
ref={canvasElement}
height={scaledViewport.height}
width={scaledViewport.width}
/>
</div> </div>
); );
} }
@@ -0,0 +1,32 @@
import { Trans } from '@lingui/react/macro';
import { Spinner } from '@documenso/ui/primitives/spinner';
type EnvelopePageImageProps = {
renderStatus: 'loading' | 'loaded' | 'error';
imageProps: React.ImgHTMLAttributes<HTMLImageElement> & Record<string, unknown> & { alt: '' };
};
export const EnvelopePageImage = ({ renderStatus, imageProps }: EnvelopePageImageProps) => {
return (
<>
{/* Loading State */}
{renderStatus === 'loading' && (
<div className="absolute inset-0 z-10 flex items-center justify-center">
<Spinner />
</div>
)}
{renderStatus === 'error' && (
<div className="absolute inset-0 z-10 flex items-center justify-center">
<p>
<Trans>Error loading page</Trans>
</p>
</div>
)}
{/* The PDF image. */}
<img {...imageProps} alt="" />
</>
);
};
@@ -14,11 +14,11 @@ import {
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth'; import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
import type { TTemplate } from '@documenso/lib/types/template'; import type { TTemplate } from '@documenso/lib/types/template';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { Stepper } from '@documenso/ui/primitives/stepper'; import { Stepper } from '@documenso/ui/primitives/stepper';
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields'; import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types'; import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
@@ -312,11 +312,12 @@ export const TemplateEditForm = ({
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<PDFViewerLazy <PDFViewer
key={template.envelopeItems[0].id} key={template.envelopeItems[0].id}
envelopeItem={template.envelopeItems[0]} envelopeItem={template.envelopeItems[0]}
token={undefined} token={undefined}
version="signed" version="current"
scrollParentRef="window"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)} onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/> />
</CardContent> </CardContent>
@@ -9,6 +9,8 @@ import { match } from 'ts-pattern';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@@ -16,12 +18,12 @@ import {
DocumentReadOnlyFields, DocumentReadOnlyFields,
mapFieldsWithRecipients, mapFieldsWithRecipients,
} from '@documenso/ui/components/document/document-read-only-fields'; } from '@documenso/ui/components/document/document-read-only-fields';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge'; import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { Spinner } from '@documenso/ui/primitives/spinner'; import { Spinner } from '@documenso/ui/primitives/spinner';
import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button'; import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
@@ -55,9 +57,14 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
data: envelope, data: envelope,
isLoading: isLoadingEnvelope, isLoading: isLoadingEnvelope,
isError: isErrorEnvelope, isError: isErrorEnvelope,
} = trpc.envelope.get.useQuery({ } = trpc.envelope.get.useQuery(
envelopeId: params.id, {
}); envelopeId: params.id,
},
{
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
if (isLoadingEnvelope) { if (isLoadingEnvelope) {
return ( return (
@@ -154,7 +161,9 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
{envelope.internalVersion === 2 ? ( {envelope.internalVersion === 2 ? (
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7"> <div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
<EnvelopeRenderProvider <EnvelopeRenderProvider
version="current"
envelope={envelope} envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={undefined} token={undefined}
fields={envelope.fields} fields={envelope.fields}
recipients={envelope.recipients} recipients={envelope.recipients}
@@ -169,9 +178,10 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
<Card className="rounded-xl before:rounded-xl" gradient> <Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2"> <CardContent className="p-2">
<PDFViewerKonvaLazy <EnvelopePdfViewer
renderer="preview"
customPageRenderer={EnvelopeGenericPageRenderer} customPageRenderer={EnvelopeGenericPageRenderer}
scrollParentRef="window"
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -193,11 +203,12 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
/> />
)} )}
<PDFViewerLazy <PDFViewer
envelopeItem={envelope.envelopeItems[0]} envelopeItem={envelope.envelopeItems[0]}
token={undefined} token={undefined}
key={envelope.envelopeItems[0].id} key={envelope.envelopeItems[0].id}
version="signed" version="current"
scrollParentRef="window"
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -6,13 +6,14 @@ import { EnvelopeType } from '@prisma/client';
import { Link, useNavigate } from 'react-router'; import { Link, useNavigate } from 'react-router';
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Spinner } from '@documenso/ui/primitives/spinner'; import { Spinner } from '@documenso/ui/primitives/spinner';
import EnvelopeEditor from '~/components/general/envelope-editor/envelope-editor'; import { EnvelopeEditor } from '~/components/general/envelope-editor/envelope-editor';
import { EnvelopeEditorRenderProviderWrapper } from '~/components/general/envelope-editor/envelope-editor-renderer-provider-wrapper';
import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
@@ -32,6 +33,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
}, },
{ {
retry: false, retry: false,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
}, },
); );
@@ -58,7 +60,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
if (envelope && (envelope.teamId !== team.id || envelope.internalVersion !== 2)) { if (envelope && (envelope.teamId !== team.id || envelope.internalVersion !== 2)) {
return ( return (
<div className="text-foreground flex h-screen w-screen flex-col items-center justify-center gap-2"> <div className="flex h-screen w-screen flex-col items-center justify-center gap-2 text-foreground">
<Spinner /> <Spinner />
<Trans>Redirecting</Trans> <Trans>Redirecting</Trans>
</div> </div>
@@ -67,7 +69,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
if (isLoadingEnvelope) { if (isLoadingEnvelope) {
return ( return (
<div className="text-foreground flex h-screen w-screen flex-col items-center justify-center gap-2"> <div className="flex h-screen w-screen flex-col items-center justify-center gap-2 text-foreground">
<Spinner /> <Spinner />
<Trans>Loading</Trans> <Trans>Loading</Trans>
</div> </div>
@@ -98,14 +100,9 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
return ( return (
<EnvelopeEditorProvider initialEnvelope={envelope}> <EnvelopeEditorProvider initialEnvelope={envelope}>
<EnvelopeRenderProvider <EnvelopeEditorRenderProviderWrapper>
envelope={envelope}
token={undefined}
fields={envelope.fields}
recipients={envelope.recipients}
>
<EnvelopeEditor /> <EnvelopeEditor />
</EnvelopeRenderProvider> </EnvelopeEditorRenderProviderWrapper>
</EnvelopeEditorProvider> </EnvelopeEditorProvider>
); );
} }
@@ -8,15 +8,17 @@ import { Link, useNavigate } from 'react-router';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields'; import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { Spinner } from '@documenso/ui/primitives/spinner'; import { Spinner } from '@documenso/ui/primitives/spinner';
import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog'; import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog';
@@ -50,9 +52,14 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
data: envelope, data: envelope,
isLoading: isLoadingEnvelope, isLoading: isLoadingEnvelope,
isError: isErrorEnvelope, isError: isErrorEnvelope,
} = trpc.envelope.get.useQuery({ } = trpc.envelope.get.useQuery(
envelopeId: params.id, {
}); envelopeId: params.id,
},
{
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
if (isLoadingEnvelope) { if (isLoadingEnvelope) {
return ( return (
@@ -173,7 +180,9 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
{envelope.internalVersion === 2 ? ( {envelope.internalVersion === 2 ? (
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7"> <div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
<EnvelopeRenderProvider <EnvelopeRenderProvider
version="current"
envelope={envelope} envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={undefined} token={undefined}
fields={envelope.fields} fields={envelope.fields}
recipients={envelope.recipients} recipients={envelope.recipients}
@@ -187,9 +196,10 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
<Card className="rounded-xl before:rounded-xl" gradient> <Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2"> <CardContent className="p-2">
<PDFViewerKonvaLazy <EnvelopePdfViewer
renderer="preview"
customPageRenderer={EnvelopeGenericPageRenderer} customPageRenderer={EnvelopeGenericPageRenderer}
scrollParentRef="window"
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -210,11 +220,12 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
documentMeta={mockedDocumentMeta} documentMeta={mockedDocumentMeta}
/> />
<PDFViewerLazy <PDFViewer
envelopeItem={envelope.envelopeItems[0]} envelopeItem={envelope.envelopeItems[0]}
token={undefined} token={undefined}
version="signed" version="current"
key={envelope.envelopeItems[0].id} key={envelope.envelopeItems[0].id}
scrollParentRef="window"
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -198,7 +198,7 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
{template.title} {template.title}
</h1> </h1>
<div className="text-muted-foreground mb-8 mt-2.5 flex items-center gap-x-2"> <div className="mb-8 mt-2.5 flex items-center gap-x-2 text-muted-foreground">
<UsersIcon className="h-4 w-4" /> <UsersIcon className="h-4 w-4" />
<p className="text-muted-foreground/80"> <p className="text-muted-foreground/80">
<Plural value={template.recipients.length} one="# recipient" other="# recipients" /> <Plural value={template.recipients.length} one="# recipient" other="# recipients" />
@@ -246,7 +246,12 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
recipient={recipient} recipient={recipient}
user={user} user={user}
> >
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}> <EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={recipient.token}
>
<DocumentSigningPageViewV2 /> <DocumentSigningPageViewV2 />
</EnvelopeRenderProvider> </EnvelopeRenderProvider>
</DocumentSigningAuthProvider> </DocumentSigningAuthProvider>
@@ -494,7 +494,12 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
recipient={recipient} recipient={recipient}
user={user} user={user}
> >
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}> <EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={recipient.token}
>
<DocumentSigningPageViewV2 /> <DocumentSigningPageViewV2 />
</EnvelopeRenderProvider> </EnvelopeRenderProvider>
</DocumentSigningAuthProvider> </DocumentSigningAuthProvider>
@@ -320,7 +320,12 @@ const EmbedDirectTemplatePageV2 = ({
user={user} user={user}
isDirectTemplate={true} isDirectTemplate={true}
> >
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}> <EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={recipient.token}
>
<EmbedSignDocumentV2ClientPage <EmbedSignDocumentV2ClientPage
hidePoweredBy={hidePoweredBy} hidePoweredBy={hidePoweredBy}
allowWhitelabelling={allowEmbedSigningWhitelabel} allowWhitelabelling={allowEmbedSigningWhitelabel}
@@ -382,7 +382,12 @@ const EmbedSignDocumentPageV2 = ({
recipient={recipient} recipient={recipient}
user={user} user={user}
> >
<EnvelopeRenderProvider envelope={envelope} token={token}> <EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={token}
>
<EmbedSignDocumentV2ClientPage <EmbedSignDocumentV2ClientPage
hidePoweredBy={hidePoweredBy} hidePoweredBy={hidePoweredBy}
allowWhitelabelling={allowEmbedSigningWhitelabel} allowWhitelabelling={allowEmbedSigningWhitelabel}
+647
View File
@@ -0,0 +1,647 @@
/**
* This is an internal test page for the embedding system.
*
* We use this to test embeds for E2E testing.
*
* No translations required.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
export const loader = () => {
if (process.env.NODE_ENV !== 'development') {
throw new Error('This page is only available in development mode.');
}
};
/**
* Dummy embed test page.
*
* Simulates an embedding parent that renders the V2 authoring iframe
* with configurable features, externalId, and mode.
*
* Navigate to /embed/dummy to use.
*/
export default function EmbedDummyPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [token, setToken] = useState(() => searchParams.get('token') || '');
const [externalId, setExternalId] = useState(() => searchParams.get('externalId') || '');
const [mode, setMode] = useState<'create' | 'edit'>(
() => (searchParams.get('mode') as 'create' | 'edit') || 'create',
);
const [envelopeId, setEnvelopeId] = useState(() => searchParams.get('envelopeId') || '');
const [envelopeType, setEnvelopeType] = useState<'DOCUMENT' | 'TEMPLATE'>(
() => (searchParams.get('envelopeType') as 'DOCUMENT' | 'TEMPLATE') || 'DOCUMENT',
);
// Auto-launch if query params are present on mount
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
const [iframeKey, setIframeKey] = useState(0);
const [messages, setMessages] = useState<string[]>([]);
// Feature flags state -- grouped by section
const [generalFeatures, setGeneralFeatures] = useState({
allowConfigureEnvelopeTitle: true,
allowUploadAndRecipientStep: true,
allowAddFieldsStep: true,
allowPreviewStep: true,
minimizeLeftSidebar: true,
});
const [settingsFeatures, setSettingsFeatures] = useState({
allowConfigureSignatureTypes: true,
allowConfigureLanguage: true,
allowConfigureDateFormat: true,
allowConfigureTimezone: true,
allowConfigureRedirectUrl: true,
allowConfigureDistribution: true,
});
const [actionsFeatures, setActionsFeatures] = useState({
allowAttachments: true,
allowDistributing: false,
allowDirectLink: false,
allowDuplication: false,
allowDownloadPDF: false,
allowDeletion: false,
allowReturnToPreviousPage: false,
});
const [envelopeItemsFeatures, setEnvelopeItemsFeatures] = useState({
allowConfigureTitle: true,
allowConfigureOrder: true,
allowUpload: true,
allowDelete: true,
});
const [recipientsFeatures, setRecipientsFeatures] = useState({
allowAIDetection: true,
allowConfigureSigningOrder: true,
allowConfigureDictateNextSigner: true,
allowApproverRole: true,
allowViewerRole: true,
allowCCerRole: true,
allowAssistantRole: true,
});
const [fieldsFeatures, setFieldsFeatures] = useState({
allowAIDetection: true,
});
// CSS theming state
const [darkModeDisabled, setDarkModeDisabled] = useState(false);
const [rawCss, setRawCss] = useState('');
const [cssVars, setCssVars] = useState<Record<string, string>>({
background: '',
foreground: '',
muted: '',
mutedForeground: '',
popover: '',
popoverForeground: '',
card: '',
cardBorder: '',
cardBorderTint: '',
cardForeground: '',
fieldCard: '',
fieldCardBorder: '',
fieldCardForeground: '',
widget: '',
widgetForeground: '',
border: '',
input: '',
primary: '',
primaryForeground: '',
secondary: '',
secondaryForeground: '',
accent: '',
accentForeground: '',
destructive: '',
destructiveForeground: '',
ring: '',
radius: '',
warning: '',
});
const [isResolvingToken, setIsResolvingToken] = useState(false);
const [tokenError, setTokenError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const hasAutoLaunched = useRef(false);
/**
* If the token starts with "api_", exchange it for a presign token
* via the embedding presign endpoint. Otherwise return as-is.
*/
const resolveToken = async (inputToken: string): Promise<string> => {
if (!inputToken.startsWith('api_')) {
return inputToken;
}
const response = await fetch('/api/v2/embedding/create-presign-token', {
method: 'POST',
headers: {
Authorization: `Bearer ${inputToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to exchange API token (${response.status}): ${text}`);
}
const data = await response.json();
const presignToken = data?.token;
if (!presignToken || typeof presignToken !== 'string') {
throw new Error(`Unexpected response shape: ${JSON.stringify(data)}`);
}
return presignToken;
};
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const timestamp = new Date().toISOString().slice(11, 19);
setMessages((prev) => [...prev, `[${timestamp}] ${JSON.stringify(event.data, null, 2)}`]);
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, []);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Auto-launch on mount if token is present in query params
useEffect(() => {
if (hasAutoLaunched.current) {
return;
}
const initialToken = searchParams.get('token');
if (initialToken) {
hasAutoLaunched.current = true;
void launchEmbed(initialToken);
}
}, []);
const updateQueryParams = (params: {
token: string;
externalId: string;
mode: string;
envelopeId: string;
envelopeType: string;
}) => {
const newParams = new URLSearchParams();
if (params.token) {
newParams.set('token', params.token);
}
if (params.externalId) {
newParams.set('externalId', params.externalId);
}
if (params.mode && params.mode !== 'create') {
newParams.set('mode', params.mode);
}
if (params.envelopeId) {
newParams.set('envelopeId', params.envelopeId);
}
if (params.envelopeType && params.envelopeType !== 'DOCUMENT') {
newParams.set('envelopeType', params.envelopeType);
}
const qs = newParams.toString();
void navigate(qs ? `?${qs}` : '.', { replace: true });
};
const launchEmbed = async (overrideToken?: string) => {
const inputToken = overrideToken ?? token;
if (!inputToken) {
return;
}
setTokenError(null);
setIsResolvingToken(true);
let presignToken: string;
try {
presignToken = await resolveToken(inputToken);
} catch (err) {
setTokenError(err instanceof Error ? err.message : String(err));
setIsResolvingToken(false);
return;
}
setIsResolvingToken(false);
// Filter out empty cssVars entries
const filteredCssVars: Record<string, string> = {};
for (const [key, value] of Object.entries(cssVars)) {
if (value) {
filteredCssVars[key] = value;
}
}
const hashData = {
externalId: externalId || undefined,
type: mode === 'create' ? envelopeType : undefined,
darkModeDisabled: darkModeDisabled || undefined,
css: rawCss || undefined,
cssVars: Object.keys(filteredCssVars).length > 0 ? filteredCssVars : undefined,
features: {
general: generalFeatures,
settings: settingsFeatures,
actions: actionsFeatures,
envelopeItems: envelopeItemsFeatures,
recipients: recipientsFeatures,
fields: fieldsFeatures,
},
};
const hash = btoa(encodeURIComponent(JSON.stringify(hashData)));
const basePath =
mode === 'create'
? '/embed/v2/authoring/envelope/create'
: `/embed/v2/authoring/envelope/edit/${envelopeId}`;
setIframeSrc(`${basePath}?token=${presignToken}#${hash}`);
setIframeKey((prev) => prev + 1);
updateQueryParams({ token: inputToken, externalId, mode, envelopeId, envelopeType });
};
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
void launchEmbed();
},
[
token,
externalId,
mode,
envelopeId,
envelopeType,
generalFeatures,
settingsFeatures,
actionsFeatures,
envelopeItemsFeatures,
recipientsFeatures,
fieldsFeatures,
darkModeDisabled,
rawCss,
cssVars,
],
);
const handleClear = () => {
setToken('');
setExternalId('');
setMode('create');
setEnvelopeId('');
setEnvelopeType('DOCUMENT');
setIframeSrc(null);
setMessages([]);
setTokenError(null);
setDarkModeDisabled(false);
setRawCss('');
setCssVars((prev) => {
const cleared: Record<string, string> = {};
for (const key of Object.keys(prev)) {
cleared[key] = '';
}
return cleared;
});
void navigate('.', { replace: true });
};
const renderCheckboxGroup = <T extends Record<string, boolean>>(
label: string,
state: T,
setState: React.Dispatch<React.SetStateAction<T>>,
) => (
<fieldset
style={{ border: '1px solid #ccc', padding: '8px', marginBottom: '8px', borderRadius: '4px' }}
>
<legend style={{ fontWeight: 'bold', fontSize: '13px' }}>{label}</legend>
{Object.entries(state).map(([key, value]) => (
<label key={key} style={{ display: 'block', fontSize: '12px', marginBottom: '2px' }}>
<input
type="checkbox"
checked={value}
onChange={(e) => setState((prev) => ({ ...prev, [key]: e.target.checked }))}
style={{ marginRight: '4px' }}
/>
{key}
</label>
))}
</fieldset>
);
return (
<div style={{ display: 'flex', height: '100vh', fontFamily: 'monospace' }}>
{/* Left panel: controls */}
<div
style={{
width: '320px',
padding: '12px',
borderRight: '1px solid #ccc',
overflowY: 'auto',
flexShrink: 0,
}}
>
<h2 style={{ margin: '0 0 12px', fontSize: '16px' }}>Embed Test</h2>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '8px' }}>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>
API or Embedded Token (Required)
</label>
<input
type="text"
value={token}
onChange={(e) => setToken(e.target.value)}
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
placeholder="api_... or presign token"
required
/>
{tokenError && (
<div style={{ color: 'red', fontSize: '11px', marginTop: '4px' }}>{tokenError}</div>
)}
</div>
<div style={{ marginBottom: '8px' }}>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>
External ID (optional)
</label>
<input
type="text"
value={externalId}
onChange={(e) => setExternalId(e.target.value)}
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
placeholder="your-correlation-id"
/>
</div>
<div style={{ marginBottom: '8px' }}>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>Mode</label>
<select
value={mode}
onChange={(e) => setMode(e.target.value as 'create' | 'edit')}
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
>
<option value="create">Create</option>
<option value="edit">Edit</option>
</select>
</div>
{mode === 'create' && (
<div style={{ marginBottom: '8px' }}>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>
Envelope Type
</label>
<select
value={envelopeType}
onChange={(e) => setEnvelopeType(e.target.value as 'DOCUMENT' | 'TEMPLATE')}
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
>
<option value="DOCUMENT">Document</option>
<option value="TEMPLATE">Template</option>
</select>
</div>
)}
{mode === 'edit' && (
<div style={{ marginBottom: '8px' }}>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>
Envelope ID
</label>
<input
type="text"
value={envelopeId}
onChange={(e) => setEnvelopeId(e.target.value)}
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
placeholder="envelope_..."
required
/>
</div>
)}
<h3 style={{ fontSize: '14px', margin: '12px 0 4px' }}>Feature Flags</h3>
{renderCheckboxGroup('General', generalFeatures, setGeneralFeatures)}
{renderCheckboxGroup('Settings', settingsFeatures, setSettingsFeatures)}
{renderCheckboxGroup('Actions', actionsFeatures, setActionsFeatures)}
{renderCheckboxGroup('Envelope Items', envelopeItemsFeatures, setEnvelopeItemsFeatures)}
{renderCheckboxGroup('Recipients', recipientsFeatures, setRecipientsFeatures)}
{renderCheckboxGroup('Fields', fieldsFeatures, setFieldsFeatures)}
<h3 style={{ fontSize: '14px', margin: '12px 0 4px' }}>CSS Theming</h3>
<label style={{ display: 'block', fontSize: '12px', marginBottom: '8px' }}>
<input
type="checkbox"
checked={darkModeDisabled}
onChange={(e) => setDarkModeDisabled(e.target.checked)}
style={{ marginRight: '4px' }}
/>
darkModeDisabled
</label>
<fieldset
style={{
border: '1px solid #ccc',
padding: '8px',
marginBottom: '8px',
borderRadius: '4px',
}}
>
<legend style={{ fontWeight: 'bold', fontSize: '13px' }}>CSS Variables</legend>
<div
style={{
maxHeight: '200px',
overflowY: 'auto',
}}
>
{Object.entries(cssVars).map(([key, value]) => (
<div
key={key}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '2px',
}}
>
<label style={{ fontSize: '11px', width: '140px', flexShrink: 0 }}>{key}</label>
{key !== 'radius' && (
<input
type="color"
value={value || '#000000'}
onChange={(e) => setCssVars((prev) => ({ ...prev, [key]: e.target.value }))}
style={{ width: '24px', height: '20px', padding: 0, border: 'none' }}
/>
)}
<input
type="text"
value={value}
onChange={(e) => setCssVars((prev) => ({ ...prev, [key]: e.target.value }))}
style={{ flex: 1, padding: '2px 4px', fontSize: '11px' }}
placeholder={key === 'radius' ? '0.5rem' : '#hex or color'}
/>
{value && (
<button
type="button"
onClick={() => setCssVars((prev) => ({ ...prev, [key]: '' }))}
style={{
fontSize: '10px',
cursor: 'pointer',
padding: '0 4px',
lineHeight: '18px',
}}
>
x
</button>
)}
</div>
))}
</div>
</fieldset>
<fieldset
style={{
border: '1px solid #ccc',
padding: '8px',
marginBottom: '8px',
borderRadius: '4px',
}}
>
<legend style={{ fontWeight: 'bold', fontSize: '13px' }}>Raw CSS</legend>
<textarea
value={rawCss}
onChange={(e) => setRawCss(e.target.value)}
style={{
width: '100%',
height: '80px',
padding: '4px',
fontSize: '11px',
fontFamily: 'monospace',
resize: 'vertical',
}}
placeholder=".my-class { color: red; }"
/>
</fieldset>
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
<button
type="submit"
disabled={isResolvingToken}
style={{
flex: 1,
padding: '8px',
fontSize: '13px',
fontWeight: 'bold',
cursor: isResolvingToken ? 'not-allowed' : 'pointer',
opacity: isResolvingToken ? 0.6 : 1,
}}
>
{isResolvingToken ? 'Resolving Token...' : 'Launch Embed'}
</button>
<button
type="button"
onClick={handleClear}
style={{
padding: '8px 12px',
fontSize: '13px',
cursor: 'pointer',
}}
>
Clear
</button>
</div>
</form>
{/* Message log */}
<div style={{ marginTop: '16px' }}>
<h3 style={{ fontSize: '14px', margin: '0 0 4px' }}>
PostMessage Log
{messages.length > 0 && (
<button
type="button"
onClick={() => setMessages([])}
style={{ marginLeft: '8px', fontSize: '10px', cursor: 'pointer' }}
>
clear
</button>
)}
</h3>
<div
style={{
height: '200px',
overflowY: 'auto',
border: '1px solid #ccc',
padding: '4px',
fontSize: '11px',
backgroundColor: '#f9f9f9',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{messages.length === 0 && (
<span style={{ color: '#999' }}>Waiting for messages...</span>
)}
{messages.map((msg, i) => (
<div key={i} style={{ borderBottom: '1px solid #eee', padding: '2px 0' }}>
{msg}
</div>
))}
<div ref={messagesEndRef} />
</div>
</div>
</div>
{/* Right panel: iframe */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{iframeSrc ? (
<iframe
key={iframeKey}
src={iframeSrc}
style={{ flex: 1, border: 'none', width: '100%', height: '100%' }}
title="Embedded Authoring"
/>
) : (
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#999',
fontSize: '14px',
}}
>
Enter a token and click "Launch Embed" to start
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,181 @@
import { useLayoutEffect } from 'react';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole, OrganisationType, TeamMemberRole } from '@prisma/client';
import { Outlet, isRouteErrorResponse, useLoaderData } from 'react-router';
import { match } from 'ts-pattern';
import { PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import { TrpcProvider } from '@documenso/trpc/react';
import type { OrganisationSession } from '@documenso/trpc/server/organisation-router/get-organisation-session.types';
import { TeamProvider } from '~/providers/team';
import { ZBaseEmbedDataSchema } from '~/types/embed-base-schemas';
import { injectCss } from '~/utils/css-vars';
import type { Route } from './+types/_layout';
export const loader = async ({ request }: Route.LoaderArgs) => {
const url = new URL(request.url);
const token = url.searchParams.get('token');
if (!token) {
throw new Response('Invalid token', { status: 404 });
}
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
if (!result) {
throw new Response('Invalid token', { status: 404 });
}
const organisationClaim = await getOrganisationClaimByTeamId({
teamId: result.teamId,
});
const teamSettings = await getTeamSettings({
userId: result.userId,
teamId: result.teamId,
});
return {
token,
userId: result.userId,
teamId: result.teamId,
organisationClaim,
preferences: {
aiFeaturesEnabled: teamSettings.aiFeaturesEnabled,
},
};
};
export default function AuthoringLayout() {
const { token, teamId, organisationClaim, preferences } = useLoaderData<typeof loader>();
const allowEmbedAuthoringWhiteLabel = organisationClaim.flags.embedAuthoringWhiteLabel ?? false;
useLayoutEffect(() => {
try {
const hash = window.location.hash.slice(1);
const result = ZBaseEmbedDataSchema.safeParse(JSON.parse(decodeURIComponent(atob(hash))));
if (!result.success) {
return;
}
const { css, cssVars, darkModeDisabled } = result.data;
if (darkModeDisabled) {
document.documentElement.classList.add('dark-mode-disabled');
}
if (allowEmbedAuthoringWhiteLabel) {
injectCss({
css,
cssVars,
});
}
} catch (error) {
console.error(error);
}
}, []);
/**
* Dummy data for providers.
*/
const team: OrganisationSession['teams'][number] = {
id: teamId,
name: '',
url: '',
createdAt: new Date(),
avatarImageId: null,
organisationId: '',
currentTeamRole: TeamMemberRole.ADMIN,
preferences: {
aiFeaturesEnabled: preferences.aiFeaturesEnabled,
},
};
/**
* Dummy data for providers.
*/
const organisation: OrganisationSession = {
id: '',
createdAt: new Date(),
updatedAt: new Date(),
type: OrganisationType.ORGANISATION,
name: '',
url: '',
avatarImageId: null,
customerId: null,
ownerUserId: -1,
organisationClaim,
teams: [team],
subscription: null,
currentOrganisationRole: OrganisationMemberRole.ADMIN,
};
return (
<OrganisationProvider organisation={organisation}>
<TeamProvider team={team}>
<TrpcProvider
headers={{ authorization: `Bearer ${token}`, 'x-team-Id': team.id.toString() }}
>
<LimitsProvider
bypassLimits={true}
initialValue={{
quota: PAID_PLAN_LIMITS,
remaining: PAID_PLAN_LIMITS,
maximumEnvelopeItemCount: organisationClaim.envelopeItemCount,
}}
teamId={team.id}
>
<Outlet />
</LimitsProvider>
</TrpcProvider>
</TeamProvider>
</OrganisationProvider>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
return (
<div>
{match(errorCode)
.with(404, () => (
<div>
<p>
<Trans>Token Not Found</Trans>
</p>
<ul>
<li>
<Trans>Ensure that you are using the embedding token, not the API token</Trans>
</li>
<li>
<Trans>
If you are using staging, ensure that you have set the host prop on the embedding
component to the staging domain (https://stg-app.documenso.com)
</Trans>
</li>
</ul>
</div>
))
.otherwise(() => (
<p>
<Trans>An error occurred</Trans>
{errorCode}
</p>
))}
</div>
);
}
@@ -0,0 +1,404 @@
import { useLayoutEffect, useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { useLingui } from '@lingui/react/macro';
import {
DocumentStatus,
EnvelopeType,
ReadStatus,
SendStatus,
SigningStatus,
} from '@prisma/client';
import { CheckCircle2Icon } from 'lucide-react';
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import type { SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import type { TDocumentMetaDateFormat } from '@documenso/lib/types/document-meta';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
import {
type TEmbedCreateEnvelopeAuthoring,
ZEmbedCreateEnvelopeAuthoringSchema,
} from '@documenso/lib/types/envelope-editor';
import type { TEnvelopeFieldAndMeta } from '@documenso/lib/types/field-meta';
import { extractDerivedDocumentMeta } from '@documenso/lib/utils/document';
import { buildEmbeddedFeatures } from '@documenso/lib/utils/embed-config';
import { buildEmbeddedEditorOptions } from '@documenso/lib/utils/embed-config';
import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { Spinner } from '@documenso/ui/primitives/spinner';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { EnvelopeEditor } from '~/components/general/envelope-editor/envelope-editor';
import { EnvelopeEditorRenderProviderWrapper } from '~/components/general/envelope-editor/envelope-editor-renderer-provider-wrapper';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/envelope.create._index';
export const loader = async ({ request }: Route.LoaderArgs) => {
const url = new URL(request.url);
// We know that the token is present because we're checking it in the parent _layout route
const token = url.searchParams.get('token') || '';
if (!token) {
throw new Response('Invalid token', { status: 404 });
}
// We also know that the token is valid, but we need the userId + teamId
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
if (!result) {
throw new Response('Invalid token', { status: 404 });
}
const teamSettings = await getTeamSettings({
userId: result.userId,
teamId: result.teamId,
});
return superLoaderJson({
token,
tokenUserId: result.userId,
tokenTeamId: result.teamId,
teamSettings,
});
};
export default function EmbeddingAuthoringEnvelopeCreatePage() {
const [hasInitialized, setHasInitialized] = useState(false);
const [embedAuthoringOptions, setEmbedAuthoringOptions] =
useState<TEmbedCreateEnvelopeAuthoring | null>(null);
useLayoutEffect(() => {
try {
const hash = window.location.hash.slice(1);
if (hash) {
const result = ZEmbedCreateEnvelopeAuthoringSchema.safeParse(
JSON.parse(decodeURIComponent(atob(hash))),
);
if (result.success) {
setEmbedAuthoringOptions({
...result.data,
features: buildEmbeddedFeatures(result.data.features),
});
}
}
} catch (err) {
console.error('Error parsing embedding params:', err);
}
setHasInitialized(true);
}, []);
if (!hasInitialized) {
return (
<div className="flex min-h-screen items-center justify-center">
<Spinner />
</div>
);
}
if (!embedAuthoringOptions) {
return (
<div className="flex min-h-screen items-center justify-center">
<Trans>Invalid Embedding Parameters</Trans>
</div>
);
}
return <EnvelopeCreatePage embedAuthoringOptions={embedAuthoringOptions} />;
}
type EnvelopeCreatePageProps = {
embedAuthoringOptions: TEmbedCreateEnvelopeAuthoring;
};
const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps) => {
const { token, tokenUserId, tokenTeamId, teamSettings } = useSuperLoaderData<typeof loader>();
const { t } = useLingui();
const { toast } = useToast();
const [isCreatingEnvelope, setIsCreatingEnvelope] = useState(false);
const [createdEnvelope, setCreatedEnvelope] = useState<{ id: string } | null>(null);
const { mutateAsync: createEmbeddingEnvelope } =
trpc.embeddingPresign.createEmbeddingEnvelope.useMutation();
const buildCreateEnvelopeRequest = (
envelope: Omit<TEditorEnvelope, 'id'>,
): { payload: TCreateEnvelopePayload; files: File[] } => {
const sortedItems = [...envelope.envelopeItems].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const itemIdToIndex = new Map<string, number>();
sortedItems.forEach((item, index) => {
itemIdToIndex.set(String(item.id), index);
});
const files: File[] = [];
for (const item of sortedItems) {
if (!item.data) {
throw new Error(`Envelope item "${item.title ?? item.id}" has no PDF data`);
}
files.push(
new File(
[item.data],
item.title?.endsWith('.pdf') ? item.title : `${item.title ?? 'document'}.pdf`,
{
type: 'application/pdf',
},
),
);
}
const recipients = envelope.recipients.map((recipient) => {
const recipientFields = envelope.fields.filter((f) => f.recipientId === recipient.id);
const fields = recipientFields.map((field) => {
return {
identifier: itemIdToIndex.get(String(field.envelopeItemId)),
page: field.page,
positionX: Number(field.positionX),
positionY: Number(field.positionY),
width: Number(field.width),
height: Number(field.height),
...({
type: field.type,
fieldMeta: field.fieldMeta ?? undefined,
} as TEnvelopeFieldAndMeta),
};
});
return {
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder ?? undefined,
accessAuth: recipient.authOptions?.accessAuth ?? [],
actionAuth: recipient.authOptions?.actionAuth ?? [],
fields,
};
});
const payload: TCreateEnvelopePayload = {
title: envelope.title,
type: envelope.type,
externalId: envelope.externalId ?? undefined,
visibility: envelope.visibility,
globalAccessAuth: envelope.authOptions?.globalAccessAuth?.length
? envelope.authOptions?.globalAccessAuth
: undefined,
globalActionAuth: envelope.authOptions?.globalActionAuth?.length
? envelope.authOptions?.globalActionAuth
: undefined,
folderId: envelope.folderId ?? undefined,
recipients,
meta: {
subject: envelope.documentMeta.subject ?? undefined,
message: envelope.documentMeta.message ?? undefined,
timezone: envelope.documentMeta.timezone ?? undefined,
dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined,
distributionMethod: envelope.documentMeta.distributionMethod ?? undefined,
signingOrder: envelope.documentMeta.signingOrder ?? undefined,
allowDictateNextSigner: envelope.documentMeta.allowDictateNextSigner ?? undefined,
redirectUrl: envelope.documentMeta.redirectUrl ?? undefined,
language: envelope.documentMeta.language as SupportedLanguageCodes,
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled ?? undefined,
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled ?? undefined,
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled ?? undefined,
emailId: envelope.documentMeta.emailId ?? undefined,
emailReplyTo: envelope.documentMeta.emailReplyTo ?? undefined,
emailSettings: envelope.documentMeta.emailSettings ?? undefined,
},
};
return { payload, files };
};
const createEmbeddedEnvelope = async (envelopeWithoutId: Omit<TEditorEnvelope, 'id'>) => {
setIsCreatingEnvelope(true);
if (isCreatingEnvelope) {
return;
}
try {
const { payload, files } = buildCreateEnvelopeRequest(envelopeWithoutId);
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
for (const file of files) {
formData.append('files', file);
}
const { id } = await createEmbeddingEnvelope(formData);
// Send a message to the parent window with the document details
if (window.parent !== window) {
window.parent.postMessage(
{
type: 'envelope-created',
envelopeId: id,
externalId: envelopeWithoutId.externalId,
},
'*',
);
}
setCreatedEnvelope({ id });
} catch (err) {
console.error('Failed to create envelope:', err);
toast({
variant: 'destructive',
title: t`Error`,
description: t`Failed to create document. Please try again.`,
});
}
setIsCreatingEnvelope(false);
};
const embeded = useMemo(
() => ({
presignToken: token,
mode: 'create' as const,
onCreate: async (envelope: Omit<TEditorEnvelope, 'id'>) => createEmbeddedEnvelope(envelope),
customBrandingLogo: Boolean(teamSettings.brandingEnabled && teamSettings.brandingLogo),
}),
[token],
);
const editorConfig = useMemo(() => {
return buildEmbeddedEditorOptions(embedAuthoringOptions.features, embeded);
}, [embedAuthoringOptions.features, embeded]);
const initialEnvelope = useMemo((): TEditorEnvelope => {
const defaultDocumentMeta = extractDerivedDocumentMeta(teamSettings, undefined);
const defaultRecipients = teamSettings.defaultRecipients
? ZDefaultRecipientsSchema.parse(teamSettings.defaultRecipients)
: [];
const recipients: TEditorEnvelope['recipients'] = defaultRecipients.map((recipient, index) => ({
id: -(index + 1),
envelopeId: '',
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: '',
readStatus: ReadStatus.NOT_OPENED,
signingStatus: SigningStatus.NOT_SIGNED,
sendStatus: SendStatus.NOT_SENT,
documentDeletedAt: null,
expired: null,
signedAt: null,
authOptions: {
accessAuth: [],
actionAuth: [],
},
signingOrder: index + 1,
rejectionReason: null,
}));
const type = embedAuthoringOptions.type;
return {
id: '',
secondaryId: '',
internalVersion: 2,
type,
status: DocumentStatus.DRAFT,
source: 'DOCUMENT',
visibility: teamSettings.documentVisibility,
templateType: 'PRIVATE',
completedAt: null,
deletedAt: null,
title: type === EnvelopeType.DOCUMENT ? 'Document Title' : 'Template Title',
authOptions: {
globalAccessAuth: [],
globalActionAuth: [],
},
publicTitle: '',
publicDescription: '',
userId: tokenUserId,
teamId: tokenTeamId,
folderId: null,
documentMeta: {
id: '',
...defaultDocumentMeta,
},
recipients,
fields: [],
envelopeItems: [],
directLink: null,
team: {
id: tokenTeamId,
url: '',
},
user: {
id: tokenUserId,
name: '',
email: '',
},
externalId: embedAuthoringOptions?.externalId ?? null,
};
}, []);
return (
<div className="min-w-screen relative min-h-screen">
{isCreatingEnvelope && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
<Spinner />
<p className="mt-2 text-sm text-muted-foreground">
{initialEnvelope.type === EnvelopeType.DOCUMENT ? (
<Trans>Creating Document</Trans>
) : (
<Trans>Creating Template</Trans>
)}
</p>
</div>
)}
{createdEnvelope && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
<div className="mx-auto w-full max-w-md text-center">
<CheckCircle2Icon className="mx-auto h-16 w-16 text-primary" />
<h1 className="mt-6 text-2xl font-bold">
{initialEnvelope.type === EnvelopeType.TEMPLATE ? (
<Trans>Template Created</Trans>
) : (
<Trans>Document Created</Trans>
)}
</h1>
<p className="mt-2 text-muted-foreground">
{initialEnvelope.type === EnvelopeType.TEMPLATE ? (
<Trans>Your template has been created successfully</Trans>
) : (
<Trans>Your document has been created successfully</Trans>
)}
</p>
</div>
</div>
)}
<EnvelopeEditorProvider initialEnvelope={initialEnvelope} editorConfig={editorConfig}>
<EnvelopeEditorRenderProviderWrapper presignedToken={token}>
<EnvelopeEditor />
</EnvelopeEditorRenderProviderWrapper>
</EnvelopeEditorProvider>
</div>
);
};
@@ -0,0 +1,353 @@
import { useLayoutEffect, useMemo, useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { CheckCircle2Icon } from 'lucide-react';
import { redirect } from 'react-router';
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import type { SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import type { TDocumentMetaDateFormat } from '@documenso/lib/types/document-meta';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
import {
type TEmbedEditEnvelopeAuthoring,
ZEmbedEditEnvelopeAuthoringSchema,
} from '@documenso/lib/types/envelope-editor';
import type { TEnvelopeFieldAndMeta } from '@documenso/lib/types/field-meta';
import { buildEmbeddedEditorOptions } from '@documenso/lib/utils/embed-config';
import { trpc } from '@documenso/trpc/react';
import type { TUpdateEmbeddingEnvelopePayload } from '@documenso/trpc/server/embedding-router/update-embedding-envelope.types';
import { Spinner } from '@documenso/ui/primitives/spinner';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { EnvelopeEditor } from '~/components/general/envelope-editor/envelope-editor';
import { EnvelopeEditorRenderProviderWrapper } from '~/components/general/envelope-editor/envelope-editor-renderer-provider-wrapper';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/envelope.edit.$id';
export const loader = async ({ request, params }: Route.LoaderArgs) => {
const url = new URL(request.url);
const { id } = params;
if (!id || !id.startsWith('envelope_')) {
throw redirect(`/embed/v2/authoring/error/not-found`);
}
// We know that the token is present because we're checking it in the parent _layout route
const token = url.searchParams.get('token') || '';
if (!token) {
throw new Response('Invalid token', { status: 404 });
}
// We also know that the token is valid, but we need the userId + teamId
const result = await verifyEmbeddingPresignToken({ token, scope: `envelopeId:${id}` }).catch(
() => null,
);
if (!result) {
throw new Error('Invalid token');
}
const settings = await getTeamSettings({
userId: result.userId,
teamId: result.teamId,
});
const envelope = await getEnvelopeById({
id: {
type: 'envelopeId',
id,
},
type: null,
userId: result.userId,
teamId: result.teamId,
}).catch(() => null);
if (!envelope) {
throw redirect(`/embed/v2/authoring/error/not-found`);
}
let brandingLogo: string | undefined = undefined;
if (settings.brandingEnabled && settings.brandingLogo) {
brandingLogo = settings.brandingLogo;
}
return superLoaderJson({
token,
envelope,
brandingLogo,
});
};
export default function EmbeddingAuthoringEnvelopeEditPage() {
const [hasInitialized, setHasInitialized] = useState(false);
const [embedAuthoringOptions, setEmbedAuthoringOptions] =
useState<TEmbedEditEnvelopeAuthoring | null>(null);
useLayoutEffect(() => {
try {
const hash = window.location.hash.slice(1);
if (hash) {
const result = ZEmbedEditEnvelopeAuthoringSchema.safeParse(
JSON.parse(decodeURIComponent(atob(hash))),
);
if (result.success) {
setEmbedAuthoringOptions(result.data);
}
}
} catch (err) {
console.error('Error parsing embedding params:', err);
}
setHasInitialized(true);
}, []);
if (!hasInitialized) {
return (
<div className="flex min-h-screen items-center justify-center">
<Spinner />
</div>
);
}
if (!embedAuthoringOptions) {
return (
<div className="flex min-h-screen items-center justify-center">
<Trans>Invalid Embedding Parameters</Trans>
</div>
);
}
return <EnvelopeEditPage embedAuthoringOptions={embedAuthoringOptions} />;
}
type EnvelopeEditPageProps = {
embedAuthoringOptions: TEmbedEditEnvelopeAuthoring;
};
const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
const { envelope, token, brandingLogo } = useSuperLoaderData<typeof loader>();
const { t } = useLingui();
const { toast } = useToast();
const [isUpdatingEnvelope, setIsUpdatingEnvelope] = useState(false);
const [updatedEnvelope, setUpdatedEnvelope] = useState<{ id: string } | null>(null);
const { mutateAsync: updateEmbeddingEnvelope } =
trpc.embeddingPresign.updateEmbeddingEnvelope.useMutation();
const buildUpdateEnvelopeRequest = (
envelope: TEditorEnvelope,
): { payload: TUpdateEmbeddingEnvelopePayload; files: File[] } => {
const files: File[] = [];
const envelopeItems = envelope.envelopeItems.map((item) => {
// Attach any new envelope item files to the request.
if (item.data) {
files.push(
new File(
[item.data],
item.title?.endsWith('.pdf') ? item.title : `${item.title ?? 'document'}.pdf`,
{
type: 'application/pdf',
},
),
);
}
return {
id: item.id,
title: item.title,
order: item.order,
index: item.data ? files.length - 1 : undefined,
};
});
const recipients = envelope.recipients.map((recipient) => {
const recipientFields = envelope.fields.filter((f) => f.recipientId === recipient.id);
const fields = recipientFields.map((field) => ({
id: field.id,
envelopeItemId: field.envelopeItemId,
page: field.page,
positionX: Number(field.positionX),
positionY: Number(field.positionY),
width: Number(field.width),
height: Number(field.height),
...({
type: field.type,
fieldMeta: field.fieldMeta ?? undefined,
} as TEnvelopeFieldAndMeta),
}));
return {
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder ?? undefined,
accessAuth: recipient.authOptions?.accessAuth ?? [],
actionAuth: recipient.authOptions?.actionAuth ?? [],
fields,
};
});
const payload: TUpdateEmbeddingEnvelopePayload = {
envelopeId: envelope.id,
data: {
title: envelope.title,
externalId: envelope.externalId,
visibility: envelope.visibility,
globalAccessAuth: envelope.authOptions?.globalAccessAuth,
globalActionAuth: envelope.authOptions?.globalActionAuth,
folderId: envelope.folderId,
recipients,
envelopeItems,
},
meta: {
subject: envelope.documentMeta.subject ?? undefined,
message: envelope.documentMeta.message ?? undefined,
timezone: envelope.documentMeta.timezone ?? undefined,
distributionMethod: envelope.documentMeta.distributionMethod ?? undefined,
signingOrder: envelope.documentMeta.signingOrder ?? undefined,
allowDictateNextSigner: envelope.documentMeta.allowDictateNextSigner ?? undefined,
redirectUrl: envelope.documentMeta.redirectUrl ?? undefined,
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled ?? undefined,
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled ?? undefined,
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled ?? undefined,
emailId: envelope.documentMeta.emailId ?? undefined,
emailReplyTo: envelope.documentMeta.emailReplyTo ?? undefined,
emailSettings: envelope.documentMeta.emailSettings ?? undefined,
dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined,
language: envelope.documentMeta.language as SupportedLanguageCodes,
},
};
return { payload, files };
};
const updateEmbeddedEnvelope = async (envelope: TEditorEnvelope) => {
setIsUpdatingEnvelope(true);
if (isUpdatingEnvelope) {
return;
}
try {
const { payload, files } = buildUpdateEnvelopeRequest(envelope);
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
for (const file of files) {
formData.append('files', file);
}
await updateEmbeddingEnvelope(formData);
// Send a message to the parent window with the document details
if (window.parent !== window) {
window.parent.postMessage(
{
type: 'envelope-updated',
envelopeId: envelope.id,
externalId: envelope.externalId || null,
},
'*',
);
}
// Navigate to the completion page.
setUpdatedEnvelope({ id: envelope.id });
} catch (err) {
console.error('Failed to update envelope:', err);
toast({
variant: 'destructive',
title: t`Error`,
description: t`Failed to update envelope. Please try again.`,
});
}
setIsUpdatingEnvelope(false);
};
const embeded = useMemo(
() => ({
presignToken: token,
mode: 'edit' as const,
onUpdate: async (envelope: TEditorEnvelope) => updateEmbeddedEnvelope(envelope),
brandingLogo,
}),
[token],
);
const editorConfig = useMemo(() => {
return buildEmbeddedEditorOptions(embedAuthoringOptions.features, embeded);
}, [embedAuthoringOptions.features, embeded]);
const initialEnvelope = useMemo(
() => ({
...envelope,
externalId: embedAuthoringOptions?.externalId || envelope.externalId || null,
}),
[envelope, embedAuthoringOptions?.externalId],
);
return (
<div className="min-w-screen relative min-h-screen">
{isUpdatingEnvelope && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
<Spinner />
<p className="mt-2 text-sm text-muted-foreground">
{envelope.type === EnvelopeType.DOCUMENT ? (
<Trans>Updating Document</Trans>
) : (
<Trans>Updating Template</Trans>
)}
</p>
</div>
)}
{updatedEnvelope && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
<div className="mx-auto w-full max-w-md text-center">
<CheckCircle2Icon className="mx-auto h-16 w-16 text-primary" />
<h1 className="mt-6 text-2xl font-bold">
{envelope.type === EnvelopeType.TEMPLATE ? (
<Trans>Template Updated</Trans>
) : (
<Trans>Document Updated</Trans>
)}
</h1>
<p className="mt-2 text-muted-foreground">
{envelope.type === EnvelopeType.TEMPLATE ? (
<Trans>Your template has been updated successfully</Trans>
) : (
<Trans>Your document has been updated successfully</Trans>
)}
</p>
</div>
</div>
)}
<EnvelopeEditorProvider initialEnvelope={initialEnvelope} editorConfig={editorConfig}>
<EnvelopeEditorRenderProviderWrapper presignedToken={token}>
<EnvelopeEditor />
</EnvelopeEditorRenderProviderWrapper>
</EnvelopeEditorProvider>
</div>
);
};
+12
View File
@@ -23,6 +23,10 @@ import {
ZGetPresignedPostUrlRequestSchema, ZGetPresignedPostUrlRequestSchema,
ZUploadPdfRequestSchema, ZUploadPdfRequestSchema,
} from './files.types'; } from './files.types';
import getEnvelopeItemImageRoute from './routes/get-envelope-item-image';
import getEnvelopeItemImageByTokenRoute from './routes/get-envelope-item-image-by-token';
import getEnvelopeItemMetaRoute from './routes/get-envelope-item-meta';
import getEnvelopeItemMetaByTokenRoute from './routes/get-envelope-item-meta-by-token';
export const filesRoute = new Hono<HonoEnv>() export const filesRoute = new Hono<HonoEnv>()
/** /**
@@ -319,3 +323,11 @@ export const filesRoute = new Hono<HonoEnv>()
}); });
}, },
); );
// Envelope item meta routes for both tokens and auth based
filesRoute.route('/', getEnvelopeItemMetaRoute);
filesRoute.route('/', getEnvelopeItemMetaByTokenRoute);
// Image routes for both tokens and auth based
filesRoute.route('/', getEnvelopeItemImageRoute);
filesRoute.route('/', getEnvelopeItemImageByTokenRoute);
@@ -72,3 +72,23 @@ export const ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema = z.object({
export type TGetEnvelopeItemFileTokenDownloadRequestParams = z.infer< export type TGetEnvelopeItemFileTokenDownloadRequestParams = z.infer<
typeof ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema typeof ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema
>; >;
export const ZGetEnvelopeItemMetaSchema = z.object({
envelopeItemId: z.string(),
documentDataId: z.string(),
pages: z
.object({
originalWidth: z.number(),
originalHeight: z.number(),
scale: z.number(),
scaledWidth: z.number(),
scaledHeight: z.number(),
})
.array(),
});
export const ZGetEnvelopeItemsMetaResponseSchema = z.object({
envelopeItems: z.array(ZGetEnvelopeItemMetaSchema),
});
export type TGetEnvelopeItemsMetaResponse = z.infer<typeof ZGetEnvelopeItemsMetaResponseSchema>;
@@ -0,0 +1,68 @@
import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono';
import { z } from 'zod';
import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../../../router';
import { handleEnvelopeItemPageRequest } from './get-envelope-item-image';
const route = new Hono<HonoEnv>();
const ZGetEnvelopeItemPageTokenParamsSchema = z.object({
token: z.string().min(1),
envelopeId: z.string().min(1),
envelopeItemId: z.string().min(1),
documentDataId: z.string().min(1),
version: z.enum(['initial', 'current']),
pageIndex: z.coerce.number().int().min(0),
});
/**
* Returns a single PDF page as a JPEG image using a token.
*/
route.get(
'/token/:token/envelope/:envelopeId/envelopeItem/:envelopeItemId/dataId/:documentDataId/:version/:pageIndex/image.jpeg',
sValidator('param', ZGetEnvelopeItemPageTokenParamsSchema),
async (c) => {
const { token, envelopeId, envelopeItemId, documentDataId, version, pageIndex } =
c.req.valid('param');
// Validate envelope access.
const envelopeItem = await prisma.envelopeItem.findFirst({
where: {
id: envelopeItemId,
documentDataId,
envelope: {
id: envelopeId,
recipients: {
some: {
token,
},
},
},
},
include: {
documentData: true,
},
});
if (!envelopeItem) {
return c.json({ error: 'Not found' }, 404);
}
// We can hard cache this since since it's a unique URL for a given recipient.
// Might be dicey if the handler returns a cacheable error code.
c.header('Cache-Control', 'public, max-age=31536000, immutable');
return await handleEnvelopeItemPageRequest({
c,
envelopeItem,
version,
pageIndex,
cacheStrategy: 'public',
});
},
);
export default route;
@@ -0,0 +1,175 @@
import { sValidator } from '@hono/standard-validator';
import type { DocumentData, EnvelopeItem } from '@prisma/client';
import { type Context, Hono } from 'hono';
import { z } from 'zod';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { PDF_IMAGE_RENDER_SCALE } from '@documenso/lib/constants/pdf-viewer';
import { pdfToImage } from '@documenso/lib/server-only/ai/pdf-to-images';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import type { DocumentDataVersion } from '@documenso/lib/types/document-data';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { UNSAFE_getS3File } from '@documenso/lib/universal/upload/server-actions';
import { getEnvelopeItemPageImageS3Key } from '@documenso/lib/utils/envelope-images';
import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../../../router';
const route = new Hono<HonoEnv>();
const ZGetEnvelopeItemPageRequestParamsSchema = z.object({
envelopeId: z.string().min(1),
envelopeItemId: z.string().min(1),
documentDataId: z.string().min(1),
version: z.enum(['initial', 'current']),
pageIndex: z.coerce.number().int().min(0),
});
const ZGetEnvelopeItemPageRequestQuerySchema = z.object({
presignToken: z.string().optional(),
});
/**
* Returns a single PDF page as a JPEG image.
*/
route.get(
'/envelope/:envelopeId/envelopeItem/:envelopeItemId/dataId/:documentDataId/:version/:pageIndex/image.jpeg',
sValidator('param', ZGetEnvelopeItemPageRequestParamsSchema),
sValidator('query', ZGetEnvelopeItemPageRequestQuerySchema),
async (c) => {
const { envelopeId, envelopeItemId, documentDataId, version, pageIndex } = c.req.valid('param');
const { presignToken } = c.req.valid('query');
const session = await getOptionalSession(c);
let userId = session.user?.id;
// Check presignToken if provided
if (presignToken) {
const verifiedToken = await verifyEmbeddingPresignToken({
token: presignToken,
}).catch(() => undefined);
userId = verifiedToken?.userId;
}
if (!userId) {
return c.json({ error: 'Not found' }, 404);
}
const envelope = await prisma.envelope.findFirst({
where: { id: envelopeId },
include: {
envelopeItems: {
where: {
id: envelopeItemId,
documentDataId,
},
include: {
documentData: true,
},
},
},
});
if (!envelope) {
return c.json({ error: 'Not found' }, 404);
}
const [envelopeItem] = envelope.envelopeItems;
if (!envelopeItem?.documentData) {
return c.json({ error: 'Not found' }, 404);
}
// Check team access
const team = await getTeamById({
userId,
teamId: envelope.teamId,
}).catch(() => null);
if (!team) {
return c.json({ error: 'Not found' }, 404);
}
return await handleEnvelopeItemPageRequest({
c,
envelopeItem,
version,
pageIndex,
cacheStrategy: 'private',
});
},
);
type HandleEnvelopeItemPageRequestOptions = {
c: Context<HonoEnv>;
envelopeItem: EnvelopeItem & {
documentData: DocumentData;
};
pageIndex: number;
version: DocumentDataVersion;
/**
* The type of cache strategy to use.
*
* For access via tokens, we can use a public cache to allow the CDN to cache it.
*
* For access via session, we must use a private cache.
*/
cacheStrategy: 'private' | 'public';
};
export const handleEnvelopeItemPageRequest = async ({
c,
envelopeItem,
pageIndex,
version,
cacheStrategy,
}: HandleEnvelopeItemPageRequestOptions) => {
// Determine which PDF data to use based on version requested.
const documentDataToUse =
version === 'current' ? envelopeItem.documentData.data : envelopeItem.documentData.initialData;
c.header('Content-Type', 'image/jpeg');
c.header('Cache-Control', `${cacheStrategy}, max-age=31536000, immutable`);
// Return the image if it already exists in S3.
if (envelopeItem.documentData.type === 'S3_PATH') {
const s3Key = getEnvelopeItemPageImageS3Key(documentDataToUse, pageIndex);
const image = await UNSAFE_getS3File(s3Key);
if (image) {
return c.body(image);
}
}
// Fetch PDF to render the page on the spot if it doesn't exist in S3.
const pdfBytes = await getFileServerSide({
type: envelopeItem.documentData.type,
data: documentDataToUse,
});
// Render page to image.
const { image } = await pdfToImage(pdfBytes, {
scale: PDF_IMAGE_RENDER_SCALE,
pageIndex,
}).catch((err) => {
console.error(err);
return {
image: null,
};
});
if (!image) {
return c.json({ error: 'Failed to render page to image' }, 500);
}
return c.body(image);
};
export default route;
@@ -0,0 +1,54 @@
import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono';
import { z } from 'zod';
import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../../../router';
import { handleEnvelopeItemsMetaRequest } from './get-envelope-item-meta';
const route = new Hono<HonoEnv>();
const ZGetEnvelopeMetaByTokenParamSchema = z.object({
token: z.string().min(1),
envelopeId: z.string().min(1),
});
/**
* Returns metadata for all envelope items including page counts and dimensions using a token.
*/
route.get(
'/token/:token/envelope/:envelopeId/meta',
sValidator('param', ZGetEnvelopeMetaByTokenParamSchema),
async (c) => {
const { token, envelopeId } = c.req.valid('param');
// Validate token belongs to envelope
const recipient = await prisma.recipient.findFirst({
where: {
token,
envelopeId,
},
select: {
envelope: {
include: {
envelopeItems: {
include: { documentData: true },
},
},
},
},
});
if (!recipient) {
return c.json({ error: 'Not found' }, 404);
}
return await handleEnvelopeItemsMetaRequest({
c,
envelopeItems: recipient.envelope.envelopeItems,
});
},
);
export default route;
@@ -0,0 +1,139 @@
import { sValidator } from '@hono/standard-validator';
import type { DocumentData, EnvelopeItem } from '@prisma/client';
import { type Context, Hono } from 'hono';
import { z } from 'zod';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import type { TDocumentDataMeta } from '@documenso/lib/types/document-data';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { extractAndStorePdfImages } from '@documenso/lib/universal/upload/put-file.server';
import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../../../router';
import type { TGetEnvelopeItemsMetaResponse } from '../files.types';
const route = new Hono<HonoEnv>();
const ZGetEnvelopeMetaParamsSchema = z.object({
envelopeId: z.string().min(1),
});
const ZGetEnvelopeMetaQuerySchema = z.object({
presignToken: z.string().optional(),
});
/**
* Returns metadata for all envelope items including page counts and dimensions.
*/
route.get(
'/envelope/:envelopeId/meta',
sValidator('param', ZGetEnvelopeMetaParamsSchema),
sValidator('query', ZGetEnvelopeMetaQuerySchema),
async (c) => {
const { envelopeId } = c.req.valid('param');
const { presignToken } = c.req.valid('query');
const session = await getOptionalSession(c);
let userId = session.user?.id;
// Check presignToken if provided
if (presignToken) {
const verifiedToken = await verifyEmbeddingPresignToken({
token: presignToken,
}).catch(() => undefined);
userId = verifiedToken?.userId;
}
if (!userId) {
return c.json({ error: 'Not found' }, 404);
}
// Note: Access is verified in the getTeamById call after this.
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
},
include: {
envelopeItems: {
include: { documentData: true },
},
},
});
if (!envelope) {
return c.json({ error: 'Not found' }, 404);
}
// Check access to envelope.
const team = await getTeamById({
userId,
teamId: envelope.teamId,
}).catch(() => null);
if (!team) {
return c.json({ error: 'Not found' }, 404);
}
return await handleEnvelopeItemsMetaRequest({
c,
envelopeItems: envelope.envelopeItems,
});
},
);
type HandleEnvelopeItemsMetaRequestOptions = {
c: Context<HonoEnv>;
envelopeItems: (EnvelopeItem & {
documentData: DocumentData;
})[];
};
export const handleEnvelopeItemsMetaRequest = async ({
c,
envelopeItems,
}: HandleEnvelopeItemsMetaRequestOptions) => {
const response = await Promise.all(
envelopeItems.map(async (item) => {
let pageMetadata = item.documentData.metadata;
// Runtime backfill if pageMetadata is missing.
if (!pageMetadata) {
const pdfBytes = await getFileServerSide({
type: item.documentData.type,
data: item.documentData.data,
});
const pdfPageMetadata: TDocumentDataMeta['pages'] = await extractAndStorePdfImages(
new Uint8Array(pdfBytes).buffer,
item.documentData.id,
);
pageMetadata = {
pages: pdfPageMetadata,
};
}
const pages = pageMetadata.pages ?? [];
return {
envelopeItemId: item.id,
documentDataId: item.documentData.id,
pages: pages.map((page) => ({
originalWidth: page.originalWidth,
originalHeight: page.originalHeight,
scale: page.scale,
scaledWidth: page.scaledWidth,
scaledHeight: page.scaledHeight,
})),
};
}),
);
return c.json({ envelopeItems: response } satisfies TGetEnvelopeItemsMetaResponse);
};
export default route;
-84
View File
@@ -27365,24 +27365,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/make-cancellable-promise": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz",
"integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==",
"license": "MIT",
"funding": {
"url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1"
}
},
"node_modules/make-event-props": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz",
"integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==",
"license": "MIT",
"funding": {
"url": "https://github.com/wojtekmaj/make-event-props?sponsor=1"
}
},
"node_modules/map-stream": { "node_modules/map-stream": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz",
@@ -27829,23 +27811,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/merge-refs": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz",
"integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==",
"license": "MIT",
"funding": {
"url": "https://github.com/wojtekmaj/merge-refs?sponsor=1"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/merge-stream": { "node_modules/merge-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -31875,44 +31840,6 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/react-pdf": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.3.0.tgz",
"integrity": "sha512-2LQzC9IgNVAX8gM+6F+1t/70a9/5RWThYxc+CWAmT2LW/BRmnj+35x1os5j/nR2oldyf8L+hCAMBmVKU8wrYFA==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"dequal": "^2.0.3",
"make-cancellable-promise": "^2.0.0",
"make-event-props": "^2.0.0",
"merge-refs": "^2.0.0",
"pdfjs-dist": "5.4.296",
"tiny-invariant": "^1.0.0",
"warning": "^4.0.0"
},
"funding": {
"url": "https://github.com/wojtekmaj/react-pdf?sponsor=1"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-pdf/node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react-promise-suspense": { "node_modules/react-promise-suspense": {
"version": "0.3.4", "version": "0.3.4",
"resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz", "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz",
@@ -36482,15 +36409,6 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/wcwidth": { "node_modules/wcwidth": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
@@ -37434,7 +37352,6 @@
"posthog-js": "^1.297.2", "posthog-js": "^1.297.2",
"posthog-node": "4.18.0", "posthog-node": "4.18.0",
"react": "^18", "react": "^18",
"react-pdf": "^10.3.0",
"remeda": "^2.32.0", "remeda": "^2.32.0",
"sharp": "0.34.5", "sharp": "0.34.5",
"skia-canvas": "^3.0.8", "skia-canvas": "^3.0.8",
@@ -37592,7 +37509,6 @@
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "^7.66.1", "react-hook-form": "^7.66.1",
"react-pdf": "^10.3.0",
"react-rnd": "^10.5.2", "react-rnd": "^10.5.2",
"remeda": "^2.32.0", "remeda": "^2.32.0",
"tailwind-merge": "^1.14.0", "tailwind-merge": "^1.14.0",
@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents'; import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';
@@ -46,7 +47,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 100, y: 100,
@@ -54,7 +55,7 @@ test.describe('AutoSave Fields Step', () => {
}); });
await page.getByRole('button', { name: 'Text' }).click(); await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 200, y: 200,
@@ -74,7 +75,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 500, y: 500,
@@ -100,7 +101,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 100, y: 100,
@@ -108,7 +109,7 @@ test.describe('AutoSave Fields Step', () => {
}); });
await page.getByRole('button', { name: 'Text' }).click(); await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 200, y: 200,
@@ -128,7 +129,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 500, y: 500,
@@ -162,7 +163,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 100, y: 100,
@@ -170,7 +171,7 @@ test.describe('AutoSave Fields Step', () => {
}); });
await page.getByRole('button', { name: 'Text' }).click(); await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 200, y: 200,
@@ -190,7 +191,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 500, y: 500,
@@ -224,7 +225,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 100, y: 100,
@@ -232,7 +233,7 @@ test.describe('AutoSave Fields Step', () => {
}); });
await page.getByRole('button', { name: 'Text' }).click(); await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 200, y: 200,
@@ -2,6 +2,7 @@ import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { EnvelopeType } from '@prisma/client'; import { EnvelopeType } from '@prisma/client';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { seedBlankDocument } from '@documenso/prisma/seed/documents'; import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';
@@ -28,7 +29,7 @@ export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => {
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 100, y: 100,
@@ -1,5 +1,6 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { seedBlankDocument } from '@documenso/prisma/seed/documents'; import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';
@@ -33,14 +34,14 @@ test('[DOCUMENT_FLOW]: Simple duplicate recipients test', async ({ page }) => {
// Step 3: Add fields // Step 3: Add fields
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } }); await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
await page.getByRole('combobox').first().click(); await page.getByRole('combobox').first().click();
// Switch to second duplicate and add field // Switch to second duplicate and add field
await page.getByText('Duplicate 2 (duplicate@example.com)').first().click(); await page.getByText('Duplicate 2 (duplicate@example.com)').first().click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } }); await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
// Continue to send // Continue to send
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
@@ -44,21 +44,21 @@ const completeDocumentFlowWithDuplicateRecipients = async (options: {
// Step 3: Add fields for each recipient // Step 3: Add fields for each recipient
// Add signature field for first duplicate recipient // Add signature field for first duplicate recipient
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } }); await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click(); await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click();
// Switch to second duplicate recipient and add their field // Switch to second duplicate recipient and add their field
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click(); await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } }); await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click(); await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
// Switch to unique recipient and add their field // Switch to unique recipient and add their field
await page.getByText('Unique Recipient (unique@example.com)').click(); await page.getByText('Unique Recipient (unique@example.com)').click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 300, y: 100 } }); await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 100 } });
// Continue to subject // Continue to subject
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
@@ -122,7 +122,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } }); await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
// Save the document by going to subject // Save the document by going to subject
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
@@ -149,7 +149,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
await page.getByText('Test Recipient Duplicate (test@example.com)').first().click(); await page.getByText('Test Recipient Duplicate (test@example.com)').first().click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } }); await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
// Complete the flow // Complete the flow
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
@@ -270,24 +270,24 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
// Add signature for first recipient // Add signature for first recipient
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } }); await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
// Add name field for second recipient // Add name field for second recipient
await page.getByRole('combobox').first().click(); await page.getByRole('combobox').first().click();
await page.getByText('Approver Role (signer@example.com)').first().click(); await page.getByText('Approver Role (signer@example.com)').first().click();
await page.getByRole('button', { name: 'Name' }).click(); await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } }); await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
// Add date field for second recipient // Add date field for second recipient
await page.getByRole('button', { name: 'Date' }).click(); await page.getByRole('button', { name: 'Date' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 150 } }); await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 150 } });
// If second recipient is still a SIGNER (role change wasn't available), // If second recipient is still a SIGNER (role change wasn't available),
// add a signature field for them to pass validation // add a signature field for them to pass validation
if (!secondRecipientIsApprover) { if (!secondRecipientIsApprover) {
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 200 } }); await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 200 } });
} }
// Complete the document // Complete the document
@@ -349,7 +349,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
// Add another field to the second duplicate // Add another field to the second duplicate
await page.getByRole('button', { name: 'Name' }).click(); await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 250, y: 150 } }); await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 250, y: 150 } });
// Save changes // Save changes
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
@@ -9,6 +9,7 @@ import {
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import path from 'node:path'; import path from 'node:path';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { import {
seedBlankDocument, seedBlankDocument,
@@ -92,7 +93,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 100, y: 100,
@@ -100,7 +101,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
}); });
await page.getByRole('button', { name: 'Email' }).click(); await page.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 200, y: 200,
@@ -158,7 +159,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 100, y: 100,
@@ -166,7 +167,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
}); });
await page.getByRole('button', { name: 'Email' }).click(); await page.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 200, y: 200,
@@ -177,7 +178,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.getByText('User 2 (user2@example.com)').click(); await page.getByText('User 2 (user2@example.com)').click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 500, x: 500,
y: 100, y: 100,
@@ -185,7 +186,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
}); });
await page.getByRole('button', { name: 'Email' }).click(); await page.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 500, x: 500,
y: 200, y: 200,
@@ -256,7 +257,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.getByRole('option', { name: 'User 1 (user1@example.com)' }).click(); await page.getByRole('option', { name: 'User 1 (user1@example.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 100, y: 100,
@@ -264,7 +265,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
}); });
await page.getByRole('button', { name: 'Email' }).click(); await page.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 200, y: 200,
@@ -275,7 +276,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.getByRole('option', { name: 'User 3 (user3@example.com)' }).click(); await page.getByRole('option', { name: 'User 3 (user3@example.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 500, x: 500,
y: 100, y: 100,
@@ -283,7 +284,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
}); });
await page.getByRole('button', { name: 'Email' }).click(); await page.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 500, x: 500,
y: 200, y: 200,
@@ -576,7 +577,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
} }
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 100 * i, y: 100 * i,
@@ -0,0 +1,223 @@
import { type Page, expect, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
clickAddMyselfButton,
clickEnvelopeEditorStep,
getEnvelopeEditorSettingsTrigger,
getRecipientEmailInputs,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
setRecipientEmail,
setRecipientName,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
type TFieldFlowResult = {
externalId: string;
recipientEmail: string;
};
const TEST_FIELD_VALUES = {
embeddedRecipient: {
email: 'embedded-field-recipient@documenso.com',
name: 'Embedded Field Recipient',
},
};
const openSettingsDialog = async (root: Page) => {
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
};
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
await openSettingsDialog(surface.root);
await surface.root.locator('input[name="externalId"]').fill(externalId);
await surface.root.getByRole('button', { name: 'Update' }).click();
if (!surface.isEmbedded) {
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
}
};
const setupRecipientsForFieldPlacement = async (surface: TEnvelopeEditorSurface) => {
if (surface.isEmbedded) {
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toHaveCount(0);
await setRecipientEmail(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.email);
await setRecipientName(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.name);
return TEST_FIELD_VALUES.embeddedRecipient.email;
}
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toBeVisible();
await clickAddMyselfButton(surface.root);
await expect(getRecipientEmailInputs(surface.root).first()).toHaveValue(surface.userEmail);
return surface.userEmail;
};
const placeFieldOnPdf = async (
root: Page,
fieldName: 'Signature' | 'Text',
position: { x: number; y: number },
) => {
await root.getByRole('button', { name: fieldName, exact: true }).click();
const canvas = root.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible();
await canvas.click({ position });
};
const runFieldFlow = async (surface: TEnvelopeEditorSurface): Promise<TFieldFlowResult> => {
const externalId = `e2e-fields-${nanoid()}`;
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(surface.root, 'embedded-fields.pdf');
}
await updateExternalId(surface, externalId);
const recipientEmail = await setupRecipientsForFieldPlacement(surface);
await clickEnvelopeEditorStep(surface.root, 'addFields');
await expect(surface.root.getByText('Selected Recipient')).toBeVisible();
await expect(surface.root.locator('.konva-container canvas').first()).toBeVisible();
await placeFieldOnPdf(surface.root, 'Signature', { x: 120, y: 140 });
await expect(surface.root.getByText('1 Field')).toBeVisible();
await placeFieldOnPdf(surface.root, 'Text', { x: 220, y: 240 });
await expect(surface.root.getByText('2 Fields')).toBeVisible();
await clickEnvelopeEditorStep(surface.root, 'upload');
await expect(surface.root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
await clickEnvelopeEditorStep(surface.root, 'addFields');
await expect(surface.root.getByText('Selected Recipient')).toBeVisible();
await expect(surface.root.getByText('2 Fields')).toBeVisible();
return {
externalId,
recipientEmail,
};
};
const getFieldMetaType = (fieldMeta: unknown) => {
if (!isRecord(fieldMeta)) {
return null;
}
return typeof fieldMeta.type === 'string' ? fieldMeta.type : null;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const assertFieldsPersistedInDatabase = async ({
surface,
externalId,
recipientEmail,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
recipientEmail: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
orderBy: {
createdAt: 'desc',
},
include: {
fields: true,
recipients: true,
},
});
const recipient = envelope.recipients.find(
(currentRecipient) => currentRecipient.email === recipientEmail,
);
expect(recipient).toBeDefined();
const fieldTypes = envelope.fields.map((field) => field.type).sort();
const expectedFieldTypes = [FieldType.SIGNATURE, FieldType.TEXT].sort();
expect(envelope.fields).toHaveLength(2);
expect(fieldTypes).toEqual(expectedFieldTypes);
expect(new Set(envelope.fields.map((field) => field.envelopeItemId)).size).toBe(1);
expect(envelope.fields.every((field) => field.recipientId === recipient?.id)).toBe(true);
const signatureField = envelope.fields.find((field) => field.type === FieldType.SIGNATURE);
const textField = envelope.fields.find((field) => field.type === FieldType.TEXT);
expect(getFieldMetaType(signatureField?.fieldMeta)).toBe('signature');
expect(getFieldMetaType(textField?.fieldMeta)).toBe('text');
};
test.describe('Envelope Editor V2 - Fields', () => {
test('documents/<id>: add and persist signature/text fields', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runFieldFlow(surface);
await assertFieldsPersistedInDatabase({
surface,
...result,
});
});
test('templates/<id>: add and persist signature/text fields', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runFieldFlow(surface);
await assertFieldsPersistedInDatabase({
surface,
...result,
});
});
test('/embed/v2/authoring/envelope/create DOCUMENT: add and persist signature/text fields', async ({
page,
}) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-fields',
});
const result = await runFieldFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertFieldsPersistedInDatabase({
surface,
...result,
});
});
test('/embed/v2/authoring/envelope/edit/<id> TEMPLATE: add and persist signature/text fields', async ({
page,
}) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-fields',
});
const result = await runFieldFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertFieldsPersistedInDatabase({
surface,
...result,
});
});
});
@@ -0,0 +1,182 @@
import { type Page, expect, test } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import {
type TEnvelopeEditorSurface,
getEnvelopeItemDragHandles,
getEnvelopeItemDropzoneInput,
getEnvelopeItemRemoveButtons,
getEnvelopeItemTitleInputs,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
} from '../fixtures/envelope-editor';
test.use({
storageState: {
cookies: [],
origins: [],
},
});
type TestFilePayload = {
name: string;
mimeType: string;
buffer: Buffer;
};
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
const createPdfPayload = (name: string): TestFilePayload => ({
name,
mimeType: 'application/pdf',
buffer: examplePdfBuffer,
});
const getCurrentTitles = async (root: Page) => {
const titleInputs = getEnvelopeItemTitleInputs(root);
const count = await titleInputs.count();
return await Promise.all(
Array.from({ length: count }, async (_, index) => await titleInputs.nth(index).inputValue()),
);
};
const uploadFiles = async (root: Page, files: TestFilePayload[]) => {
const input = getEnvelopeItemDropzoneInput(root);
await input.setInputFiles(files);
};
const dragEnvelopeItemByHandle = async ({
root,
sourceIndex,
targetIndex,
}: {
root: Page;
sourceIndex: number;
targetIndex: number;
}) => {
const sourceHandle = getEnvelopeItemDragHandles(root).nth(sourceIndex);
const targetHandle = getEnvelopeItemDragHandles(root).nth(targetIndex);
await expect(sourceHandle).toBeVisible();
await expect(targetHandle).toBeVisible();
const sourceBox = await sourceHandle.boundingBox();
const targetBox = await targetHandle.boundingBox();
if (!sourceBox || !targetBox) {
throw new Error('Could not resolve drag handle bounding boxes');
}
const sourceX = sourceBox.x + sourceBox.width / 2;
const sourceY = sourceBox.y + sourceBox.height / 2;
const targetX = targetBox.x + targetBox.width / 2;
const targetY = targetBox.y + targetBox.height / 2;
await root.mouse.move(sourceX, sourceY);
await root.mouse.down();
await root.mouse.move(targetX, targetY, { steps: 20 });
await root.mouse.up();
};
const runEnvelopeItemCrudFlow = async ({
root,
isEmbedded,
initialCount,
filesToUpload,
}: TEnvelopeEditorSurface & {
initialCount: number;
filesToUpload: TestFilePayload[];
}) => {
await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible();
await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(initialCount);
await uploadFiles(root, filesToUpload);
const expectedCountAfterUpload = initialCount + filesToUpload.length;
await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(expectedCountAfterUpload);
await getEnvelopeItemTitleInputs(root).nth(0).fill('Envelope Item A');
await getEnvelopeItemTitleInputs(root).nth(1).fill('Envelope Item B');
await expect(getEnvelopeItemTitleInputs(root).nth(0)).toHaveValue('Envelope Item A');
await expect(getEnvelopeItemTitleInputs(root).nth(1)).toHaveValue('Envelope Item B');
await dragEnvelopeItemByHandle({
root,
sourceIndex: 0,
targetIndex: 1,
});
await expect
.poll(async () => await getCurrentTitles(root))
.toEqual(['Envelope Item B', 'Envelope Item A']);
await getEnvelopeItemRemoveButtons(root).first().click();
if (!isEmbedded) {
await root.getByRole('button', { name: 'Delete' }).click();
}
await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(expectedCountAfterUpload - 1);
};
test.describe('Envelope Editor V2 - Envelope item CRUD', () => {
test('documents/<id>: add, remove, reorder and retitle items', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
await runEnvelopeItemCrudFlow({
...surface,
initialCount: 1,
filesToUpload: [createPdfPayload('document-item-added.pdf')],
});
});
test('templates/<id>: add, remove, reorder and retitle items', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
await runEnvelopeItemCrudFlow({
...surface,
initialCount: 1,
filesToUpload: [createPdfPayload('template-item-added.pdf')],
});
});
test('/embed/v2/authoring/envelope/create DOCUMENT: add, remove, reorder and retitle items', async ({
page,
}) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
});
await runEnvelopeItemCrudFlow({
...surface,
initialCount: 0,
filesToUpload: [
createPdfPayload('embedded-document-item-a.pdf'),
createPdfPayload('embedded-document-item-b.pdf'),
],
});
});
test('/embed/v2/authoring/envelope/edit/<id> TEMPLATE: add, remove, reorder and retitle items', async ({
page,
}) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-items',
});
await runEnvelopeItemCrudFlow({
...surface,
initialCount: 1,
filesToUpload: [createPdfPayload('embedded-template-item-updated.pdf')],
});
});
});
@@ -0,0 +1,279 @@
import { type Page, expect, test } from '@playwright/test';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
assertRecipientRole,
clickAddMyselfButton,
clickAddSignerButton,
clickEnvelopeEditorStep,
getEnvelopeEditorSettingsTrigger,
getRecipientEmailInputs,
getRecipientNameInputs,
getRecipientRemoveButtons,
getSigningOrderInputs,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
setRecipientEmail,
setRecipientName,
setRecipientRole,
setSigningOrderValue,
toggleAllowDictateSigners,
toggleSigningOrder,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
type RecipientFlowResult = {
externalId: string;
expectedRecipientsBySigningOrder: Array<{
email: string;
name: string;
role: RecipientRole;
signingOrder: number;
}>;
removedRecipientEmail: string;
};
const TEST_RECIPIENT_VALUES = {
secondRecipient: {
email: 'recipient-two@example.com',
name: 'Recipient Two',
},
thirdRecipient: {
email: 'recipient-three@example.com',
name: 'Recipient Three',
},
embeddedPrimaryRecipient: {
email: 'embedded-primary@example.com',
name: 'Embedded Primary',
},
};
const openSettingsDialog = async (root: Page) => {
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
};
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
await openSettingsDialog(surface.root);
await surface.root.locator('input[name="externalId"]').fill(externalId);
await surface.root.getByRole('button', { name: 'Update' }).click();
if (!surface.isEmbedded) {
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
}
};
const navigateToAddFieldsAndBack = async (root: Page) => {
await clickEnvelopeEditorStep(root, 'addFields');
await expect(root.getByText('Selected Recipient')).toBeVisible();
await clickEnvelopeEditorStep(root, 'upload');
await expect(root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
};
const runRecipientFlow = async (surface: TEnvelopeEditorSurface): Promise<RecipientFlowResult> => {
const externalId = `e2e-recipients-${nanoid()}`;
await updateExternalId(surface, externalId);
let primaryRecipient = TEST_RECIPIENT_VALUES.embeddedPrimaryRecipient;
if (surface.isEmbedded) {
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toHaveCount(0);
await setRecipientEmail(surface.root, 0, primaryRecipient.email);
await setRecipientName(surface.root, 0, primaryRecipient.name);
} else {
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toBeVisible();
await clickAddMyselfButton(surface.root);
primaryRecipient = {
email: surface.userEmail,
name: surface.userName,
};
await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(surface.userEmail);
}
await clickAddSignerButton(surface.root);
await clickAddSignerButton(surface.root);
await setRecipientEmail(surface.root, 1, TEST_RECIPIENT_VALUES.secondRecipient.email);
await setRecipientName(surface.root, 1, TEST_RECIPIENT_VALUES.secondRecipient.name);
await setRecipientEmail(surface.root, 2, TEST_RECIPIENT_VALUES.thirdRecipient.email);
await setRecipientName(surface.root, 2, TEST_RECIPIENT_VALUES.thirdRecipient.name);
await setRecipientRole(surface.root, 1, 'Needs to approve');
await setRecipientRole(surface.root, 2, 'Receives copy');
await getRecipientRemoveButtons(surface.root).nth(2).click();
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
await toggleSigningOrder(surface.root, true);
await expect(getSigningOrderInputs(surface.root)).toHaveCount(2);
await setSigningOrderValue(surface.root, 0, 2);
await toggleAllowDictateSigners(surface.root, true);
await navigateToAddFieldsAndBack(surface.root);
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(
TEST_RECIPIENT_VALUES.secondRecipient.email,
);
await expect(getRecipientEmailInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.email);
await expect(getRecipientNameInputs(surface.root).nth(0)).toHaveValue(
TEST_RECIPIENT_VALUES.secondRecipient.name,
);
await expect(getRecipientNameInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.name);
await assertRecipientRole(surface.root, 0, 'Needs to approve');
await assertRecipientRole(surface.root, 1, 'Needs to sign');
await expect(surface.root.locator('#signingOrder')).toHaveAttribute('aria-checked', 'true');
await expect(surface.root.locator('#allowDictateNextSigner')).toHaveAttribute(
'aria-checked',
'true',
);
await expect(getSigningOrderInputs(surface.root).nth(0)).toHaveValue('1');
await expect(getSigningOrderInputs(surface.root).nth(1)).toHaveValue('2');
return {
externalId,
removedRecipientEmail: TEST_RECIPIENT_VALUES.thirdRecipient.email,
expectedRecipientsBySigningOrder: [
{
email: TEST_RECIPIENT_VALUES.secondRecipient.email,
name: TEST_RECIPIENT_VALUES.secondRecipient.name,
role: RecipientRole.APPROVER,
signingOrder: 1,
},
{
email: primaryRecipient.email,
name: primaryRecipient.name,
role: RecipientRole.SIGNER,
signingOrder: 2,
},
],
};
};
const assertRecipientsPersistedInDatabase = async ({
surface,
externalId,
expectedRecipientsBySigningOrder,
removedRecipientEmail,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
expectedRecipientsBySigningOrder: RecipientFlowResult['expectedRecipientsBySigningOrder'];
removedRecipientEmail: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
include: {
documentMeta: true,
recipients: {
orderBy: {
signingOrder: 'asc',
},
},
},
orderBy: {
createdAt: 'desc',
},
});
expect(envelope.recipients).toHaveLength(expectedRecipientsBySigningOrder.length);
expect(envelope.documentMeta.signingOrder).toBe(DocumentSigningOrder.SEQUENTIAL);
expect(envelope.documentMeta.allowDictateNextSigner).toBe(true);
expectedRecipientsBySigningOrder.forEach((expectedRecipient, index) => {
const recipient = envelope.recipients[index];
expect(recipient.email).toBe(expectedRecipient.email);
expect(recipient.name).toBe(expectedRecipient.name);
expect(recipient.role).toBe(expectedRecipient.role);
expect(recipient.signingOrder).toBe(expectedRecipient.signingOrder);
});
expect(envelope.recipients.some((recipient) => recipient.email === removedRecipientEmail)).toBe(
false,
);
};
test.describe('Envelope Editor V2 - Recipients', () => {
test('documents/<id>: add myself, CRUD, roles, signing order and dictate signers', async ({
page,
}) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runRecipientFlow(surface);
await assertRecipientsPersistedInDatabase({
surface,
...result,
});
});
test('templates/<id>: add myself, CRUD, roles, signing order and dictate signers', async ({
page,
}) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runRecipientFlow(surface);
await assertRecipientsPersistedInDatabase({
surface,
...result,
});
});
test('/embed/v2/authoring/envelope/create DOCUMENT: recipients settings persist after create', async ({
page,
}) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-recipients',
});
await addEnvelopeItemPdf(surface.root, 'embedded-document-recipients.pdf');
const result = await runRecipientFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertRecipientsPersistedInDatabase({
surface,
...result,
});
});
test('/embed/v2/authoring/envelope/edit/<id> TEMPLATE: recipients settings persist after update', async ({
page,
}) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-recipients',
});
const result = await runRecipientFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertRecipientsPersistedInDatabase({
surface,
...result,
});
});
});
@@ -0,0 +1,359 @@
import { type Page, expect, test } from '@playwright/test';
import { DocumentDistributionMethod, DocumentVisibility } from '@prisma/client';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface,
getEnvelopeEditorSettingsTrigger,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
type SettingsFlowData = {
externalId: string;
isEmbedded: boolean;
};
const TEST_SETTINGS_VALUES = {
replyTo: 'e2e-settings@example.com',
redirectUrl: 'https://example.com/e2e-settings-complete',
subject: 'E2E settings subject',
message: 'E2E settings message',
language: 'French',
dateFormat: 'DD/MM/YYYY',
timezone: 'Europe/London',
distributionMethod: 'None',
accessAuth: 'Require account',
actionAuth: 'Require password',
visibility: 'Managers and above',
};
const DB_EXPECTED_VALUES = {
language: 'fr',
dateFormat: 'dd/MM/yyyy',
timezone: 'Europe/London',
distributionMethod: DocumentDistributionMethod.NONE,
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
globalAccessAuth: ['ACCOUNT'],
globalActionAuth: ['PASSWORD'],
emailSettings: {
recipientSigned: false,
recipientSigningRequest: false,
recipientRemoved: false,
documentPending: false,
documentCompleted: false,
documentDeleted: false,
ownerDocumentCompleted: false,
},
};
const openSettingsDialog = async (root: Page) => {
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
};
const clickSettingsDialogHeader = async (root: Page) => {
await root.locator('[data-testid="envelope-editor-settings-dialog-header"]').click();
};
const getComboboxByLabel = (root: Page, label: string) =>
root
.locator(`label:has-text("${label}")`)
.locator('xpath=..')
.locator('[role="combobox"]')
.first();
const selectMultiSelectOption = async (
root: Page,
dataTestId: 'documentAccessSelectValue' | 'documentActionSelectValue',
optionLabel: string,
) => {
const select = root.locator(`[data-testid="${dataTestId}"]`);
await select.click();
await root.locator('[cmdk-item]').filter({ hasText: optionLabel }).first().click();
await clickSettingsDialogHeader(root);
};
const runSettingsFlow = async (
{ root }: TEnvelopeEditorSurface,
{ externalId, isEmbedded }: SettingsFlowData,
) => {
await openSettingsDialog(root);
await getComboboxByLabel(root, 'Language').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.language }).click();
await clickSettingsDialogHeader(root);
const signatureTypesCombobox = getComboboxByLabel(root, 'Allowed Signature Types');
await signatureTypesCombobox.click();
await root.getByRole('option', { name: 'Upload' }).click();
await clickSettingsDialogHeader(root);
await getComboboxByLabel(root, 'Date Format').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.dateFormat, exact: true }).click();
await clickSettingsDialogHeader(root);
await getComboboxByLabel(root, 'Time Zone').click();
await root.locator('[cmdk-input]').last().fill(TEST_SETTINGS_VALUES.timezone);
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.timezone }).click();
await clickSettingsDialogHeader(root);
await root.locator('input[name="externalId"]').fill(externalId);
await root.locator('input[name="meta.redirectUrl"]').fill(TEST_SETTINGS_VALUES.redirectUrl);
await root.locator('[data-testid="documentDistributionMethodSelectValue"]').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.distributionMethod }).click();
await clickSettingsDialogHeader(root);
await root.getByRole('button', { name: 'Email' }).click();
await root.locator('#recipientSigned').click();
await root.locator('#recipientSigningRequest').click();
await root.locator('#recipientRemoved').click();
await root.locator('#documentPending').click();
await root.locator('#documentCompleted').click();
await root.locator('#documentDeleted').click();
await root.locator('#ownerDocumentCompleted').click();
await root.locator('input[name="meta.emailReplyTo"]').fill(TEST_SETTINGS_VALUES.replyTo);
await root.locator('input[name="meta.subject"]').fill(TEST_SETTINGS_VALUES.subject);
await root.locator('textarea[name="meta.message"]').fill(TEST_SETTINGS_VALUES.message);
await root.getByRole('button', { name: 'Security' }).click();
await selectMultiSelectOption(root, 'documentAccessSelectValue', TEST_SETTINGS_VALUES.accessAuth);
const actionAuthSelect = root.locator('[data-testid="documentActionSelectValue"]');
const hasActionAuthSelect = (await actionAuthSelect.count()) > 0;
if (hasActionAuthSelect) {
await selectMultiSelectOption(
root,
'documentActionSelectValue',
TEST_SETTINGS_VALUES.actionAuth,
);
}
await root.locator('[data-testid="documentVisibilitySelectValue"]').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.visibility }).click();
await clickSettingsDialogHeader(root);
await root.getByRole('button', { name: 'Update' }).click();
if (!isEmbedded) {
await expectToastTextToBeVisible(root, 'Envelope updated');
}
await openSettingsDialog(root);
await expect(root.locator('input[name="externalId"]')).toHaveValue(externalId);
await expect(root.locator('input[name="meta.redirectUrl"]')).toHaveValue(
TEST_SETTINGS_VALUES.redirectUrl,
);
await expect(getComboboxByLabel(root, 'Language')).toContainText(TEST_SETTINGS_VALUES.language);
await expect(getComboboxByLabel(root, 'Allowed Signature Types')).not.toContainText('Upload');
await expect(getComboboxByLabel(root, 'Date Format')).toContainText(
TEST_SETTINGS_VALUES.dateFormat,
);
await expect(getComboboxByLabel(root, 'Time Zone')).toContainText(TEST_SETTINGS_VALUES.timezone);
await expect(root.locator('[data-testid="documentDistributionMethodSelectValue"]')).toContainText(
TEST_SETTINGS_VALUES.distributionMethod,
);
await root.getByRole('button', { name: 'Email' }).click();
await expect(root.locator('#recipientSigned')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#recipientSigningRequest')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#recipientRemoved')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#documentPending')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#documentCompleted')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#documentDeleted')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#ownerDocumentCompleted')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('input[name="meta.emailReplyTo"]')).toHaveValue(
TEST_SETTINGS_VALUES.replyTo,
);
await expect(root.locator('input[name="meta.subject"]')).toHaveValue(
TEST_SETTINGS_VALUES.subject,
);
await expect(root.locator('textarea[name="meta.message"]')).toHaveValue(
TEST_SETTINGS_VALUES.message,
);
await root.getByRole('button', { name: 'Security' }).click();
await expect(root.locator('[data-testid="documentAccessSelectValue"]')).toContainText(
TEST_SETTINGS_VALUES.accessAuth,
);
if (hasActionAuthSelect) {
await expect(root.locator('[data-testid="documentActionSelectValue"]')).toContainText(
TEST_SETTINGS_VALUES.actionAuth,
);
}
await expect(root.locator('[data-testid="documentVisibilitySelectValue"]')).toContainText(
TEST_SETTINGS_VALUES.visibility,
);
await root.getByRole('button', { name: 'Update' }).click();
if (!isEmbedded) {
await expectToastTextToBeVisible(root, 'Envelope updated');
}
return {
hasActionAuthSelect,
};
};
const assertEnvelopeSettingsPersistedInDatabase = async ({
externalId,
surface,
hasActionAuthSelect,
}: {
externalId: string;
surface: TEnvelopeEditorSurface;
hasActionAuthSelect: boolean;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
orderBy: { createdAt: 'desc' },
include: {
documentMeta: true,
},
});
expect(envelope.externalId).toBe(externalId);
expect(envelope.visibility).toBe(DB_EXPECTED_VALUES.visibility);
expect(envelope.documentMeta.language).toBe(DB_EXPECTED_VALUES.language);
expect(envelope.documentMeta.dateFormat).toBe(DB_EXPECTED_VALUES.dateFormat);
expect(envelope.documentMeta.timezone).toBe(DB_EXPECTED_VALUES.timezone);
expect(envelope.documentMeta.distributionMethod).toBe(DB_EXPECTED_VALUES.distributionMethod);
expect(envelope.documentMeta.redirectUrl).toBe(TEST_SETTINGS_VALUES.redirectUrl);
expect(envelope.documentMeta.emailReplyTo).toBe(TEST_SETTINGS_VALUES.replyTo);
expect(envelope.documentMeta.subject).toBe(TEST_SETTINGS_VALUES.subject);
expect(envelope.documentMeta.message).toBe(TEST_SETTINGS_VALUES.message);
expect(envelope.documentMeta.drawSignatureEnabled).toBe(true);
expect(envelope.documentMeta.typedSignatureEnabled).toBe(true);
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(false);
expect(envelope.documentMeta.emailSettings).toMatchObject(DB_EXPECTED_VALUES.emailSettings);
const authOptions = parseAuthOptions(envelope.authOptions);
expect(authOptions.globalAccessAuth ?? []).toEqual(DB_EXPECTED_VALUES.globalAccessAuth);
if (hasActionAuthSelect) {
expect(authOptions.globalActionAuth ?? []).toEqual(DB_EXPECTED_VALUES.globalActionAuth);
}
};
const parseAuthOptions = (
authOptions: unknown,
): { globalAccessAuth: string[]; globalActionAuth: string[] } => {
if (!isRecord(authOptions)) {
return {
globalAccessAuth: [],
globalActionAuth: [],
};
}
return {
globalAccessAuth: Array.isArray(authOptions.globalAccessAuth)
? authOptions.globalAccessAuth.filter((entry): entry is string => typeof entry === 'string')
: [],
globalActionAuth: Array.isArray(authOptions.globalActionAuth)
? authOptions.globalActionAuth.filter((entry): entry is string => typeof entry === 'string')
: [],
};
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
test.describe('Envelope Editor V2 - Envelope settings dialog', () => {
test('documents/<id>: update and persist settings', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const externalId = `e2e-settings-${nanoid()}`;
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
externalId,
isEmbedded: false,
});
await assertEnvelopeSettingsPersistedInDatabase({
externalId,
surface,
hasActionAuthSelect,
});
});
test('templates/<id>: update and persist settings', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const externalId = `e2e-settings-${nanoid()}`;
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
externalId,
isEmbedded: false,
});
await assertEnvelopeSettingsPersistedInDatabase({
externalId,
surface,
hasActionAuthSelect,
});
});
test('/embed/v2/authoring/envelope/create DOCUMENT: update and persist settings', async ({
page,
}) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-settings',
});
const externalId = `e2e-settings-${nanoid()}`;
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
externalId,
isEmbedded: true,
});
await persistEmbeddedEnvelope(surface);
await assertEnvelopeSettingsPersistedInDatabase({
externalId,
surface,
hasActionAuthSelect,
});
});
test('/embed/v2/authoring/envelope/edit/<id> TEMPLATE: update and persist settings', async ({
page,
}) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-settings',
});
const externalId = `e2e-settings-${nanoid()}`;
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
externalId,
isEmbedded: true,
});
await persistEmbeddedEnvelope(surface);
await assertEnvelopeSettingsPersistedInDatabase({
externalId,
surface,
hasActionAuthSelect,
});
});
});
@@ -1,13 +1,12 @@
import { createCanvas } from '@napi-rs/canvas';
import type { TestInfo } from '@playwright/test'; import type { TestInfo } from '@playwright/test';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DocumentStatus, EnvelopeType } from '@prisma/client'; import { DocumentStatus, EnvelopeType } from '@prisma/client';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import pixelMatch from 'pixelmatch'; import pixelMatch from 'pixelmatch';
import { PNG } from 'pngjs'; import { PNG } from 'pngjs';
import { pdfToImages } from '@documenso/lib/server-only/ai/pdf-to-images';
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download'; import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed'; import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed';
@@ -378,39 +377,14 @@ test.skip('download envelope images', async ({ page }) => {
}); });
async function renderPdfToImage(pdfBytes: Uint8Array) { async function renderPdfToImage(pdfBytes: Uint8Array) {
const loadingTask = pdfjsLib.getDocument({ data: pdfBytes });
const pdf = await loadingTask.promise;
// Increase for higher resolution // Increase for higher resolution
const scale = 4; const scale = 4;
return await Promise.all( return (await pdfToImages(pdfBytes, { scale, imageFormat: 'png' })).map((image) => ({
Array.from({ length: pdf.numPages }, async (_, index) => { image: image.image,
const page = await pdf.getPage(index + 1); width: Math.floor(image.scaledWidth),
height: Math.floor(image.scaledHeight),
const viewport = page.getViewport({ scale }); }));
const canvas = createCanvas(viewport.width, viewport.height);
const canvasContext = canvas.getContext('2d');
canvasContext.imageSmoothingEnabled = false;
await page.render({
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
canvas,
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
canvasContext,
viewport,
}).promise;
return {
image: await canvas.encode('png'),
// Rounded down because the certificate page somehow gives dimensions with decimals
width: Math.floor(viewport.width),
height: Math.floor(viewport.height),
};
}),
);
} }
type CompareSignedPdfWithImagesOptions = { type CompareSignedPdfWithImagesOptions = {
@@ -0,0 +1,429 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { DEFAULT_EMBEDDED_EDITOR_CONFIG } from '@documenso/lib/types/envelope-editor';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from './authentication';
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
export type TEnvelopeEditorSurface = {
root: Page;
isEmbedded: boolean;
envelopeId?: string;
envelopeType: TEnvelopeEditorType;
userId: number;
userEmail: string;
userName: string;
teamId: number;
};
export type TEnvelopeEditorType = 'DOCUMENT' | 'TEMPLATE';
type TEmbeddedHashCommonOptions = {
externalId?: string;
features?: typeof DEFAULT_EMBEDDED_EDITOR_CONFIG;
css?: string;
cssVars?: Record<string, string>;
darkModeDisabled?: boolean;
};
const encodeEmbeddedOptions = (options: Record<string, unknown>) => {
const encodedPayload = encodeURIComponent(JSON.stringify(options));
if (typeof btoa === 'function') {
return btoa(encodedPayload);
}
return Buffer.from(encodedPayload, 'utf8').toString('base64');
};
export const createEmbeddedEnvelopeCreateHash = ({
envelopeType,
externalId,
features = DEFAULT_EMBEDDED_EDITOR_CONFIG,
css,
cssVars,
darkModeDisabled,
}: { envelopeType: TEnvelopeEditorType } & TEmbeddedHashCommonOptions) => {
return encodeEmbeddedOptions({
externalId,
type: envelopeType,
features,
css,
cssVars,
darkModeDisabled,
});
};
export const createEmbeddedEnvelopeEditHash = ({
externalId,
features = DEFAULT_EMBEDDED_EDITOR_CONFIG,
css,
cssVars,
darkModeDisabled,
}: TEmbeddedHashCommonOptions) => {
return encodeEmbeddedOptions({
externalId,
features,
css,
cssVars,
darkModeDisabled,
});
};
export const openDocumentEnvelopeEditor = async (page: Page): Promise<TEnvelopeEditorSurface> => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id, {
internalVersion: 2,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`,
});
return {
root: page,
isEmbedded: false,
envelopeId: document.id,
envelopeType: 'DOCUMENT',
userId: user.id,
userEmail: user.email,
userName: user.name ?? '',
teamId: team.id,
};
};
export const openTemplateEnvelopeEditor = async (page: Page): Promise<TEnvelopeEditorSurface> => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id, {
createTemplateOptions: {
title: `E2E Template ${Date.now()}`,
userId: user.id,
teamId: team.id,
internalVersion: 2,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit?step=uploadAndRecipients`,
});
return {
root: page,
isEmbedded: false,
envelopeId: template.id,
envelopeType: 'TEMPLATE',
userId: user.id,
userEmail: user.email,
userName: user.name ?? '',
teamId: team.id,
};
};
type OpenEmbeddedEnvelopeEditorOptions = {
envelopeType: TEnvelopeEditorType;
mode?: 'create' | 'edit';
tokenNamePrefix?: string;
externalId?: string;
features?: typeof DEFAULT_EMBEDDED_EDITOR_CONFIG;
css?: string;
cssVars?: Record<string, string>;
darkModeDisabled?: boolean;
};
export const openEmbeddedEnvelopeEditor = async (
page: Page,
{
envelopeType,
mode = 'create',
tokenNamePrefix = 'e2e-embed',
externalId,
features,
css,
cssVars,
darkModeDisabled,
}: OpenEmbeddedEnvelopeEditorOptions,
): Promise<TEnvelopeEditorSurface> => {
const { user, team } = await seedUser();
const envelopeToEdit =
mode === 'edit'
? envelopeType === 'DOCUMENT'
? await seedBlankDocument(user, team.id, {
internalVersion: 2,
})
: await seedBlankTemplate(user, team.id, {
createTemplateOptions: {
title: `E2E Template ${Date.now()}`,
userId: user.id,
teamId: team.id,
internalVersion: 2,
},
})
: null;
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: `${tokenNamePrefix}-${envelopeType.toLowerCase()}`,
expiresIn: null,
});
const embeddedToken = await resolveEmbeddingToken(
page,
token,
envelopeToEdit ? `envelopeId:${envelopeToEdit.id}` : undefined,
);
if (envelopeToEdit) {
const hash = createEmbeddedEnvelopeEditHash({
externalId,
features: features ?? DEFAULT_EMBEDDED_EDITOR_CONFIG,
css,
cssVars,
darkModeDisabled,
});
await page.goto(
`/embed/v2/authoring/envelope/edit/${envelopeToEdit.id}?token=${encodeURIComponent(embeddedToken)}#${hash}`,
);
} else {
const hash = createEmbeddedEnvelopeCreateHash({
envelopeType,
externalId,
features,
css,
cssVars,
darkModeDisabled,
});
await page.goto(
`/embed/v2/authoring/envelope/create?token=${encodeURIComponent(embeddedToken)}#${hash}`,
);
}
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
return {
root: page,
isEmbedded: true,
envelopeId: envelopeToEdit?.id,
envelopeType,
userId: user.id,
userEmail: user.email,
userName: user.name ?? '',
teamId: team.id,
};
};
export const getEnvelopeEditorSettingsTrigger = (root: Page) =>
root.locator('button[title="Settings"]');
export const getEnvelopeItemTitleInputs = (root: Page) =>
root.locator('[data-testid^="envelope-item-title-input-"]');
export const getEnvelopeItemDragHandles = (root: Page) =>
root.locator('[data-testid^="envelope-item-drag-handle-"]');
export const getEnvelopeItemRemoveButtons = (root: Page) =>
root.locator('[data-testid^="envelope-item-remove-button-"]');
export const getEnvelopeItemDropzoneInput = (root: Page) =>
root.locator('[data-testid="envelope-item-dropzone"] input[type="file"]');
export const addEnvelopeItemPdf = async (root: Page, fileName = 'embedded-envelope-item.pdf') => {
await getEnvelopeItemDropzoneInput(root).setInputFiles({
name: fileName,
mimeType: 'application/pdf',
buffer: examplePdfBuffer,
});
};
export const getRecipientEmailInputs = (root: Page) =>
root.locator('[data-testid="signer-email-input"]');
export const getRecipientNameInputs = (root: Page) =>
root.locator('input[placeholder^="Recipient "]');
export const getRecipientRows = (root: Page) =>
root.locator('[data-testid="signer-email-input"]').locator('xpath=ancestor::fieldset[1]');
export const getRecipientRemoveButtons = (root: Page) =>
root.locator('[data-testid="remove-signer-button"]');
export const getSigningOrderInputs = (root: Page) =>
root.locator('[data-testid="signing-order-input"]');
export const clickEnvelopeEditorStep = async (
root: Page,
stepId: 'upload' | 'addFields' | 'preview',
) => {
await root.waitForTimeout(200);
await root.locator(`[data-testid="envelope-editor-step-${stepId}"]`).first().click();
};
export const clickAddMyselfButton = async (root: Page) => {
await root.getByRole('button', { name: 'Add Myself' }).click();
};
export const clickAddSignerButton = async (root: Page) => {
await root.getByRole('button', { name: 'Add Signer' }).click();
};
export const setRecipientEmail = async (root: Page, index: number, email: string) => {
await getRecipientEmailInputs(root).nth(index).fill(email);
};
export const setRecipientName = async (root: Page, index: number, name: string) => {
await getRecipientNameInputs(root).nth(index).fill(name);
};
export const setRecipientRole = async (
root: Page,
index: number,
roleLabel:
| 'Needs to sign'
| 'Needs to approve'
| 'Needs to view'
| 'Receives copy'
| 'Can prepare',
) => {
const row = getRecipientRows(root).nth(index);
await row.locator('button[role="combobox"]').first().click();
await root.getByRole('option', { name: roleLabel }).click();
};
export const assertRecipientRole = async (
root: Page,
index: number,
roleLabel:
| 'Needs to sign'
| 'Needs to approve'
| 'Needs to view'
| 'Receives copy'
| 'Can prepare',
) => {
const row = getRecipientRows(root).nth(index);
const roleValueByLabel: Record<typeof roleLabel, string> = {
'Needs to sign': 'SIGNER',
'Needs to approve': 'APPROVER',
'Needs to view': 'VIEWER',
'Receives copy': 'CC',
'Can prepare': 'ASSISTANT',
};
await expect(row.locator('button[role="combobox"]').first()).toHaveAttribute(
'title',
roleValueByLabel[roleLabel],
);
};
export const toggleSigningOrder = async (root: Page, enabled: boolean) => {
const checkbox = root.locator('#signingOrder');
const currentState = await checkbox.getAttribute('aria-checked');
const isEnabled = currentState === 'true';
if (isEnabled !== enabled) {
await checkbox.click();
}
};
export const toggleAllowDictateSigners = async (root: Page, enabled: boolean) => {
const checkbox = root.locator('#allowDictateNextSigner');
const currentState = await checkbox.getAttribute('aria-checked');
const isEnabled = currentState === 'true';
if (isEnabled !== enabled) {
await checkbox.click();
}
};
export const setSigningOrderValue = async (root: Page, index: number, value: number) => {
const input = getSigningOrderInputs(root).nth(index);
await input.fill(value.toString());
await input.blur();
};
export const persistEmbeddedEnvelope = async (surface: TEnvelopeEditorSurface) => {
if (!surface.isEmbedded) {
return;
}
const isUpdateFlow =
(await surface.root.getByRole('button', { name: 'Update Document' }).count()) > 0 ||
(await surface.root.getByRole('button', { name: 'Update Template' }).count()) > 0;
const actionButtonName = isUpdateFlow
? surface.envelopeType === 'DOCUMENT'
? 'Update Document'
: 'Update Template'
: surface.envelopeType === 'DOCUMENT'
? 'Create Document'
: 'Create Template';
await surface.root.getByRole('button', { name: actionButtonName }).click();
const completionHeading = isUpdateFlow
? surface.envelopeType === 'DOCUMENT'
? 'Document Updated'
: 'Template Updated'
: surface.envelopeType === 'DOCUMENT'
? 'Document Created'
: 'Template Created';
await expect(surface.root.getByRole('heading', { name: completionHeading })).toBeVisible();
};
const resolveEmbeddingToken = async (
page: Page,
inputToken: string,
scope?: string,
): Promise<string> => {
if (!inputToken.startsWith('api_')) {
return inputToken;
}
const response = await page
.context()
.request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2/embedding/create-presign-token`, {
headers: {
Authorization: `Bearer ${inputToken}`,
'Content-Type': 'application/json',
},
data: scope ? { scope } : {},
});
if (!response.ok()) {
const text = await response.text();
throw new Error(`Failed to exchange API token (${response.status()}): ${text}`);
}
const data: unknown = await response.json();
if (typeof data !== 'object' || data === null || !('token' in data)) {
throw new Error(`Unexpected response shape: ${JSON.stringify(data)}`);
}
const token = data.token;
if (typeof token !== 'string' || token.length === 0) {
throw new Error(`Unexpected response shape: ${JSON.stringify(data)}`);
}
return token;
};
@@ -42,21 +42,21 @@ const completeTemplateFlowWithDuplicateRecipients = async (options: {
// Step 3: Add fields for each recipient instance // Step 3: Add fields for each recipient instance
// Add signature field for first instance // Add signature field for first instance
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } }); await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
// Switch to second instance and add their field // Switch to second instance and add their field
await page.getByRole('combobox').first().click(); await page.getByRole('combobox').first().click();
await page.getByText('Second Instance').first().click(); await page.getByText('Second Instance').first().click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } }); await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
// Switch to different recipient and add their fields // Switch to different recipient and add their fields
await page.getByRole('combobox').first().click(); await page.getByRole('combobox').first().click();
await page.getByText('Different Recipient').first().click(); await page.getByText('Different Recipient').first().click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 300, y: 100 } }); await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 100 } });
await page.getByRole('button', { name: 'Name' }).click(); await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 300, y: 150 } }); await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 150 } });
// Save template // Save template
await page.getByRole('button', { name: 'Save Template' }).click(); await page.getByRole('button', { name: 'Save Template' }).click();
@@ -209,17 +209,17 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
// Add fields for each recipient // Add fields for each recipient
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } }); await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
await page.getByRole('combobox').first().click(); await page.getByRole('combobox').first().click();
await page.getByText('Duplicate Recipient 2').first().click(); await page.getByText('Duplicate Recipient 2').first().click();
await page.getByRole('button', { name: 'Date' }).click(); await page.getByRole('button', { name: 'Date' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } }); await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
await page.getByRole('combobox').first().click(); await page.getByRole('combobox').first().click();
await page.getByText('Different Recipient').first().click(); await page.getByText('Different Recipient').first().click();
await page.getByRole('button', { name: 'Name' }).click(); await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 200 } }); await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 200 } });
// Save template // Save template
await page.getByRole('button', { name: 'Save Template' }).click(); await page.getByRole('button', { name: 'Save Template' }).click();
@@ -272,7 +272,7 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
await page.getByRole('combobox').first().click(); await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'First Instance' }).first().click(); await page.getByRole('option', { name: 'First Instance' }).first().click();
await page.getByRole('button', { name: 'Name' }).click(); await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 300 } }); await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 300 } });
await page.waitForTimeout(2500); await page.waitForTimeout(2500);
@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
@@ -47,7 +48,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 100, y: 100,
@@ -55,7 +56,7 @@ test.describe('AutoSave Fields Step', () => {
}); });
await page.getByRole('button', { name: 'Text' }).click(); await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 200, y: 200,
@@ -75,7 +76,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 500, y: 500,
@@ -110,7 +111,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 100, y: 100,
@@ -118,7 +119,7 @@ test.describe('AutoSave Fields Step', () => {
}); });
await page.getByRole('button', { name: 'Text' }).click(); await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 200, y: 200,
@@ -138,7 +139,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 500, y: 500,
@@ -179,7 +180,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 100, y: 100,
@@ -187,7 +188,7 @@ test.describe('AutoSave Fields Step', () => {
}); });
await page.getByRole('button', { name: 'Text' }).click(); await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 200, y: 200,
@@ -207,7 +208,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 500, y: 500,
@@ -250,7 +251,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 100, y: 100,
@@ -258,7 +259,7 @@ test.describe('AutoSave Fields Step', () => {
}); });
await page.getByRole('button', { name: 'Text' }).click(); await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 200, y: 200,
@@ -22,6 +22,12 @@ export const useLimits = () => {
export type LimitsProviderProps = { export type LimitsProviderProps = {
initialValue?: TLimitsResponseSchema; initialValue?: TLimitsResponseSchema;
/**
* Bypass limits for embed authoring. This is just client side bypass since
* all embeds should be paid plans.
*/
bypassLimits?: boolean;
teamId: number; teamId: number;
children?: React.ReactNode; children?: React.ReactNode;
}; };
@@ -32,12 +38,17 @@ export const LimitsProvider = ({
remaining: FREE_PLAN_LIMITS, remaining: FREE_PLAN_LIMITS,
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT, maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
}, },
bypassLimits,
teamId, teamId,
children, children,
}: LimitsProviderProps) => { }: LimitsProviderProps) => {
const [limits, setLimits] = useState(() => initialValue); const [limits, setLimits] = useState(() => initialValue);
const refreshLimits = useCallback(async () => { const refreshLimits = useCallback(async () => {
if (bypassLimits) {
return;
}
const newLimits = await getLimits({ teamId }); const newLimits = await getLimits({ teamId });
setLimits((oldLimits) => { setLimits((oldLimits) => {
@@ -54,6 +65,10 @@ export const LimitsProvider = ({
}, [refreshLimits]); }, [refreshLimits]);
useEffect(() => { useEffect(() => {
if (bypassLimits) {
return;
}
const onFocus = () => { const onFocus = () => {
void refreshLimits(); void refreshLimits();
}; };
@@ -1,4 +1,4 @@
export const getBoundingClientRect = (element: HTMLElement) => { export const getBoundingClientRect = (element: HTMLElement | Element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
const { width, height } = rect; const { width, height } = rect;
@@ -14,7 +14,10 @@ export const useDocumentElement = () => {
const target = event.target; const target = event.target;
const $page = const $page =
target.closest<HTMLElement>(pageSelector) ?? target.querySelector<HTMLElement>(pageSelector); target.closest<HTMLElement>(pageSelector) ??
document
.elementsFromPoint(event.clientX, event.clientY)
.find((el) => el.matches(pageSelector));
if (!$page) { if (!$page) {
return null; return null;
@@ -6,11 +6,10 @@ import { FieldType } from '@prisma/client';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import type { TEnvelope } from '../../types/envelope';
export const ZLocalFieldSchema = z.object({ export const ZLocalFieldSchema = z.object({
// This is the actual ID of the field if created. // This is the actual ID of the field if created.
id: z.number().optional(), id: z.number().optional(),
@@ -37,7 +36,7 @@ const ZEditorFieldsFormSchema = z.object({
export type TEditorFieldsFormSchema = z.infer<typeof ZEditorFieldsFormSchema>; export type TEditorFieldsFormSchema = z.infer<typeof ZEditorFieldsFormSchema>;
type EditorFieldsProps = { type EditorFieldsProps = {
envelope: TEnvelope; envelope: TEditorEnvelope;
handleFieldsUpdate: (fields: TLocalField[]) => unknown; handleFieldsUpdate: (fields: TLocalField[]) => unknown;
}; };
@@ -11,10 +11,9 @@ import {
ZRecipientActionAuthTypesSchema, ZRecipientActionAuthTypesSchema,
ZRecipientAuthOptionsSchema, ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth'; } from '@documenso/lib/types/document-auth';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient'; import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import type { TEnvelope } from '../../types/envelope';
const LocalRecipientSchema = z.object({ const LocalRecipientSchema = z.object({
formId: z.string().min(1), formId: z.string().min(1),
id: z.number().optional(), id: z.number().optional(),
@@ -36,12 +35,12 @@ export const ZEditorRecipientsFormSchema = z.object({
export type TEditorRecipientsFormSchema = z.infer<typeof ZEditorRecipientsFormSchema>; export type TEditorRecipientsFormSchema = z.infer<typeof ZEditorRecipientsFormSchema>;
type EditorRecipientsProps = { type EditorRecipientsProps = {
envelope: TEnvelope; envelope: TEditorEnvelope;
}; };
type ResetFormOptions = { type ResetFormOptions = {
recipients?: Recipient[]; recipients?: Recipient[];
documentMeta?: TEnvelope['documentMeta']; documentMeta?: TEditorEnvelope['documentMeta'];
}; };
type UseEditorRecipientsResponse = { type UseEditorRecipientsResponse = {
@@ -1,46 +1,45 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import Konva from 'konva'; import Konva from 'konva';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf'; import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer';
import { EAGER_LOAD_PAGE_COUNT, type PageRenderData } from '../providers/envelope-render-provider';
type RenderFunction = (props: { stage: Konva.Stage; pageLayer: Konva.Layer }) => void; type RenderFunction = (props: { stage: Konva.Stage; pageLayer: Konva.Layer }) => void;
export function usePageRenderer(renderFunction: RenderFunction) { export function usePageRenderer(renderFunction: RenderFunction, pageData: PageRenderData) {
const pageContext = usePageContext(); const { pageWidth, pageHeight, scale, imageUrl, pageNumber } = pageData;
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const canvasElement = useRef<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null); const konvaContainer = useRef<HTMLDivElement>(null);
const stage = useRef<Konva.Stage | null>(null); const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Konva.Layer | null>(null); const pageLayer = useRef<Konva.Layer | null>(null);
const [renderError, setRenderError] = useState<boolean>(false); const [renderStatus, setRenderStatus] = useState<'loading' | 'loaded' | 'error'>('loading');
/** /**
* The raw viewport with no scaling. Basically the actual PDF size. * The raw viewport with no scaling. Basically the actual PDF size.
*/ */
const unscaledViewport = useMemo( const unscaledViewport = useMemo(
() => page.getViewport({ scale: 1, rotation: rotate }), () => ({
[page, rotate, scale], scale: 1,
width: pageWidth,
height: pageHeight,
}),
[pageWidth, pageHeight],
); );
/** /**
* The viewport scaled according to page width. * The viewport scaled according to page width.
*/ */
const scaledViewport = useMemo( const scaledViewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }), () => ({
[page, rotate, scale], scale,
width: pageWidth * scale,
height: pageHeight * scale,
}),
[pageWidth, pageHeight, scale],
); );
/** /**
@@ -48,88 +47,77 @@ export function usePageRenderer(renderFunction: RenderFunction) {
* in a higher resolution. * in a higher resolution.
*/ */
const renderViewport = useMemo( const renderViewport = useMemo(
() => page.getViewport({ scale: scale * window.devicePixelRatio, rotation: rotate }), () => ({
[page, rotate, scale], scale: scale * window.devicePixelRatio,
width: pageWidth * scale * window.devicePixelRatio,
height: pageHeight * scale * window.devicePixelRatio,
}),
[pageWidth, pageHeight, scale],
); );
/** /**
* Render the PDF and create the scaled Konva stage. * The props for the image element which will render the page.
*/ */
useEffect( const imageProps = useMemo(
function drawPageOnCanvas() { (): React.ImgHTMLAttributes<HTMLImageElement> & Record<string, unknown> & { alt: '' } => ({
if (!page) { className: PDF_VIEWER_PAGE_CLASSNAME,
return; width: `${Math.floor(scaledViewport.width)}px`,
} height: `${Math.floor(scaledViewport.height)}px`,
alt: '',
const { current: canvas } = canvasElement; onLoad: () => setRenderStatus('loaded'),
const { current: kContainer } = konvaContainer; // Purposely not using lazy here since we can use the virtual list overscan to let us prerender images.
loading: pageNumber < EAGER_LOAD_PAGE_COUNT ? 'eager' : undefined,
if (!canvas || !kContainer) { src: imageUrl,
return; 'data-page-number': pageNumber,
} }),
[renderViewport, scaledViewport],
canvas.width = renderViewport.width;
canvas.height = renderViewport.height;
canvas.style.width = `${Math.floor(scaledViewport.width)}px`;
canvas.style.height = `${Math.floor(scaledViewport.height)}px`;
const renderContext: RenderParameters = {
canvas,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport: renderViewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
stage.current = new Konva.Stage({
container: kContainer,
width: scaledViewport.width,
height: scaledViewport.height,
scale: {
x: scale,
y: scale,
},
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current.add(pageLayer.current);
renderFunction({
stage: stage.current,
pageLayer: pageLayer.current,
});
void document.fonts.ready.then(function () {
pageLayer.current?.batchDraw();
});
});
return () => {
runningTask.cancel();
};
},
[page, scaledViewport],
); );
useEffect(() => {
const { current: container } = konvaContainer;
if (renderStatus !== 'loaded' || !container) {
return;
}
stage.current = new Konva.Stage({
container,
width: scaledViewport.width,
height: scaledViewport.height,
scale: {
x: scale,
y: scale,
},
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current.add(pageLayer.current);
renderFunction({
stage: stage.current,
pageLayer: pageLayer.current,
});
void document.fonts.ready.then(function () {
pageLayer.current?.batchDraw();
});
return () => {
stage.current?.destroy();
stage.current = null;
};
}, [renderStatus, imageProps]);
return { return {
canvasElement,
konvaContainer, konvaContainer,
imageProps,
stage, stage,
pageLayer, pageLayer,
unscaledViewport, unscaledViewport,
scaledViewport, scaledViewport,
pageContext, renderStatus,
renderError, setRenderStatus,
setRenderError,
}; };
} }
@@ -1,9 +1,17 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'; import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client'; import { EnvelopeType, Prisma, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import { useSearchParams } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import {
DEFAULT_EDITOR_CONFIG,
type EnvelopeEditorConfig,
type TEditorEnvelope,
} from '@documenso/lib/types/envelope-editor';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TSetEnvelopeFieldsResponse } from '@documenso/trpc/server/envelope-router/set-envelope-fields.types';
import type { TSetEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types'; import type { TSetEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types'; import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors'; import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
@@ -11,7 +19,6 @@ import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import type { TDocumentEmailSettings } from '../../types/document-email'; import type { TDocumentEmailSettings } from '../../types/document-email';
import type { TEnvelope } from '../../types/envelope';
import { formatDocumentsPath, formatTemplatesPath } from '../../utils/teams'; import { formatDocumentsPath, formatTemplatesPath } from '../../utils/teams';
import { useEditorFields } from '../hooks/use-editor-fields'; import { useEditorFields } from '../hooks/use-editor-fields';
import type { TLocalField } from '../hooks/use-editor-fields'; import type { TLocalField } from '../hooks/use-editor-fields';
@@ -38,14 +45,20 @@ export const useDebounceFunction = <Args extends unknown[]>(
); );
}; };
export type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
type UpdateEnvelopePayload = Pick<TUpdateEnvelopeRequest, 'data' | 'meta'>; type UpdateEnvelopePayload = Pick<TUpdateEnvelopeRequest, 'data' | 'meta'>;
type EnvelopeEditorProviderValue = { type EnvelopeEditorProviderValue = {
envelope: TEnvelope; editorConfig: EnvelopeEditorConfig;
envelope: TEditorEnvelope;
isEmbedded: boolean;
isDocument: boolean; isDocument: boolean;
isTemplate: boolean; isTemplate: boolean;
setLocalEnvelope: (localEnvelope: Partial<TEnvelope>) => void;
setLocalEnvelope: (localEnvelope: Partial<TEditorEnvelope>) => void;
updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void; updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
updateEnvelopeAsync: (envelopeUpdates: UpdateEnvelopePayload) => Promise<void>; updateEnvelopeAsync: (envelopeUpdates: UpdateEnvelopePayload) => Promise<void>;
setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void; setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void;
@@ -57,7 +70,7 @@ type EnvelopeEditorProviderValue = {
editorRecipients: ReturnType<typeof useEditorRecipients>; editorRecipients: ReturnType<typeof useEditorRecipients>;
isAutosaving: boolean; isAutosaving: boolean;
flushAutosave: () => Promise<void>; flushAutosave: () => Promise<TEditorEnvelope>;
autosaveError: boolean; autosaveError: boolean;
relativePath: { relativePath: {
@@ -68,12 +81,14 @@ type EnvelopeEditorProviderValue = {
templateRootPath: string; templateRootPath: string;
}; };
navigateToStep: (step: EnvelopeEditorStep) => Promise<void>;
syncEnvelope: () => Promise<void>; syncEnvelope: () => Promise<void>;
}; };
interface EnvelopeEditorProviderProps { interface EnvelopeEditorProviderProps {
children: React.ReactNode; children: React.ReactNode;
initialEnvelope: TEnvelope; editorConfig?: EnvelopeEditorConfig;
initialEnvelope: TEditorEnvelope;
} }
const EnvelopeEditorContext = createContext<EnvelopeEditorProviderValue | null>(null); const EnvelopeEditorContext = createContext<EnvelopeEditorProviderValue | null>(null);
@@ -90,14 +105,29 @@ export const useCurrentEnvelopeEditor = () => {
export const EnvelopeEditorProvider = ({ export const EnvelopeEditorProvider = ({
children, children,
editorConfig = DEFAULT_EDITOR_CONFIG,
initialEnvelope, initialEnvelope,
}: EnvelopeEditorProviderProps) => { }: EnvelopeEditorProviderProps) => {
const { t } = useLingui(); const { t } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const [envelope, setEnvelope] = useState(initialEnvelope); const [_searchParams, setSearchParams] = useSearchParams();
const [envelope, _setEnvelope] = useState(initialEnvelope);
const [autosaveError, setAutosaveError] = useState<boolean>(false); const [autosaveError, setAutosaveError] = useState<boolean>(false);
const envelopeRef = useRef(initialEnvelope);
const setEnvelope: typeof _setEnvelope = (action) => {
_setEnvelope((prev) => {
const next = typeof action === 'function' ? action(prev) : action;
envelopeRef.current = next;
return next;
});
};
const isEmbedded = editorConfig.embeded !== undefined;
const editorFields = useEditorFields({ const editorFields = useEditorFields({
envelope, envelope,
handleFieldsUpdate: (fields) => setFieldsDebounced(fields), handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
@@ -107,61 +137,35 @@ export const EnvelopeEditorProvider = ({
envelope, envelope,
}); });
const envelopeUpdateMutationQuery = trpc.envelope.update.useMutation({ const setRecipientsMutation = trpc.envelope.recipient.set.useMutation();
onSuccess: (response, input) => { const setFieldsMutation = trpc.envelope.field.set.useMutation();
setEnvelope({ const updateEnvelopeMutation = trpc.envelope.update.useMutation();
...envelope,
...response,
documentMeta: {
...envelope.documentMeta,
...input.meta,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
emailSettings: (input.meta?.emailSettings ||
null) as unknown as TDocumentEmailSettings | null,
},
});
setAutosaveError(false); /**
}, * Handles debouncing the recipients updates to the server.
onError: (err) => { *
console.error(err); * Will set the local envelope recipients and fields after the update is complete.
*/
const {
triggerSave: setRecipientsDebounced,
flush: flushSetRecipients,
isPending: isRecipientsMutationPending,
} = useEnvelopeAutosave(async (localRecipients: TSetEnvelopeRecipientsRequest['recipients']) => {
try {
let recipients: TEditorEnvelope['recipients'] = [];
setAutosaveError(true); if (!isEmbedded) {
const response = await setRecipientsMutation.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
recipients: localRecipients,
});
toast({ recipients = response.data;
title: t`Save failed`, } else {
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`, recipients = mapLocalRecipientsToRecipients({ envelope, localRecipients });
variant: 'destructive', }
duration: 7500,
});
},
});
const envelopeFieldSetMutationQuery = trpc.envelope.field.set.useMutation({
onSuccess: ({ data: fields }) => {
setEnvelope((prev) => ({
...prev,
fields,
}));
setAutosaveError(false);
},
onError: (err) => {
console.error(err);
setAutosaveError(true);
toast({
title: t`Save failed`,
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
variant: 'destructive',
duration: 7500,
});
},
});
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
onSuccess: ({ data: recipients }) => {
setEnvelope((prev) => ({ setEnvelope((prev) => ({
...prev, ...prev,
recipients, recipients,
@@ -178,8 +182,7 @@ export const EnvelopeEditorProvider = ({
); );
setAutosaveError(false); setAutosaveError(false);
}, } catch (err) {
onError: (err) => {
console.error(err); console.error(err);
setAutosaveError(true); setAutosaveError(true);
@@ -190,58 +193,137 @@ export const EnvelopeEditorProvider = ({
variant: 'destructive', variant: 'destructive',
duration: 7500, duration: 7500,
}); });
}, }
});
const {
triggerSave: setRecipientsDebounced,
flush: setRecipientsAsync,
isPending: isRecipientsMutationPending,
} = useEnvelopeAutosave(async (recipients: TSetEnvelopeRecipientsRequest['recipients']) => {
await envelopeRecipientSetMutationQuery.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
recipients,
});
}, 1000); }, 1000);
const setRecipientsAsync = async (
localRecipients: TSetEnvelopeRecipientsRequest['recipients'],
) => {
setRecipientsDebounced(localRecipients);
await flushSetRecipients();
};
/**
* Handles debouncing the fields updates to the server.
*
* Will set the local envelope fields after the update is complete.
*/
const { const {
triggerSave: setFieldsDebounced, triggerSave: setFieldsDebounced,
flush: setFieldsAsync, flush: flushSetFields,
isPending: isFieldsMutationPending, isPending: isFieldsMutationPending,
} = useEnvelopeAutosave(async (localFields: TLocalField[]) => { } = useEnvelopeAutosave(async (localFields: TLocalField[]) => {
const envelopeFields = await envelopeFieldSetMutationQuery.mutateAsync({ try {
envelopeId: envelope.id, let fields: TSetEnvelopeFieldsResponse['data'] = [];
envelopeType: envelope.type,
fields: localFields,
});
// Insert the IDs into the local fields. if (!isEmbedded) {
envelopeFields.data.forEach((field) => { const response = await setFieldsMutation.mutateAsync({
const localField = localFields.find((localField) => localField.formId === field.formId); envelopeId: envelope.id,
envelopeType: envelope.type,
fields: localFields,
});
if (localField && !localField.id) { fields = response.data;
localField.id = field.id; } else {
fields = mapLocalFieldsToFields({ envelope, localFields });
editorFields.setFieldId(localField.formId, field.id);
} }
});
setEnvelope((prev) => ({
...prev,
fields,
}));
setAutosaveError(false);
// Insert the IDs into the local fields.
fields.forEach((field) => {
const localField = localFields.find((localField) => localField.formId === field.formId);
if (localField && !localField.id) {
localField.id = field.id;
editorFields.setFieldId(localField.formId, field.id);
}
});
} catch (err) {
console.error(err);
setAutosaveError(true);
toast({
title: t`Save failed`,
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
variant: 'destructive',
duration: 7500,
});
}
}, 2000); }, 2000);
const setFieldsAsync = async (localFields: TLocalField[]) => {
setFieldsDebounced(localFields);
await flushSetFields();
};
/**
* Handles debouncing the envelope updates to the server.
*
* Will set the local envelope after the update is complete.
*/
const { const {
triggerSave: setEnvelopeDebounced, triggerSave: updateEnvelopeDebounced,
flush: setEnvelopeAsync, flush: flushUpdateEnvelope,
isPending: isEnvelopeMutationPending, isPending: isEnvelopeMutationPending,
} = useEnvelopeAutosave(async (envelopeUpdates: UpdateEnvelopePayload) => { } = useEnvelopeAutosave(async ({ data, meta }: UpdateEnvelopePayload) => {
await envelopeUpdateMutationQuery.mutateAsync({ try {
envelopeId: envelope.id, const response = !isEmbedded
data: envelopeUpdates.data, ? await updateEnvelopeMutation.mutateAsync({
meta: envelopeUpdates.meta, envelopeId: envelope.id,
}); data,
meta,
})
: {};
setEnvelope((prev) => ({
...prev,
...data,
authOptions: {
globalAccessAuth: data?.globalAccessAuth || [],
globalActionAuth: data?.globalActionAuth || [],
},
...response,
documentMeta: {
...prev.documentMeta,
...meta,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
emailSettings: (meta?.emailSettings || null) as unknown as TDocumentEmailSettings | null,
},
}));
setAutosaveError(false);
} catch (err) {
console.error(err);
setAutosaveError(true);
toast({
title: t`Save failed`,
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
variant: 'destructive',
duration: 7500,
});
}
}, 1000); }, 1000);
const updateEnvelopeAsync = async (envelopeUpdates: UpdateEnvelopePayload) => {
updateEnvelopeDebounced(envelopeUpdates);
await flushUpdateEnvelope();
};
/** /**
* Updates the local envelope and debounces the update to the server. * Updates the local envelope and debounces the update to the server.
*
* Use this when you want to update the local envelope immediately while debouncing
* the actual update to the server.
*/ */
const updateEnvelope = (envelopeUpdates: UpdateEnvelopePayload) => { const updateEnvelope = (envelopeUpdates: UpdateEnvelopePayload) => {
setEnvelope((prev) => ({ setEnvelope((prev) => ({
@@ -253,14 +335,7 @@ export const EnvelopeEditorProvider = ({
}, },
})); }));
setEnvelopeDebounced(envelopeUpdates); updateEnvelopeDebounced(envelopeUpdates);
};
const updateEnvelopeAsync = async (envelopeUpdates: UpdateEnvelopePayload) => {
await envelopeUpdateMutationQuery.mutateAsync({
envelopeId: envelope.id,
...envelopeUpdates,
});
}; };
const getRecipientColorKey = useCallback( const getRecipientColorKey = useCallback(
@@ -276,12 +351,13 @@ export const EnvelopeEditorProvider = ({
[envelope.recipients], [envelope.recipients],
); );
const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery( const { refetch: reloadEnvelope } = trpc.envelope.get.useQuery(
{ {
envelopeId: envelope.id, envelopeId: envelope.id,
}, },
{ {
initialData: envelope, enabled: !isEmbedded,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
}, },
); );
@@ -293,6 +369,11 @@ export const EnvelopeEditorProvider = ({
const syncEnvelope = async () => { const syncEnvelope = async () => {
await flushAutosave(); await flushAutosave();
// Bypass syncing for embedded mode.
if (isEmbedded) {
return;
}
const fetchedEnvelopeData = await reloadEnvelope(); const fetchedEnvelopeData = await reloadEnvelope();
if (fetchedEnvelopeData.data) { if (fetchedEnvelopeData.data) {
@@ -302,55 +383,89 @@ export const EnvelopeEditorProvider = ({
recipients: fetchedEnvelopeData.data.recipients, recipients: fetchedEnvelopeData.data.recipients,
documentMeta: fetchedEnvelopeData.data.documentMeta, documentMeta: fetchedEnvelopeData.data.documentMeta,
}); });
editorFields.resetForm(fetchedEnvelopeData.data.fields); editorFields.resetForm(fetchedEnvelopeData.data.fields);
} }
}; };
const setLocalEnvelope = (localEnvelope: Partial<TEnvelope>) => { const setLocalEnvelope = (localEnvelope: Partial<TEditorEnvelope>) => {
setEnvelope((prev) => ({ ...prev, ...localEnvelope })); setEnvelope((prev) => ({ ...prev, ...localEnvelope }));
}; };
const isAutosaving = useMemo(() => { const isAutosaving = useMemo(() => {
return ( return isFieldsMutationPending || isRecipientsMutationPending || isEnvelopeMutationPending;
envelopeFieldSetMutationQuery.isPending || }, [isFieldsMutationPending, isRecipientsMutationPending, isEnvelopeMutationPending]);
envelopeRecipientSetMutationQuery.isPending ||
envelopeUpdateMutationQuery.isPending ||
isFieldsMutationPending ||
isRecipientsMutationPending ||
isEnvelopeMutationPending
);
}, [
envelopeFieldSetMutationQuery.isPending,
envelopeRecipientSetMutationQuery.isPending,
envelopeUpdateMutationQuery.isPending,
isFieldsMutationPending,
isRecipientsMutationPending,
isEnvelopeMutationPending,
]);
const relativePath = useMemo(() => { const relativePath = useMemo(() => {
const documentRootPath = formatDocumentsPath(envelope.team.url); let documentRootPath = formatDocumentsPath(envelope.team.url);
const templateRootPath = formatTemplatesPath(envelope.team.url); let templateRootPath = formatTemplatesPath(envelope.team.url);
const basePath = envelope.type === EnvelopeType.DOCUMENT ? documentRootPath : templateRootPath; const basePath = envelope.type === EnvelopeType.DOCUMENT ? documentRootPath : templateRootPath;
let envelopePath = `${basePath}/${envelope.id}`;
let editorPath = `${basePath}/${envelope.id}/edit`;
if (editorConfig.embeded) {
let embeddedEditorPath =
editorConfig.embeded.mode === 'edit'
? `/embed/v2/authoring/envelope/edit/${envelope.id}`
: `/embed/v2/authoring/envelope/create`;
embeddedEditorPath += `?token=${editorConfig.embeded.presignToken}`;
// Todo: Embeds - This should be thought about more.
envelopePath = embeddedEditorPath;
editorPath = embeddedEditorPath;
documentRootPath = embeddedEditorPath;
templateRootPath = embeddedEditorPath;
}
return { return {
basePath, basePath,
envelopePath: `${basePath}/${envelope.id}`, envelopePath,
editorPath: `${basePath}/${envelope.id}/edit`, editorPath,
documentRootPath, documentRootPath,
templateRootPath, templateRootPath,
}; };
}, [envelope.type, envelope.id]); }, [envelope.type, envelope.id]);
const flushAutosave = async (): Promise<void> => { const navigateToStep = async (step: EnvelopeEditorStep) => {
await Promise.all([setFieldsAsync(), setRecipientsAsync(), setEnvelopeAsync()]); setSearchParams((prev) => {
const newParams = new URLSearchParams(prev);
if (step === 'upload') {
newParams.delete('step');
} else {
newParams.set('step', step);
}
return newParams;
});
await flushAutosave();
resetForms();
};
const resetForms = () => {
editorRecipients.resetForm({
recipients: envelopeRef.current.recipients,
documentMeta: envelopeRef.current.documentMeta,
});
editorFields.resetForm(envelopeRef.current.fields);
};
const flushAutosave = async (): Promise<TEditorEnvelope> => {
await Promise.all([flushSetFields(), flushSetRecipients(), flushUpdateEnvelope()]);
return envelopeRef.current;
}; };
return ( return (
<EnvelopeEditorContext.Provider <EnvelopeEditorContext.Provider
value={{ value={{
editorConfig,
envelope, envelope,
isEmbedded,
isDocument: envelope.type === EnvelopeType.DOCUMENT, isDocument: envelope.type === EnvelopeType.DOCUMENT,
isTemplate: envelope.type === EnvelopeType.TEMPLATE, isTemplate: envelope.type === EnvelopeType.TEMPLATE,
setLocalEnvelope, setLocalEnvelope,
@@ -366,9 +481,107 @@ export const EnvelopeEditorProvider = ({
isAutosaving, isAutosaving,
relativePath, relativePath,
syncEnvelope, syncEnvelope,
navigateToStep,
}} }}
> >
{children} {children}
</EnvelopeEditorContext.Provider> </EnvelopeEditorContext.Provider>
); );
}; };
type MapLocalRecipientsToRecipientsOptions = {
envelope: TEditorEnvelope;
localRecipients: TSetEnvelopeRecipientsRequest['recipients'];
};
const mapLocalRecipientsToRecipients = ({
envelope,
localRecipients,
}: MapLocalRecipientsToRecipientsOptions): TEditorEnvelope['recipients'] => {
let smallestRecipientId = localRecipients.reduce((min, recipient) => {
if (recipient.id && recipient.id < min) {
return recipient.id;
}
return min;
}, -1);
return localRecipients.map((recipient) => {
const foundRecipient = envelope.recipients.find((recipient) => recipient.id === recipient.id);
let recipientId = recipient.id;
if (recipientId === undefined) {
recipientId = smallestRecipientId;
smallestRecipientId--;
}
return {
id: recipientId,
envelopeId: envelope.id,
email: recipient.email,
name: recipient.name,
token: foundRecipient?.token || '',
documentDeletedAt: foundRecipient?.documentDeletedAt || null,
expired: foundRecipient?.expired || null,
signedAt: foundRecipient?.signedAt || null,
authOptions:
recipient.actionAuth.length > 0
? { actionAuth: recipient.actionAuth, accessAuth: [] }
: null,
signingOrder: recipient.signingOrder ?? null,
rejectionReason: foundRecipient?.rejectionReason || null,
role: recipient.role,
readStatus: foundRecipient?.readStatus || ReadStatus.NOT_OPENED,
signingStatus: foundRecipient?.signingStatus || SigningStatus.NOT_SIGNED,
sendStatus: foundRecipient?.sendStatus || SendStatus.NOT_SENT,
};
});
};
type MapLocalFieldsToFieldsOptions = {
localFields: TLocalField[];
envelope: TEditorEnvelope;
};
const mapLocalFieldsToFields = ({
envelope,
localFields,
}: MapLocalFieldsToFieldsOptions): TSetEnvelopeFieldsResponse['data'] => {
let smallestFieldId = localFields.reduce((min, field) => {
if (field.id && field.id < min) {
return field.id;
}
return min;
}, -1);
return localFields.map((field) => {
const foundField = envelope.fields.find((envelopeField) => envelopeField.id === field.id);
let fieldId = field.id;
if (fieldId === undefined) {
fieldId = smallestFieldId;
smallestFieldId--;
}
return {
...field,
formId: field.formId,
id: fieldId,
envelopeId: envelope.id,
envelopeItemId: field.envelopeItemId,
type: field.type,
recipientId: field.recipientId,
positionX: new Prisma.Decimal(field.positionX),
positionY: new Prisma.Decimal(field.positionY),
width: new Prisma.Decimal(field.width),
height: new Prisma.Decimal(field.height),
secondaryId: foundField?.secondaryId || '',
inserted: foundField?.inserted || false,
customText: foundField?.customText || '',
fieldMeta: field.fieldMeta || null,
};
});
};
@@ -1,23 +1,48 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import React from 'react'; import React from 'react';
import type { Field, Recipient } from '@prisma/client'; import type { Field, Recipient } from '@prisma/client';
import { PDF_IMAGE_RENDER_SCALE } from '@documenso/lib/constants/pdf-viewer';
import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
import type { TGetEnvelopeItemsMetaResponse } from '@documenso/remix/server/api/files/files.types';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors'; import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors'; import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
import type { DocumentDataVersion } from '../../types/document-data';
import type { TEnvelope } from '../../types/envelope'; import type { TEnvelope } from '../../types/envelope';
import type { FieldRenderMode } from '../../universal/field-renderer/render-field'; import type { FieldRenderMode } from '../../universal/field-renderer/render-field';
import { getEnvelopeItemPdfUrl } from '../../utils/envelope-download'; import { getEnvelopeItemMetaUrl, getEnvelopeItemPageImageUrl } from '../../utils/envelope-images';
type FileData = /**
| { * Number of pages to load eagerly on initial render.
status: 'loading' | 'error'; * Pages beyond this threshold will be loaded lazily when they enter the viewport.
} */
| { export const EAGER_LOAD_PAGE_COUNT = 5;
file: Uint8Array;
status: 'loaded'; export type PageRenderData = BasePageRenderData & {
}; scale: number;
};
export type BasePageRenderData = {
envelopeItemId: string;
documentDataId: string;
pageIndex: number;
pageNumber: number;
pageWidth: number;
pageHeight: number;
imageUrl: string;
};
export type ImageLoadingState = 'loading' | 'loaded' | 'error';
type EnvelopeRenderOverrideSettings = { type EnvelopeRenderOverrideSettings = {
mode?: FieldRenderMode; mode?: FieldRenderMode;
@@ -25,11 +50,19 @@ type EnvelopeRenderOverrideSettings = {
showRecipientSigningStatus?: boolean; showRecipientSigningStatus?: boolean;
}; };
type EnvelopeRenderItem = TEnvelope['envelopeItems'][number]; type EnvelopeRenderItem = {
id: string;
title: string;
order: number;
envelopeId: string;
data?: Uint8Array | null;
};
type EnvelopeRenderProviderValue = { type EnvelopeRenderProviderValue = {
getPdfBuffer: (envelopeItemId: string) => FileData | null; version: DocumentDataVersion;
envelopeItems: EnvelopeRenderItem[]; envelopeItems: EnvelopeRenderItem[];
envelopeItemsMeta: Record<string, BasePageRenderData[]>;
envelopeItemsMetaLoadingState: ImageLoadingState;
envelopeStatus: TEnvelope['status']; envelopeStatus: TEnvelope['status'];
envelopeType: TEnvelope['type']; envelopeType: TEnvelope['type'];
currentEnvelopeItem: EnvelopeRenderItem | null; currentEnvelopeItem: EnvelopeRenderItem | null;
@@ -46,7 +79,17 @@ type EnvelopeRenderProviderValue = {
interface EnvelopeRenderProviderProps { interface EnvelopeRenderProviderProps {
children: React.ReactNode; children: React.ReactNode;
envelope: Pick<TEnvelope, 'envelopeItems' | 'status' | 'type'>; /**
* The envelope item version to render.
*/
version: DocumentDataVersion;
envelope: Pick<TEnvelope, 'id' | 'status' | 'type'>;
/**
* The envelope items to render.
*/
envelopeItems: EnvelopeRenderItem[];
/** /**
* Optional fields which are passed down to renderers for custom rendering needs. * Optional fields which are passed down to renderers for custom rendering needs.
@@ -70,6 +113,13 @@ interface EnvelopeRenderProviderProps {
*/ */
token: string | undefined; token: string | undefined;
/**
* The presign token to access the envelope.
*
* If not provided, it will be assumed that the current user can access the document.
*/
presignToken?: string | undefined;
/** /**
* Custom override settings for generic page renderers. * Custom override settings for generic page renderers.
*/ */
@@ -89,81 +139,175 @@ export const useCurrentEnvelopeRender = () => {
}; };
/** /**
* Manages fetching and storing PDF files to render on the client. * Manages fetching the data required to render an envelope and it's items.
*/ */
export const EnvelopeRenderProvider = ({ export const EnvelopeRenderProvider = ({
children, children,
envelope, envelope,
envelopeItems: envelopeItemsFromProps,
fields, fields,
token, token,
presignToken,
recipients = [], recipients = [],
version,
overrideSettings, overrideSettings,
}: EnvelopeRenderProviderProps) => { }: EnvelopeRenderProviderProps) => {
// Indexed by documentDataId. // Indexed by envelope item ID.
const [files, setFiles] = useState<Record<string, FileData>>({}); const [envelopeItemsMeta, setEnvelopeItemsMeta] = useState<Record<string, BasePageRenderData[]>>(
{},
);
const [currentItem, setCurrentItem] = useState<EnvelopeRenderItem | null>(null); const [envelopeItemsMetaLoadingState, setEnvelopeItemsMetaLoadingState] =
useState<ImageLoadingState>('loading');
const [renderError, setRenderError] = useState<boolean>(false); const [renderError, setRenderError] = useState<boolean>(false);
// Track the timestamp of the most recent fetch to prevent race conditions
const fetchStartedAtRef = useRef<number>(0);
const envelopeItems = useMemo( const envelopeItems = useMemo(
() => envelope.envelopeItems.sort((a, b) => a.order - b.order), () => envelopeItemsFromProps.sort((a, b) => a.order - b.order),
[envelope.envelopeItems], [envelopeItemsFromProps],
); );
const loadEnvelopeItemPdfFile = async (envelopeItem: EnvelopeRenderItem) => { const [currentItem, setCurrentItem] = useState<EnvelopeRenderItem | null>(
if (files[envelopeItem.id]?.status === 'loading') { envelopeItems[0] ?? null,
);
/**
* Fetch metadata and preload initial images when the envelope or token changes.
*/
useEffect(() => {
void fetchEnvelopeRenderData();
}, [envelope.id, envelopeItems.length, token, version]);
const fetchEnvelopeRenderData = async () => {
if (envelopeItems.length === 0) {
return; return;
} }
if (!files[envelopeItem.id]) { // Render certain envelope items locally, such as embedded.
setFiles((prev) => ({ // No envelope ID means it's in embedded create mode.
...prev, if (
[envelopeItem.id]: { !envelope.id ||
status: 'loading', envelopeItems.some(
}, (item) => item.id.startsWith(PRESIGNED_ENVELOPE_ITEM_ID_PREFIX) && item.data,
})); )
) {
await handleLocalFileFetch();
return;
} }
// Record when this fetch started to detect stale responses
const fetchStartedAt = Date.now();
fetchStartedAtRef.current = fetchStartedAt;
setEnvelopeItemsMetaLoadingState('loading');
try { try {
const downloadUrl = getEnvelopeItemPdfUrl({ // Fetch metadata for all envelope items
type: 'view', const metaUrl = getEnvelopeItemMetaUrl({
envelopeItem: envelopeItem, envelopeId: envelope.id,
token, token,
presignToken,
}); });
const blob = await fetch(downloadUrl).then(async (res) => await res.blob()); const response = await fetch(metaUrl);
const file = await blob.arrayBuffer(); if (!response.ok) {
throw new Error(`Failed to fetch envelope meta: ${response.status}`);
}
setFiles((prev) => ({ const data: TGetEnvelopeItemsMetaResponse = await response.json();
...prev,
[envelopeItem.id]: { // Check again after parsing JSON in case a newer fetch started
file: new Uint8Array(file), if (fetchStartedAtRef.current !== fetchStartedAt) {
status: 'loaded', return;
}, }
}));
// Build a map of envelope items by ID
const metaMap: Record<string, BasePageRenderData[]> = {};
for (const item of data.envelopeItems) {
metaMap[item.envelopeItemId] = item.pages.map((page, pageIndex) => {
const imageUrl = getEnvelopeItemPageImageUrl({
envelopeId: envelope.id,
envelopeItemId: item.envelopeItemId,
documentDataId: item.documentDataId,
pageIndex,
token,
presignToken,
version,
});
return {
envelopeItemId: item.envelopeItemId,
documentDataId: item.documentDataId,
pageIndex,
pageNumber: pageIndex + 1,
pageWidth: page.originalWidth,
pageHeight: page.originalHeight,
imageUrl,
};
});
}
setEnvelopeItemsMeta(metaMap);
setEnvelopeItemsMetaLoadingState('loaded');
} catch (error) { } catch (error) {
console.error(error); // Only set error state if this is still the most recent fetch
if (fetchStartedAtRef.current === fetchStartedAt) {
setFiles((prev) => ({ console.error('Failed to load envelope data:', error);
...prev, setEnvelopeItemsMetaLoadingState('error');
[envelopeItem.id]: { }
status: 'error',
},
}));
} }
}; };
const getPdfBuffer = useCallback( const handleLocalFileFetch = async () => {
(envelopeItemId: string) => { setEnvelopeItemsMetaLoadingState('loading');
return files[envelopeItemId] || null;
}, try {
[files], // Build a map of envelope items by ID
); const metaMap: Record<string, BasePageRenderData[]> = {};
// Dynamically import "pdfToImagesClientSide" function to prevent bundling.
const { pdfToImagesClientSide } = await import(
'@documenso/lib/server-only/ai/pdf-to-images.client'
);
for (const item of envelopeItems) {
if (item.data) {
// Clone the buffer so PDF.js can transfer it to its worker without detaching the one in state
const pdfBytes = new Uint8Array(structuredClone(item.data));
const pdfImages = await pdfToImagesClientSide(pdfBytes, {
scale: PDF_IMAGE_RENDER_SCALE,
});
metaMap[item.id] = pdfImages.map((image) => ({
envelopeItemId: item.id,
documentDataId: item.id,
pageIndex: image.pageIndex,
pageNumber: image.pageIndex + 1,
pageWidth: image.width,
pageHeight: image.height,
imageUrl: image.image,
}));
}
}
setEnvelopeItemsMeta(metaMap);
setEnvelopeItemsMetaLoadingState('loaded');
} catch (error) {
console.error('Failed to load envelope data:', error);
setEnvelopeItemsMetaLoadingState('error');
}
};
const setCurrentEnvelopeItem = (envelopeItemId: string) => { const setCurrentEnvelopeItem = (envelopeItemId: string) => {
const foundItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId); const foundItem = envelopeItems.find((item) => item.id === envelopeItemId);
setCurrentItem(foundItem ?? null); setCurrentItem(foundItem ?? null);
}; };
@@ -179,15 +323,6 @@ export const EnvelopeRenderProvider = ({
} }
}, [currentItem, envelopeItems]); }, [currentItem, envelopeItems]);
// Look for any missing pdf files and load them.
useEffect(() => {
const missingFiles = envelope.envelopeItems.filter((item) => !files[item.id]);
for (const item of missingFiles) {
void loadEnvelopeItemPdfFile(item);
}
}, [envelope.envelopeItems]);
const recipientIds = useMemo( const recipientIds = useMemo(
() => recipients.map((recipient) => recipient.id).sort(), () => recipients.map((recipient) => recipient.id).sort(),
[recipients], [recipients],
@@ -207,7 +342,9 @@ export const EnvelopeRenderProvider = ({
return ( return (
<EnvelopeRenderContext.Provider <EnvelopeRenderContext.Provider
value={{ value={{
getPdfBuffer, version,
envelopeItemsMeta,
envelopeItemsMetaLoadingState,
envelopeItems, envelopeItems,
envelopeStatus: envelope.status, envelopeStatus: envelope.status,
envelopeType: envelope.type, envelopeType: envelope.type,
+22
View File
@@ -0,0 +1,22 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
// This is separate from the pdf-viewer.ts constant file due to parsing issues during testing.
export const PDF_VIEWER_ERROR_MESSAGES = {
editor: {
title: msg`Configuration Error`,
description: msg`There was an issue rendering some fields, please review the fields and try again.`,
},
preview: {
title: msg`Configuration Error`,
description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`,
},
signing: {
title: msg`Configuration Error`,
description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`,
},
default: {
title: msg`Configuration Error`,
description: msg`Something went wrong while rendering the document, please try again or contact our support.`,
},
} satisfies Record<string, { title: MessageDescriptor; description: MessageDescriptor }>;
+7 -1
View File
@@ -1,2 +1,8 @@
export const PDF_VIEWER_CONTAINER_SELECTOR = '.react-pdf__Document'; // Keep these two constants in sync.
export const PDF_VIEWER_PAGE_SELECTOR = '.react-pdf__Page'; export const PDF_VIEWER_PAGE_SELECTOR = '.react-pdf__Page';
export const PDF_VIEWER_PAGE_CLASSNAME = 'react-pdf__Page z-0';
/**
* Changing this will require large testing.
*/
export const PDF_IMAGE_RENDER_SCALE = 2;
@@ -242,18 +242,13 @@ export const run = async ({
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) { for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) {
const newData = await tx.documentData.findFirstOrThrow({ await tx.envelopeItem.update({
where: { where: {
id: newDocumentDataId, envelopeId: envelope.id,
}, documentDataId: oldDocumentDataId,
});
await tx.documentData.update({
where: {
id: oldDocumentDataId,
}, },
data: { data: {
data: newData.data, documentDataId: newDocumentDataId,
}, },
}); });
} }
-1
View File
@@ -55,7 +55,6 @@
"posthog-js": "^1.297.2", "posthog-js": "^1.297.2",
"posthog-node": "4.18.0", "posthog-node": "4.18.0",
"react": "^18", "react": "^18",
"react-pdf": "^10.3.0",
"remeda": "^2.32.0", "remeda": "^2.32.0",
"sharp": "0.34.5", "sharp": "0.34.5",
"skia-canvas": "^3.0.8", "skia-canvas": "^3.0.8",
@@ -162,8 +162,8 @@ export const detectFieldsFromPdf = async ({
// Mask existing fields on the image // Mask existing fields on the image
const maskedImage = await maskFieldsOnImage({ const maskedImage = await maskFieldsOnImage({
image: page.image, image: page.image,
width: page.width, width: page.scaledWidth,
height: page.height, height: page.scaledHeight,
fields: fieldsOnPage, fields: fieldsOnPage,
}); });
@@ -0,0 +1,73 @@
import pMap from 'p-map';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/legacy/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
export type PdfToImagesOptions = {
scale?: number;
};
export type PdfImageResult = {
pageIndex: number;
image: string;
width: number;
height: number;
};
export const pdfToImagesClientSide = async (
pdfBytes: Uint8Array,
options: PdfToImagesOptions = {},
): Promise<PdfImageResult[]> => {
const { scale = 2 } = options;
const task = pdfjsLib.getDocument({
data: pdfBytes,
});
const pdf = await task.promise;
const images = await pMap(
Array.from({ length: pdf.numPages }),
async (_, pageIndex) => {
const pageNumber = pageIndex + 1;
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Failed to get 2D canvas context');
}
await page.render({
canvasContext: context,
viewport,
canvas,
}).promise;
const imageBase64 = canvas.toDataURL('image/jpeg');
page.cleanup();
return {
pageIndex,
image: imageBase64,
width: canvas.width,
height: canvas.height,
};
},
{ concurrency: 50 },
);
await pdf.destroy();
await task.destroy();
return images;
};
+94 -32
View File
@@ -1,7 +1,11 @@
import pMap from 'p-map'; import pMap from 'p-map';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs'; import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import type { PDFDocumentProxy } from 'pdfjs-dist/types/src/display/api';
import type { ExportFormat } from 'skia-canvas';
import { Canvas, Image, Path2D } from 'skia-canvas'; import { Canvas, Image, Path2D } from 'skia-canvas';
import { PDF_IMAGE_RENDER_SCALE } from '../../constants/pdf-viewer';
// @ts-expect-error napi-rs/canvas satisfies the requirements // @ts-expect-error napi-rs/canvas satisfies the requirements
globalThis.Path2D = Path2D; globalThis.Path2D = Path2D;
// @ts-expect-error napi-rs/canvas satisfies the requirements // @ts-expect-error napi-rs/canvas satisfies the requirements
@@ -42,10 +46,17 @@ class SkiaCanvasFactory {
export type PdfToImagesOptions = { export type PdfToImagesOptions = {
scale?: number; scale?: number;
/**
* The format of the images to return.
*
* Defaults to 'jpeg'.
*/
imageFormat?: ExportFormat;
}; };
export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOptions = {}) => { export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOptions = {}) => {
const { scale = 2 } = options; const { scale = PDF_IMAGE_RENDER_SCALE, imageFormat = 'jpeg' } = options;
const task = await pdfjsLib.getDocument({ const task = await pdfjsLib.getDocument({
data: pdfBytes, data: pdfBytes,
@@ -56,37 +67,7 @@ export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOpti
const images = await pMap( const images = await pMap(
Array.from({ length: pdf.numPages }), Array.from({ length: pdf.numPages }),
async (_, index) => { async (_, pageIndex) => getPdfPageAsImage({ pdf, pageIndex, scale, imageFormat }),
const pageNumber = index + 1;
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale });
const canvas = new Canvas(viewport.width, viewport.height);
canvas.gpu = false;
const canvasContext = canvas.getContext('2d');
await page.render({
// @ts-expect-error napi-rs/canvas satifies the requirements
canvas,
// @ts-expect-error napi-rs/canvas satifies the requirements
canvasContext,
viewport,
}).promise;
const result = {
pageNumber,
image: await canvas.toBuffer('jpeg'),
width: Math.floor(viewport.width),
height: Math.floor(viewport.height),
mimeType: 'image/jpeg',
};
void page.cleanup();
return result;
},
{ concurrency: 10 }, { concurrency: 10 },
); );
@@ -95,3 +76,84 @@ export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOpti
return images; return images;
}; };
export type PdfToImageOptions = {
scale?: number;
pageIndex: number;
/**
* The format of the image to return.
* Defaults to 'jpeg'.
*/
imageFormat?: ExportFormat;
};
export const pdfToImage = async (pdfBytes: Uint8Array, options: PdfToImageOptions) => {
const { scale = PDF_IMAGE_RENDER_SCALE, pageIndex, imageFormat = 'jpeg' } = options;
if (pageIndex !== undefined && pageIndex < 0) {
throw new Error('Page index must be greater than or equal to 0');
}
const task = await pdfjsLib.getDocument({
data: pdfBytes,
CanvasFactory: SkiaCanvasFactory,
});
const pdf = await task.promise;
const image = await getPdfPageAsImage({ pdf, pageIndex, scale, imageFormat });
void pdf.destroy().catch((e) => console.error(e));
void task.destroy().catch((e) => console.error(e));
return image;
};
type GetPdfPageAsImageOptions = {
pdf: PDFDocumentProxy;
pageIndex: number;
scale: number;
imageFormat: ExportFormat;
};
const getPdfPageAsImage = async ({
pdf,
pageIndex,
scale,
imageFormat,
}: GetPdfPageAsImageOptions) => {
const page = await pdf.getPage(pageIndex + 1);
const viewport = page.getViewport({ scale });
const canvas = new Canvas(viewport.width, viewport.height);
canvas.gpu = false;
const canvasContext = canvas.getContext('2d');
await page.render({
// @ts-expect-error napi-rs/canvas satifies the requirements
canvas,
// @ts-expect-error napi-rs/canvas satifies the requirements
canvasContext,
viewport,
}).promise;
const originalViewport = page.getViewport({ scale: 1 });
const result = {
pageIndex,
pageNumber: pageIndex + 1,
image: await canvas.toBuffer(imageFormat),
originalWidth: originalViewport.width,
originalHeight: originalViewport.height,
scale,
scaledWidth: Math.floor(viewport.width),
scaledHeight: Math.floor(viewport.height),
};
void page.cleanup();
return result;
};
@@ -310,7 +310,7 @@ export const sendDocument = async ({
const injectFormValuesIntoDocument = async ( const injectFormValuesIntoDocument = async (
envelope: Envelope, envelope: Envelope,
envelopeItem: Pick<EnvelopeItem, 'id'> & { documentData: DocumentData }, envelopeItem: Pick<EnvelopeItem, 'id'> & { documentData: Omit<DocumentData, 'metadata'> },
) => { ) => {
const file = await getFileServerSide(envelopeItem.documentData); const file = await getFileServerSide(envelopeItem.documentData);
@@ -60,6 +60,15 @@ export const verifyEmbeddingPresignToken = async ({
where: { where: {
id: tokenId, id: tokenId,
}, },
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
}); });
if (!apiToken) { if (!apiToken) {
@@ -69,7 +78,7 @@ export const verifyEmbeddingPresignToken = async ({
} }
// This should never happen but we need to narrow types // This should never happen but we need to narrow types
if (!apiToken.userId) { if (!apiToken.userId || !apiToken.user) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token: API token does not have a user attached', message: 'Invalid presign token: API token does not have a user attached',
}); });
@@ -119,5 +128,10 @@ export const verifyEmbeddingPresignToken = async ({
return { return {
...apiToken, ...apiToken,
userId, userId,
user: {
id: apiToken.user.id,
name: apiToken.user.name,
email: apiToken.user.email,
},
}; };
}; };
@@ -97,6 +97,14 @@ export type CreateEnvelopeOptions = {
data: string; data: string;
type?: TEnvelopeAttachmentType; type?: TEnvelopeAttachmentType;
}>; }>;
/**
* Whether to bypass adding default recipients.
*
* Defaults to false.
*/
bypassDefaultRecipients?: boolean;
meta?: Partial<Omit<DocumentMeta, 'id'>>; meta?: Partial<Omit<DocumentMeta, 'id'>>;
requestMetadata: ApiRequestMetadata; requestMetadata: ApiRequestMetadata;
}; };
@@ -110,6 +118,7 @@ export const createEnvelope = async ({
meta, meta,
requestMetadata, requestMetadata,
internalVersion, internalVersion,
bypassDefaultRecipients = false,
}: CreateEnvelopeOptions) => { }: CreateEnvelopeOptions) => {
const { const {
type, type,
@@ -355,9 +364,10 @@ export const createEnvelope = async ({
const firstEnvelopeItem = envelope.envelopeItems[0]; const firstEnvelopeItem = envelope.envelopeItems[0];
const defaultRecipients = settings.defaultRecipients const defaultRecipients =
? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) settings.defaultRecipients && !bypassDefaultRecipients
: []; ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
: [];
const mappedDefaultRecipients: CreateEnvelopeRecipientOptions[] = defaultRecipients.map( const mappedDefaultRecipients: CreateEnvelopeRecipientOptions[] = defaultRecipients.map(
(recipient) => ({ (recipient) => ({
@@ -21,14 +21,7 @@ export type SetTemplateRecipientsOptions = {
userId: number; userId: number;
teamId: number; teamId: number;
id: EnvelopeIdOptions; id: EnvelopeIdOptions;
recipients: { recipients: RecipientData[];
id?: number;
email: string;
name: string;
role: RecipientRole;
signingOrder?: number | null;
actionAuth?: TRecipientActionAuthTypes[];
}[];
}; };
export const setTemplateRecipients = async ({ export const setTemplateRecipients = async ({
@@ -183,7 +176,10 @@ export const setTemplateRecipients = async ({
}); });
} }
return upsertedRecipient; return {
...upsertedRecipient,
clientId: recipient.clientId,
};
}), }),
); );
}); });
@@ -199,7 +195,7 @@ export const setTemplateRecipients = async ({
} }
// Filter out recipients that have been removed or have been updated. // Filter out recipients that have been removed or have been updated.
const filteredRecipients: Recipient[] = existingRecipients.filter((recipient) => { const filteredRecipients: RecipientDataWithClientId[] = existingRecipients.filter((recipient) => {
const isRemoved = removedRecipients.find( const isRemoved = removedRecipients.find(
(removedRecipient) => removedRecipient.id === recipient.id, (removedRecipient) => removedRecipient.id === recipient.id,
); );
@@ -218,3 +214,17 @@ export const setTemplateRecipients = async ({
})), })),
}; };
}; };
type RecipientData = {
id?: number;
clientId?: string | null;
email: string;
name: string;
role: RecipientRole;
signingOrder?: number | null;
actionAuth?: TRecipientActionAuthTypes[];
};
type RecipientDataWithClientId = Recipient & {
clientId?: string | null;
};
+18
View File
@@ -0,0 +1,18 @@
import { z } from 'zod';
export const ZDocumentDataMetaSchema = z.object({
// Could store other things such as PDF size, etc here.
pages: z
.object({
originalWidth: z.number().describe('Original PDF page width'),
originalHeight: z.number().describe('Original PDF page height'),
scale: z.number().describe('The scale applied to the width/height of the PDF page'),
scaledWidth: z.number().describe('Scaled PDF page image width'),
scaledHeight: z.number().describe('Scaled PDF page image height'),
})
.array(),
});
export type TDocumentDataMeta = z.infer<typeof ZDocumentDataMetaSchema>;
export type DocumentDataVersion = 'initial' | 'current';
+300
View File
@@ -0,0 +1,300 @@
import { EnvelopeType } from '@prisma/client';
import { z } from 'zod';
import { ZEnvelopeFieldSchema } from '@documenso/lib/types/field';
import { ZEnvelopeRecipientLiteSchema } from '@documenso/lib/types/recipient';
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
import { EnvelopeItemSchema } from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
import { EnvelopeSchema } from '@documenso/prisma/generated/zod/modelSchema/EnvelopeSchema';
import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import { TemplateDirectLinkSchema } from '@documenso/prisma/generated/zod/modelSchema/TemplateDirectLinkSchema';
import { ZBaseEmbedDataSchema } from '@documenso/remix/app/types/embed-base-schemas';
/**
* DO NOT MAKE ANY BREAKING BACKWARD CHANGES HERE UNLESS YOu'RE SURE
* IT WON'T BREAK EMBEDDINGS.
*
* Keep this in sync with the embedded repo (the types + schema)
*/
export const ZEnvelopeEditorSettingsSchema = z.object({
/**
* Generic editor related configurations.
*/
general: z.object({
allowConfigureEnvelopeTitle: z.boolean(),
allowUploadAndRecipientStep: z.boolean(),
allowAddFieldsStep: z.boolean(),
allowPreviewStep: z.boolean(),
minimizeLeftSidebar: z.boolean(),
}),
/**
* Envelope meta/settings related configuration
*
* If null, the settings will not be available to be seen/updated.
*/
settings: z
.object({
allowConfigureSignatureTypes: z.boolean(),
allowConfigureLanguage: z.boolean(),
allowConfigureDateFormat: z.boolean(),
allowConfigureTimezone: z.boolean(),
allowConfigureRedirectUrl: z.boolean(),
allowConfigureDistribution: z.boolean(),
})
.nullable(),
/**
* Action related configurations.
*/
actions: z.object({
allowAttachments: z.boolean(),
allowDistributing: z.boolean(),
allowDirectLink: z.boolean(),
allowDuplication: z.boolean(),
allowDownloadPDF: z.boolean(),
allowDeletion: z.boolean(),
allowReturnToPreviousPage: z.boolean(),
}),
/**
* Envelope items related configurations.
*
* If null, no adjustments to envelope items will be allowed.
*/
envelopeItems: z
.object({
allowConfigureTitle: z.boolean(),
allowConfigureOrder: z.boolean(),
allowUpload: z.boolean(),
allowDelete: z.boolean(),
})
.nullable(),
/**
* Recipient related configurations.
*
* If null, recipients will not be configurable at all.
*/
recipients: z
.object({
allowAIDetection: z.boolean(),
allowConfigureSigningOrder: z.boolean(),
allowConfigureDictateNextSigner: z.boolean(),
allowApproverRole: z.boolean(),
allowViewerRole: z.boolean(),
allowCCerRole: z.boolean(),
allowAssistantRole: z.boolean(),
})
.nullable(),
/**
* Fields related configurations.
*/
fields: z.object({
allowAIDetection: z.boolean(),
}),
});
export type TEnvelopeEditorSettings = z.infer<typeof ZEnvelopeEditorSettingsSchema>;
/**
* The default editor configuration for normal flows.
*/
export const DEFAULT_EDITOR_CONFIG: EnvelopeEditorConfig = {
general: {
allowConfigureEnvelopeTitle: true,
allowUploadAndRecipientStep: true,
allowAddFieldsStep: true,
allowPreviewStep: true,
minimizeLeftSidebar: false,
},
settings: {
allowConfigureSignatureTypes: true,
allowConfigureLanguage: true,
allowConfigureDateFormat: true,
allowConfigureTimezone: true,
allowConfigureRedirectUrl: true,
allowConfigureDistribution: true,
},
actions: {
allowAttachments: true,
allowDistributing: true,
allowDirectLink: true,
allowDuplication: true,
allowDownloadPDF: true,
allowDeletion: true,
allowReturnToPreviousPage: true,
},
envelopeItems: {
allowConfigureTitle: true,
allowConfigureOrder: true,
allowUpload: true,
allowDelete: true,
},
recipients: {
allowAIDetection: true,
allowConfigureSigningOrder: true,
allowConfigureDictateNextSigner: true,
allowApproverRole: true,
allowViewerRole: true,
allowCCerRole: true,
allowAssistantRole: true,
},
fields: {
allowAIDetection: true,
},
};
/**
* The default configuration for the embedded editor. This is merged with whatever is provided
* by the embedded hash.
*
* This is duplicated in the embedded repo playground
*
* /playground/src/components/embedddings/envelope-feature.ts
*/
export const DEFAULT_EMBEDDED_EDITOR_CONFIG = {
general: {
allowConfigureEnvelopeTitle: true,
allowUploadAndRecipientStep: true,
allowAddFieldsStep: true,
allowPreviewStep: true,
minimizeLeftSidebar: true,
},
settings: {
allowConfigureSignatureTypes: true,
allowConfigureLanguage: true,
allowConfigureDateFormat: true,
allowConfigureTimezone: true,
allowConfigureRedirectUrl: true,
allowConfigureDistribution: true,
},
actions: {
allowAttachments: true,
allowDistributing: false,
allowDirectLink: false,
allowDuplication: false,
allowDownloadPDF: false,
allowDeletion: false,
allowReturnToPreviousPage: true,
},
envelopeItems: {
allowConfigureTitle: true,
allowConfigureOrder: true,
allowUpload: true,
allowDelete: true,
},
recipients: {
allowAIDetection: false,
allowConfigureSigningOrder: true,
allowConfigureDictateNextSigner: true,
allowApproverRole: true,
allowViewerRole: true,
allowCCerRole: true,
allowAssistantRole: true,
},
fields: {
allowAIDetection: false,
},
} as const satisfies EnvelopeEditorConfig;
export const ZEmbedCreateEnvelopeAuthoringSchema = ZBaseEmbedDataSchema.extend({
externalId: z.string().optional(),
type: z.nativeEnum(EnvelopeType),
features: z.object({}).passthrough().optional().default(DEFAULT_EMBEDDED_EDITOR_CONFIG),
});
export const ZEmbedEditEnvelopeAuthoringSchema = ZBaseEmbedDataSchema.extend({
externalId: z.string().optional(),
features: z.object({}).passthrough().optional().default(DEFAULT_EMBEDDED_EDITOR_CONFIG),
});
export type TEmbedCreateEnvelopeAuthoring = z.infer<typeof ZEmbedCreateEnvelopeAuthoringSchema>;
export type TEmbedEditEnvelopeAuthoring = z.infer<typeof ZEmbedEditEnvelopeAuthoringSchema>;
/**
* A subset of the full envelope response schema used for the envelope editor.
*
* Internal usage only.
*/
export const ZEditorEnvelopeSchema = EnvelopeSchema.pick({
internalVersion: true,
type: true,
status: true,
source: true,
visibility: true,
templateType: true,
id: true,
secondaryId: true,
externalId: true,
completedAt: true,
deletedAt: true,
title: true,
authOptions: true,
publicTitle: true,
publicDescription: true,
userId: true,
teamId: true,
folderId: true,
}).extend({
documentMeta: DocumentMetaSchema.pick({
signingOrder: true,
distributionMethod: true,
id: true,
subject: true,
message: true,
timezone: true,
dateFormat: true,
redirectUrl: true,
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
allowDictateNextSigner: true,
language: true,
emailSettings: true,
emailId: true,
emailReplyTo: true,
}),
recipients: ZEnvelopeRecipientLiteSchema.array(),
fields: ZEnvelopeFieldSchema.array(),
envelopeItems: EnvelopeItemSchema.pick({
envelopeId: true,
id: true,
title: true,
order: true,
})
.extend({
// Only used for embedded.
data: z.instanceof(Uint8Array).optional(),
})
.array(),
directLink: TemplateDirectLinkSchema.pick({
directTemplateRecipientId: true,
enabled: true,
id: true,
token: true,
}).nullable(),
team: TeamSchema.pick({
id: true,
url: true,
}),
user: z.object({
id: z.number(),
name: z.string(),
email: z.string(),
}),
});
export type TEditorEnvelope = z.infer<typeof ZEditorEnvelopeSchema>;
export type EnvelopeEditorConfig = TEnvelopeEditorSettings & {
embeded?: {
presignToken: string;
mode: 'create' | 'edit';
onCreate?: (envelope: Omit<TEditorEnvelope, 'id'>) => void;
onUpdate?: (envelope: TEditorEnvelope) => void;
customBrandingLogo?: boolean;
};
};
+1 -1
View File
@@ -419,4 +419,4 @@ export const ZEnvelopeFieldAndMetaSchema = z.discriminatedUnion('type', [
}), }),
]); ]);
type TEnvelopeFieldAndMeta = z.infer<typeof ZEnvelopeFieldAndMetaSchema>; export type TEnvelopeFieldAndMeta = z.infer<typeof ZEnvelopeFieldAndMetaSchema>;
+1 -7
View File
@@ -1,4 +1,3 @@
import { msg } from '@lingui/core/macro';
import { z } from 'zod'; import { z } from 'zod';
import { RecipientSchema } from '@documenso/prisma/generated/zod/modelSchema/RecipientSchema'; import { RecipientSchema } from '@documenso/prisma/generated/zod/modelSchema/RecipientSchema';
@@ -114,10 +113,5 @@ export const ZEnvelopeRecipientManySchema = ZRecipientManySchema.omit({
export const ZRecipientEmailSchema = z.union([ export const ZRecipientEmailSchema = z.union([
z.literal(''), z.literal(''),
z z.string().trim().toLowerCase().email({ message: 'Invalid email' }).max(254),
.string()
.trim()
.toLowerCase()
.email({ message: msg`Invalid email`.id })
.max(254),
]); ]);
@@ -1,13 +1,19 @@
import { PDF } from '@libpdf/core'; import { PDF } from '@libpdf/core';
import { DocumentDataType } from '@prisma/client'; import { DocumentDataType } from '@prisma/client';
import { base64 } from '@scure/base'; import { base64 } from '@scure/base';
import pMap from 'p-map';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import type { TDocumentDataMeta } from '@documenso/lib/types/document-data';
import { ZDocumentDataMetaSchema } from '@documenso/lib/types/document-data';
import { env } from '@documenso/lib/utils/env'; import { env } from '@documenso/lib/utils/env';
import { prisma } from '@documenso/prisma';
import { AppError } from '../../errors/app-error'; import { AppError } from '../../errors/app-error';
import { pdfToImages } from '../../server-only/ai/pdf-to-images';
import { createDocumentData } from '../../server-only/document-data/create-document-data'; import { createDocumentData } from '../../server-only/document-data/create-document-data';
import { normalizePdf } from '../../server-only/pdf/normalize-pdf'; import { normalizePdf } from '../../server-only/pdf/normalize-pdf';
import { getEnvelopeItemPageImageS3Key } from '../../utils/envelope-images';
import { uploadS3File } from './server-actions'; import { uploadS3File } from './server-actions';
type File = { type File = {
@@ -41,7 +47,73 @@ export const putPdfFileServerSide = async (file: File) => {
const { type, data } = await putFileServerSide(file); const { type, data } = await putFileServerSide(file);
return await createDocumentData({ type, data }); const newDocumentData = await createDocumentData({ type, data });
void extractAndStorePdfImages(arrayBuffer, newDocumentData.id).catch((err) => {
console.error(`Error extracting and storing PDF images: ${err}`);
// Do nothing.
});
return newDocumentData;
};
/**
* Extract and stores page images and metadata to S3.
*/
export const extractAndStorePdfImages = async (
arrayBuffer: ArrayBuffer,
documentDataId: string,
): Promise<TDocumentDataMeta['pages']> => {
const images = await pdfToImages(new Uint8Array(arrayBuffer));
const pageMetadata = images.map((image) => ({
originalWidth: image.originalWidth,
originalHeight: image.originalHeight,
scale: image.scale,
scaledWidth: image.scaledWidth,
scaledHeight: image.scaledHeight,
}));
const documentDataMetadata = ZDocumentDataMetaSchema.parse({
pages: pageMetadata,
} satisfies TDocumentDataMeta);
// Only update metadata (page dimensions). Never update type, data, or initialData:
// DocumentData content is immutable so cache keys (documentDataId) stay valid.
const updatedDocumentData = await prisma.documentData.update({
where: { id: documentDataId },
data: {
metadata: documentDataMetadata,
},
});
if (
env('NEXT_PUBLIC_UPLOAD_TRANSPORT') === 's3' &&
updatedDocumentData.type === DocumentDataType.S3_PATH
) {
await pMap(
images,
async (image) => {
const imageBlob = new Blob([new Uint8Array(image.image)], { type: 'image/jpeg' });
const pageIndex = image.pageIndex;
const s3Key = getEnvelopeItemPageImageS3Key(updatedDocumentData.data, pageIndex);
const imageFile = new File([imageBlob], `${pageIndex}.jpeg`, {
type: 'image/jpeg',
});
const { key } = await uploadS3File(imageFile, s3Key);
return key;
},
{ concurrency: 100 },
);
}
return pageMetadata;
}; };
/** /**
@@ -63,10 +135,18 @@ export const putNormalizedPdfFileServerSide = async (
arrayBuffer: async () => Promise.resolve(normalized), arrayBuffer: async () => Promise.resolve(normalized),
}); });
return await createDocumentData({ const newDocumentData = await createDocumentData({
type: documentData.type, type: documentData.type,
data: documentData.data, data: documentData.data,
}); });
void extractAndStorePdfImages(normalized, newDocumentData.id).catch((err) => {
console.error(`Error extracting and storing PDF images: ${err}`);
// Do nothing.
});
return newDocumentData;
}; };
/** /**
@@ -91,13 +91,13 @@ export const getPresignGetUrl = async (key: string) => {
/** /**
* Uploads a file to S3. * Uploads a file to S3.
*/ */
export const uploadS3File = async (file: File) => { export const uploadS3File = async (file: File, keyOverride?: string) => {
const client = getS3Client(); const client = getS3Client();
// Get the basename and extension for the file // Get the basename and extension for the file
const { name, ext } = path.parse(file.name); const { name, ext } = path.parse(file.name);
const key = `${alphaid(12)}/${slugify(name)}${ext}`; const key = keyOverride ?? `${alphaid(12)}/${slugify(name)}${ext}`;
const fileBuffer = await file.arrayBuffer(); const fileBuffer = await file.arrayBuffer();
@@ -124,6 +124,28 @@ export const deleteS3File = async (key: string) => {
); );
}; };
/**
* Be careful about using this function as we don't allow the
* frontend to ever pull a file from S3 directly.
*/
export const UNSAFE_getS3File = async (key: string) => {
// Additional safeguard to prevent path traversal.
if (key.includes('..') || key.startsWith('/')) {
throw new Error('Invalid S3 key');
}
const client = getS3Client();
const response = await client.send(
new GetObjectCommand({
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
}),
);
return response.Body;
};
const getS3Client = () => { const getS3Client = () => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT'); const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
+142
View File
@@ -0,0 +1,142 @@
import type { EnvelopeEditorConfig } from '../types/envelope-editor';
import { DEFAULT_EMBEDDED_EDITOR_CONFIG } from '../types/envelope-editor';
export const PRESIGNED_ENVELOPE_ITEM_ID_PREFIX = 'PRESIGNED_';
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
/**
* Takes parsed `features` from the embedding hash and an `embeded` config,
* and produces a complete `EnvelopeEditorConfig` with sensible embedded-mode defaults.
*
* Any explicitly provided feature flag overrides the embedded default.
*/
export function buildEmbeddedEditorOptions(
features: DeepPartial<EnvelopeEditorConfig>,
embeded: EnvelopeEditorConfig['embeded'],
): EnvelopeEditorConfig {
return {
embeded,
...buildEmbeddedFeatures(features),
};
}
export const buildEmbeddedFeatures = (
features: DeepPartial<EnvelopeEditorConfig>,
): EnvelopeEditorConfig => {
return {
general: {
allowConfigureEnvelopeTitle:
features.general?.allowConfigureEnvelopeTitle ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowConfigureEnvelopeTitle,
allowUploadAndRecipientStep:
features.general?.allowUploadAndRecipientStep ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowUploadAndRecipientStep,
allowAddFieldsStep:
features.general?.allowAddFieldsStep ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowAddFieldsStep,
allowPreviewStep:
features.general?.allowPreviewStep ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowPreviewStep,
minimizeLeftSidebar:
features.general?.minimizeLeftSidebar ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.minimizeLeftSidebar,
},
settings:
features.settings !== null
? {
allowConfigureSignatureTypes:
features.settings?.allowConfigureSignatureTypes ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureSignatureTypes,
allowConfigureLanguage:
features.settings?.allowConfigureLanguage ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureLanguage,
allowConfigureDateFormat:
features.settings?.allowConfigureDateFormat ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureDateFormat,
allowConfigureTimezone:
features.settings?.allowConfigureTimezone ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureTimezone,
allowConfigureRedirectUrl:
features.settings?.allowConfigureRedirectUrl ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureRedirectUrl,
allowConfigureDistribution:
features.settings?.allowConfigureDistribution ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureDistribution,
}
: null,
actions: {
allowAttachments:
features.actions?.allowAttachments ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowAttachments,
allowDistributing:
features.actions?.allowDistributing ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDistributing,
allowDirectLink:
features.actions?.allowDirectLink ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDirectLink,
allowDuplication:
features.actions?.allowDuplication ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDuplication,
allowDownloadPDF:
features.actions?.allowDownloadPDF ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDownloadPDF,
allowDeletion:
features.actions?.allowDeletion ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDeletion,
allowReturnToPreviousPage:
features.actions?.allowReturnToPreviousPage ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowReturnToPreviousPage,
},
envelopeItems:
features.envelopeItems !== null
? {
allowConfigureTitle:
features.envelopeItems?.allowConfigureTitle ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowConfigureTitle,
allowConfigureOrder:
features.envelopeItems?.allowConfigureOrder ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowConfigureOrder,
allowUpload:
features.envelopeItems?.allowUpload ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowUpload,
allowDelete:
features.envelopeItems?.allowDelete ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowDelete,
}
: null,
recipients:
features.recipients !== null
? {
allowAIDetection:
features.recipients?.allowAIDetection ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowAIDetection,
allowConfigureSigningOrder:
features.recipients?.allowConfigureSigningOrder ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowConfigureSigningOrder,
allowConfigureDictateNextSigner:
features.recipients?.allowConfigureDictateNextSigner ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowConfigureDictateNextSigner,
allowApproverRole:
features.recipients?.allowApproverRole ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowApproverRole,
allowViewerRole:
features.recipients?.allowViewerRole ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowViewerRole,
allowCCerRole:
features.recipients?.allowCCerRole ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowCCerRole,
allowAssistantRole:
features.recipients?.allowAssistantRole ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowAssistantRole,
}
: null,
fields: {
allowAIDetection:
features.fields?.allowAIDetection ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.fields.allowAIDetection,
},
};
};
+77
View File
@@ -0,0 +1,77 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import type { DocumentDataVersion } from '../types/document-data';
export type EnvelopeItemPageImageUrlOptions = {
envelopeId: string;
envelopeItemId: string;
documentDataId: string;
pageIndex: number;
token: string | undefined;
presignToken?: string | undefined;
version: DocumentDataVersion;
};
/**
* Generates the URL for fetching a single page of a PDF as an image.
*/
export const getEnvelopeItemPageImageUrl = (options: EnvelopeItemPageImageUrlOptions): string => {
const { envelopeId, envelopeItemId, documentDataId, pageIndex, token, presignToken, version } =
options;
const partialUrl = `envelope/${envelopeId}/envelopeItem/${envelopeItemId}/dataId/${documentDataId}/${version}/${pageIndex}/image.jpeg`;
// Recipient token endpoint.
if (token) {
return `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/${partialUrl}`;
}
// Endpoint authenticated by session or presigned token.
const baseUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/${partialUrl}`;
if (presignToken) {
return `${baseUrl}?presignToken=${presignToken}`;
}
return baseUrl;
};
export type EnvelopeItemMetaUrlOptions = {
envelopeId: string;
token: string | undefined;
presignToken?: string | undefined;
};
/**
* Generates the URL for fetching envelope metadata (page counts and dimensions).
*/
export const getEnvelopeItemMetaUrl = (options: EnvelopeItemMetaUrlOptions): string => {
const { envelopeId, token, presignToken } = options;
// Recipient token endpoint.
if (token) {
return `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelope/${envelopeId}/meta`;
}
// Endpoint authenticated by session or presigned token.
const baseUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/meta`;
if (presignToken) {
return `${baseUrl}?presignToken=${presignToken}`;
}
return baseUrl;
};
export const getEnvelopeItemPageImageS3Key = (
documentDataId: string,
pageIndex: number,
): string => {
// Sanity check incase someone passes in a base64 PDF somehow.
if (documentDataId.length > 100) {
throw new Error('Document data is too long to be a valid S3 key');
}
const baseKey = documentDataId.split('/')[0];
return `${baseKey}/${pageIndex}.jpeg`;
};
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "DocumentData" ADD COLUMN "metadata" JSONB;
+2
View File
@@ -487,11 +487,13 @@ enum DocumentSigningOrder {
SEQUENTIAL SEQUENTIAL
} }
/// @zod.import(["import { ZDocumentDataMetaSchema } from '@documenso/lib/types/document-data';"])
model DocumentData { model DocumentData {
id String @id @default(cuid()) id String @id @default(cuid())
type DocumentDataType type DocumentDataType
data String data String
initialData String initialData String
metadata Json? /// [DocumentDataMeta] @zod.custom.use(ZDocumentDataMetaSchema)
envelopeItem EnvelopeItem? envelopeItem EnvelopeItem?
} }
+2
View File
@@ -4,6 +4,7 @@ import type {
TDocumentAuthOptions, TDocumentAuthOptions,
TRecipientAuthOptions, TRecipientAuthOptions,
} from '@documenso/lib/types/document-auth'; } from '@documenso/lib/types/document-auth';
import type { TDocumentDataMeta } from '@documenso/lib/types/document-data';
import type { TDocumentEmailSettings } from '@documenso/lib/types/document-email'; import type { TDocumentEmailSettings } from '@documenso/lib/types/document-email';
import type { TDocumentFormValues } from '@documenso/lib/types/document-form-values'; import type { TDocumentFormValues } from '@documenso/lib/types/document-form-values';
import type { TEnvelopeAttachmentType } from '@documenso/lib/types/envelope-attachment'; import type { TEnvelopeAttachmentType } from '@documenso/lib/types/envelope-attachment';
@@ -29,6 +30,7 @@ declare global {
type EnvelopeAttachmentType = TEnvelopeAttachmentType; type EnvelopeAttachmentType = TEnvelopeAttachmentType;
type DefaultRecipient = TDefaultRecipient; type DefaultRecipient = TDefaultRecipient;
type DocumentDataMeta = TDocumentDataMeta;
} }
} }
@@ -28,7 +28,14 @@ export const getDocumentByTokenRoute = authenticatedProcedure
include: { include: {
envelopeItems: { envelopeItems: {
include: { include: {
documentData: true, documentData: {
select: {
id: true,
type: true,
data: true,
initialData: true,
},
},
}, },
}, },
}, },
@@ -7,7 +7,12 @@ export const ZGetDocumentByTokenRequestSchema = z.object({
}); });
export const ZGetDocumentByTokenResponseSchema = z.object({ export const ZGetDocumentByTokenResponseSchema = z.object({
documentData: DocumentDataSchema, documentData: DocumentDataSchema.pick({
id: true,
type: true,
data: true,
initialData: true,
}),
}); });
export type TGetDocumentByTokenRequest = z.infer<typeof ZGetDocumentByTokenRequestSchema>; export type TGetDocumentByTokenRequest = z.infer<typeof ZGetDocumentByTokenRequestSchema>;
@@ -1,17 +1,21 @@
import { router } from '../trpc'; import { router } from '../trpc';
import { createEmbeddingDocumentRoute } from './create-embedding-document'; import { createEmbeddingDocumentRoute } from './create-embedding-document';
import { createEmbeddingEnvelopeRoute } from './create-embedding-envelope';
import { createEmbeddingPresignTokenRoute } from './create-embedding-presign-token'; import { createEmbeddingPresignTokenRoute } from './create-embedding-presign-token';
import { createEmbeddingTemplateRoute } from './create-embedding-template'; import { createEmbeddingTemplateRoute } from './create-embedding-template';
import { getMultiSignDocumentRoute } from './get-multi-sign-document'; import { getMultiSignDocumentRoute } from './get-multi-sign-document';
import { updateEmbeddingDocumentRoute } from './update-embedding-document'; import { updateEmbeddingDocumentRoute } from './update-embedding-document';
import { updateEmbeddingEnvelopeRoute } from './update-embedding-envelope';
import { updateEmbeddingTemplateRoute } from './update-embedding-template'; import { updateEmbeddingTemplateRoute } from './update-embedding-template';
import { verifyEmbeddingPresignTokenRoute } from './verify-embedding-presign-token'; import { verifyEmbeddingPresignTokenRoute } from './verify-embedding-presign-token';
export const embeddingPresignRouter = router({ export const embeddingPresignRouter = router({
createEmbeddingPresignToken: createEmbeddingPresignTokenRoute, createEmbeddingPresignToken: createEmbeddingPresignTokenRoute,
verifyEmbeddingPresignToken: verifyEmbeddingPresignTokenRoute, verifyEmbeddingPresignToken: verifyEmbeddingPresignTokenRoute,
createEmbeddingEnvelope: createEmbeddingEnvelopeRoute,
createEmbeddingDocument: createEmbeddingDocumentRoute, createEmbeddingDocument: createEmbeddingDocumentRoute,
createEmbeddingTemplate: createEmbeddingTemplateRoute, createEmbeddingTemplate: createEmbeddingTemplateRoute,
updateEmbeddingEnvelope: updateEmbeddingEnvelopeRoute,
updateEmbeddingDocument: updateEmbeddingDocumentRoute, updateEmbeddingDocument: updateEmbeddingDocumentRoute,
updateEmbeddingTemplate: updateEmbeddingTemplateRoute, updateEmbeddingTemplate: updateEmbeddingTemplateRoute,
// applyMultiSignSignature: applyMultiSignSignatureRoute, // applyMultiSignSignature: applyMultiSignSignatureRoute,
@@ -0,0 +1,41 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { createEnvelopeRouteCaller } from '../envelope-router/create-envelope';
import { procedure } from '../trpc';
import {
ZCreateEmbeddingEnvelopeRequestSchema,
ZCreateEmbeddingEnvelopeResponseSchema,
} from './create-embedding-envelope.types';
export const createEmbeddingEnvelopeRoute = procedure
.input(ZCreateEmbeddingEnvelopeRequestSchema)
.output(ZCreateEmbeddingEnvelopeResponseSchema)
.mutation(async ({ input, ctx }) => {
const { req } = ctx;
const authorizationHeader = req.headers.get('authorization');
const [presignToken] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
if (!presignToken) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'No presign token provided',
});
}
const apiToken = await verifyEmbeddingPresignToken({ token: presignToken });
const { userId, teamId } = apiToken;
return await createEnvelopeRouteCaller({
userId,
teamId,
input,
options: {
// Default recipients should be added on the frontend automatically for embeds.
bypassDefaultRecipients: true,
},
apiRequestMetadata: ctx.metadata,
});
});
@@ -0,0 +1,8 @@
import {
ZCreateEnvelopeRequestSchema,
ZCreateEnvelopeResponseSchema,
} from '../envelope-router/create-envelope.types';
export const ZCreateEmbeddingEnvelopeRequestSchema = ZCreateEnvelopeRequestSchema;
export const ZCreateEmbeddingEnvelopeResponseSchema = ZCreateEnvelopeResponseSchema;
@@ -24,7 +24,9 @@ export const ZCreateEmbeddingPresignTokenRequestSchema = z.object({
scope: z scope: z
.string() .string()
.optional() .optional()
.describe('Resource restriction. Example: documentId:1, templateId:2'), .describe(
'Resource restriction. V1 embeds only support documentId:1, templateId:2. V2 embeds only support envelopeId:envelope_123',
),
}); });
export const ZCreateEmbeddingPresignTokenResponseSchema = z.object({ export const ZCreateEmbeddingPresignTokenResponseSchema = z.object({
@@ -0,0 +1,389 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import pMap from 'p-map';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { updateEnvelope } from '@documenso/lib/server-only/envelope/update-envelope';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template';
import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients';
import { setTemplateRecipients } from '@documenso/lib/server-only/recipient/set-template-recipients';
import { nanoid } from '@documenso/lib/universal/id';
import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { UNSAFE_createEnvelopeItems } from '@documenso/trpc/server/envelope-router/create-envelope-items';
import { UNSAFE_deleteEnvelopeItem } from '@documenso/trpc/server/envelope-router/delete-envelope-item';
import { UNSAFE_updateEnvelopeItems } from '@documenso/trpc/server/envelope-router/update-envelope-items';
import { procedure } from '../trpc';
import {
ZUpdateEmbeddingEnvelopeRequestSchema,
ZUpdateEmbeddingEnvelopeResponseSchema,
} from './update-embedding-envelope.types';
export const updateEmbeddingEnvelopeRoute = procedure
.input(ZUpdateEmbeddingEnvelopeRequestSchema)
.output(ZUpdateEmbeddingEnvelopeResponseSchema)
.mutation(async ({ input, ctx }) => {
const { payload, files } = input;
const { envelopeId, data, meta } = payload;
ctx.logger.info({
input: {
envelopeId,
},
});
const authorizationHeader = ctx.req.headers.get('authorization');
const [presignToken] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
if (!presignToken) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'No presign token provided',
});
}
const apiToken = await verifyEmbeddingPresignToken({
token: presignToken,
scope: `envelopeId:${envelopeId}`,
});
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: envelopeId,
},
type: null, // Allow updating both documents and templates.
userId: apiToken.userId,
teamId: apiToken.teamId,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
envelopeItems: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
recipients: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
if (envelope.status === DocumentStatus.COMPLETED) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot modify completed envelope',
});
}
// Step 1: Update the envelope items.
const envelopeItemsToUpdate: EnvelopeItemUpdateOptions[] = [];
const envelopeItemsToCreate: EnvelopeItemCreateOptions[] = [];
// Sort and group envelope items to update and create.
data.envelopeItems.forEach((item) => {
const isNewEnvelopeItem = item.id.startsWith(PRESIGNED_ENVELOPE_ITEM_ID_PREFIX);
// Handle existing envelope items.
if (!isNewEnvelopeItem) {
const envelopeItem = envelope.envelopeItems.find(
(envelopeItem) => envelopeItem.id === item.id,
);
if (!envelopeItem) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope item not found',
});
}
const hasEnvelopeItemChanged =
envelopeItem.title !== item.title || envelopeItem.order !== item.order;
if (hasEnvelopeItemChanged) {
envelopeItemsToUpdate.push({
envelopeItemId: envelopeItem.id,
title: item.title,
order: item.order,
});
}
// Return to continue loop.
return;
}
const newEnvelopeItemFile = item.index !== undefined ? files[item.index] : undefined;
if (!newEnvelopeItemFile) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid envelope item index',
});
}
// Handle not yet uploaded envelope items.
envelopeItemsToCreate.push({
embeddedEnvelopeItemId: item.id,
title: item.title,
order: item.order,
file: newEnvelopeItemFile,
});
});
// Delete envelope items that have been removed from the payload.
const envelopeItemIdsToDelete = envelope.envelopeItems
.filter((item) => !data.envelopeItems.some((i) => i.id === item.id))
.map((item) => item.id);
const willEnvelopeItemsBeModified =
envelopeItemIdsToDelete.length > 0 ||
envelopeItemsToCreate.length > 0 ||
envelopeItemsToUpdate.length > 0;
const organisationClaim = envelope.team.organisation.organisationClaim;
const resultingEnvelopeItemCount =
envelope.envelopeItems.length - envelopeItemIdsToDelete.length + envelopeItemsToCreate.length;
if (resultingEnvelopeItemCount > organisationClaim.envelopeItemCount) {
throw new AppError('ENVELOPE_ITEM_LIMIT_EXCEEDED', {
message: `You cannot upload more than ${organisationClaim.envelopeItemCount} envelope items`,
statusCode: 400,
});
}
// Should be safe to use stale envelope.recipients since only signed or sent
// recipients affect the outcome.
if (willEnvelopeItemsBeModified && !canEnvelopeItemsBeModified(envelope, envelope.recipients)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope item is not editable',
});
}
if (envelopeItemIdsToDelete.length > 0) {
console.log('[DEBUG]: Deleting envelope items', envelopeItemIdsToDelete);
await pMap(
envelopeItemIdsToDelete,
async (envelopeItemId) => {
await UNSAFE_deleteEnvelopeItem({
envelopeId: envelope.id,
envelopeItemId,
user: apiToken.user,
apiRequestMetadata: ctx.metadata,
});
},
{ concurrency: 2 },
);
}
// Mapping for the client side embedded prefix envelope item IDs to the real envelope item IDs.
const embeddedEnvelopeItemIdMapping: Record<string, string> = {};
// Create new envelope items.
if (envelopeItemsToCreate.length > 0) {
console.log('[DEBUG]: Creating envelope items', envelopeItemsToCreate);
const createdEnvelopeItems = await UNSAFE_createEnvelopeItems({
files: envelopeItemsToCreate.map((item) => ({
clientId: item.embeddedEnvelopeItemId,
file: item.file,
orderOverride: item.order,
})),
envelope: {
...envelope,
// Purposefully putting empty recipients here since placeholders should automatically injected on the client side for
// embeded purposes. Todo: Embeds - (Not implemeneted yet)
recipients: [],
},
user: {
id: apiToken.user.id,
name: apiToken.user.name,
email: apiToken.user.email,
},
apiRequestMetadata: ctx.metadata,
});
// Build the map from the envelope item order.
createdEnvelopeItems.forEach((item) => {
if (!item.clientId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Client ID not found',
});
}
embeddedEnvelopeItemIdMapping[item.clientId] = item.id;
});
}
if (envelopeItemsToUpdate.length > 0) {
console.log('[DEBUG]: Updating envelope items', envelopeItemsToUpdate);
await UNSAFE_updateEnvelopeItems({
envelopeId: envelope.id,
data: envelopeItemsToUpdate,
});
}
// Step 2: Update the general envelope data and meta.
await updateEnvelope({
userId: apiToken.userId,
teamId: apiToken.teamId,
id: {
type: 'envelopeId',
id: envelope.id,
},
data: {
title: data.title,
externalId: data.externalId,
visibility: data.visibility,
globalAccessAuth: data.globalAccessAuth,
globalActionAuth: data.globalActionAuth,
folderId: data.folderId,
},
meta,
requestMetadata: ctx.metadata,
});
// Step 3: Update the recipients
const recipientsWithClientId = data.recipients.map((recipient) => ({
...recipient,
clientId: nanoid(),
}));
const { recipients: updatedRecipients } = await match(envelope.type)
.with(EnvelopeType.DOCUMENT, async () =>
setDocumentRecipients({
userId: apiToken.userId,
teamId: apiToken.teamId,
id: {
type: 'envelopeId',
id: envelope.id,
},
recipients: recipientsWithClientId.map((recipient) => ({
id: recipient.id,
clientId: recipient.clientId,
email: recipient.email,
name: recipient.name ?? '',
role: recipient.role,
signingOrder: recipient.signingOrder,
})),
requestMetadata: ctx.metadata,
}),
)
.with(EnvelopeType.TEMPLATE, async () =>
setTemplateRecipients({
userId: apiToken.userId,
teamId: apiToken.teamId,
id: {
type: 'envelopeId',
id: envelope.id,
},
recipients: recipientsWithClientId.map((recipient) => ({
id: recipient.id,
clientId: recipient.clientId,
email: recipient.email,
name: recipient.name ?? '',
role: recipient.role,
signingOrder: recipient.signingOrder,
})),
}),
)
.exhaustive();
// Step 4: Update the fields.
const fields = recipientsWithClientId.flatMap((recipient) => {
const recipientId = updatedRecipients.find((r) => r.clientId === recipient.clientId)?.id;
if (!recipientId) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Recipient not found',
});
}
return (recipient.fields ?? []).map((field) => {
let envelopeItemId = field.envelopeItemId;
if (envelopeItemId.startsWith(PRESIGNED_ENVELOPE_ITEM_ID_PREFIX)) {
envelopeItemId = embeddedEnvelopeItemIdMapping[envelopeItemId];
}
if (!envelopeItemId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope item not found',
});
}
return {
...field,
recipientId,
envelopeItemId,
};
});
});
await match(envelope.type)
.with(EnvelopeType.DOCUMENT, async () =>
setFieldsForDocument({
userId: apiToken.userId,
teamId: apiToken.teamId,
id: {
type: 'envelopeId',
id: envelopeId,
},
fields: fields.map((field) => ({
...field,
pageNumber: field.page,
pageX: field.positionX,
pageY: field.positionY,
pageWidth: field.width,
pageHeight: field.height,
})),
requestMetadata: ctx.metadata,
}),
)
.with(EnvelopeType.TEMPLATE, async () =>
setFieldsForTemplate({
userId: apiToken.userId,
teamId: apiToken.teamId,
id: {
type: 'envelopeId',
id: envelopeId,
},
fields: fields.map((field) => ({
...field,
pageNumber: field.page,
pageX: field.positionX,
pageY: field.positionY,
pageWidth: field.width,
pageHeight: field.height,
})),
}),
)
.exhaustive();
});
type EnvelopeItemUpdateOptions = {
envelopeItemId: string;
title?: string;
order?: number;
};
type EnvelopeItemCreateOptions = {
embeddedEnvelopeItemId: string;
title: string;
order: number;
file: File;
};
@@ -0,0 +1,94 @@
import { z } from 'zod';
import { zfd } from 'zod-form-data';
import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZDocumentMetaUpdateSchema } from '@documenso/lib/types/document-meta';
import {
ZClampedFieldHeightSchema,
ZClampedFieldPositionXSchema,
ZClampedFieldPositionYSchema,
ZClampedFieldWidthSchema,
ZFieldPageNumberSchema,
} from '@documenso/lib/types/field';
import { ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { ZSetEnvelopeRecipientSchema } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
import { zodFormData } from '../../utils/zod-form-data';
import {
ZDocumentExternalIdSchema,
ZDocumentTitleSchema,
ZDocumentVisibilitySchema,
} from '../document-router/schema';
export const ZUpdateEmbeddingEnvelopePayloadSchema = z.object({
envelopeId: z.string(),
data: z.object({
title: ZDocumentTitleSchema.optional(),
externalId: ZDocumentExternalIdSchema.nullish(),
visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
folderId: z.string().nullish(),
/**
* The list of envelope items that are part of the envelope.
*
* Any missing IDs will be treated as deleting the envelope item.
*/
envelopeItems: z
.object({
/**
* This is not necesssarily a real id, it can be a temporary id for the envelope item.
*/
id: z.string(),
/**
* The title of the envelope item.
*/
title: z.string(),
/**
* The order of the envelope item in the envelope.
*/
order: z.number().int().min(0),
/**
* The file index for items that are not yet uploaded.
*/
index: z.number().int().min(0).optional(),
})
.array(),
/**
* This is a set command.
*/
recipients: ZSetEnvelopeRecipientSchema.extend({
fields: ZEnvelopeFieldAndMetaSchema.and(
z.object({
id: z.number().optional(),
page: ZFieldPageNumberSchema,
positionX: ZClampedFieldPositionXSchema,
positionY: ZClampedFieldPositionYSchema,
width: ZClampedFieldWidthSchema,
height: ZClampedFieldHeightSchema,
envelopeItemId: z.string(),
}),
).array(),
}).array(),
}),
meta: ZDocumentMetaUpdateSchema.optional(),
});
export const ZUpdateEmbeddingEnvelopeRequestSchema = zodFormData({
payload: zfd.json(ZUpdateEmbeddingEnvelopePayloadSchema),
files: zfd.repeatableOfType(zfd.file()),
});
export const ZUpdateEmbeddingEnvelopeResponseSchema = z.void();
export type TUpdateEmbeddingEnvelopePayload = z.infer<typeof ZUpdateEmbeddingEnvelopePayloadSchema>;
export type TUpdateEmbeddingEnvelopeRequest = z.infer<typeof ZUpdateEmbeddingEnvelopeRequestSchema>;
@@ -1,3 +1,5 @@
import type { Envelope, EnvelopeItem, Recipient } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { import {
@@ -8,6 +10,7 @@ import { findRecipientByPlaceholder } from '@documenso/lib/server-only/pdf/helpe
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf'; import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf'; import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prefixedId } from '@documenso/lib/universal/id'; import { prefixedId } from '@documenso/lib/universal/id';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
@@ -91,133 +94,186 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
}); });
} }
// For each file: normalize, extract & clean placeholders, then upload. const result = await UNSAFE_createEnvelopeItems({
const envelopeItems = await Promise.all( files: files.map((file) => ({
files.map(async (file) => { file,
let buffer = Buffer.from(await file.arrayBuffer()); })),
envelope,
if (envelope.formValues) { user: {
buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues }); id: user.id,
} name: user.name,
email: user.email,
const normalized = await normalizePdf(buffer, { },
flattenForm: envelope.type !== 'TEMPLATE', apiRequestMetadata: metadata,
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { id: documentDataId } = await putPdfFileServerSide({
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(cleanedPdf),
});
return {
title: file.name,
documentDataId,
placeholders,
};
}),
);
const currentHighestOrderValue =
envelope.envelopeItems[envelope.envelopeItems.length - 1]?.order ?? 1;
const result = await prisma.$transaction(async (tx) => {
const createdItems = await tx.envelopeItem.createManyAndReturn({
data: envelopeItems.map((item) => ({
id: prefixedId('envelope_item'),
envelopeId,
title: item.title,
documentDataId: item.documentDataId,
order: currentHighestOrderValue + 1,
})),
include: {
documentData: true,
},
});
await tx.documentAuditLog.createMany({
data: createdItems.map((item) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_CREATED,
envelopeId: envelope.id,
data: {
envelopeItemId: item.id,
envelopeItemTitle: item.title,
},
user: {
name: user.name,
email: user.email,
},
requestMetadata: metadata.requestMetadata,
}),
),
});
// Create fields from placeholders if the envelope already has recipients.
if (envelope.recipients.length > 0) {
const orderedRecipients = [...envelope.recipients].sort((a, b) => {
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) {
return aOrder - bOrder;
}
return a.id - b.id;
});
for (const uploadedItem of envelopeItems) {
if (!uploadedItem.placeholders || uploadedItem.placeholders.length === 0) {
continue;
}
const createdItem = createdItems.find(
(ci) => ci.documentDataId === uploadedItem.documentDataId,
);
if (!createdItem) {
continue;
}
const fieldsToCreate = convertPlaceholdersToFieldInputs(
uploadedItem.placeholders,
(recipientPlaceholder, placeholder) =>
findRecipientByPlaceholder(
recipientPlaceholder,
placeholder,
orderedRecipients,
orderedRecipients,
),
createdItem.id,
);
if (fieldsToCreate.length > 0) {
await tx.field.createMany({
data: fieldsToCreate.map((field) => ({
envelopeId: envelope.id,
envelopeItemId: createdItem.id,
recipientId: field.recipientId,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta || undefined,
})),
});
}
}
}
return createdItems;
}); });
return { return {
data: result, data: result,
}; };
}); });
type UnsafeCreateEnvelopeItemsOptions = {
files: {
clientId?: string;
file: File;
orderOverride?: number;
}[];
envelope: Envelope & {
envelopeItems: EnvelopeItem[];
recipients: Recipient[];
};
user: {
id: number;
name: string | null;
email: string;
};
apiRequestMetadata: ApiRequestMetadata;
};
/**
* Create envelope items.
*
* It is assumed all prior validation has been completed.
*/
export const UNSAFE_createEnvelopeItems = async ({
files,
envelope,
user,
apiRequestMetadata,
}: UnsafeCreateEnvelopeItemsOptions) => {
const currentHighestOrderValue =
envelope.envelopeItems[envelope.envelopeItems.length - 1]?.order ?? 1;
// For each file: normalize, extract & clean placeholders, then upload.
const envelopeItemsToCreate = await Promise.all(
files.map(async ({ file, orderOverride, clientId }, index) => {
let buffer = Buffer.from(await file.arrayBuffer());
if (envelope.formValues) {
buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues });
}
const normalized = await normalizePdf(buffer, {
flattenForm: envelope.type !== 'TEMPLATE',
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { id: documentDataId } = await putPdfFileServerSide({
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(cleanedPdf),
});
return {
id: prefixedId('envelope_item'),
title: file.name,
clientId,
documentDataId,
placeholders,
order: orderOverride ?? currentHighestOrderValue + index + 1,
};
}),
);
return await prisma.$transaction(async (tx) => {
const createdItems = await tx.envelopeItem.createManyAndReturn({
data: envelopeItemsToCreate.map((item) => ({
id: item.id,
envelopeId: envelope.id,
title: item.title,
documentDataId: item.documentDataId,
order: item.order,
})),
include: {
documentData: true,
},
});
await tx.documentAuditLog.createMany({
data: createdItems.map((item) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_CREATED,
envelopeId: envelope.id,
data: {
envelopeItemId: item.id,
envelopeItemTitle: item.title,
},
user: {
name: user.name,
email: user.email,
},
requestMetadata: apiRequestMetadata.requestMetadata,
}),
),
});
// Create fields from placeholders if the envelope already has recipients.
if (envelope.recipients.length > 0) {
const orderedRecipients = [...envelope.recipients].sort((a, b) => {
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) {
return aOrder - bOrder;
}
return a.id - b.id;
});
for (const uploadedItem of envelopeItemsToCreate) {
if (!uploadedItem.placeholders || uploadedItem.placeholders.length === 0) {
continue;
}
const createdItem = createdItems.find(
(ci) => ci.documentDataId === uploadedItem.documentDataId,
);
if (!createdItem) {
continue;
}
const fieldsToCreate = convertPlaceholdersToFieldInputs(
uploadedItem.placeholders,
(recipientPlaceholder, placeholder) =>
findRecipientByPlaceholder(
recipientPlaceholder,
placeholder,
orderedRecipients,
orderedRecipients,
),
createdItem.id,
);
if (fieldsToCreate.length > 0) {
await tx.field.createMany({
data: fieldsToCreate.map((field) => ({
envelopeId: envelope.id,
envelopeItemId: createdItem.id,
recipientId: field.recipientId,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta || undefined,
})),
});
}
}
}
return createdItems.map((item) => {
const clientId = envelopeItemsToCreate.find((file) => file.id === item.id)?.clientId;
return {
...item,
clientId,
};
});
});
};

Some files were not shown because too many files have changed in this diff Show More