feat: add envelope pdf replacement (#2602)

This commit is contained in:
David Nguyen 2026-03-18 22:53:28 +11:00 committed by GitHub
parent 5dcdac7ecd
commit 0b605d61c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2003 additions and 110 deletions

View file

@ -203,6 +203,7 @@ Controls how envelope items (individual files within the envelope) can be manage
| `allowConfigureOrder` | `boolean` | `true` | Allow reordering items |
| `allowUpload` | `boolean` | `true` | Allow uploading new items |
| `allowDelete` | `boolean` | `true` | Allow deleting items |
| `allowReplace` | `boolean` | `true` | Allow replacing an item's PDF |
### Recipients

View file

@ -0,0 +1,368 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { AlertTriangleIcon, FileIcon, UploadIcon, XIcon } from 'lucide-react';
import { type FileRejection, useDropzone } from 'react-dropzone';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { trpc } from '@documenso/trpc/react';
import { ZDocumentTitleSchema } from '@documenso/trpc/server/document-router/schema';
import type { TReplaceEnvelopeItemPdfPayload } from '@documenso/trpc/server/envelope-router/replace-envelope-item-pdf.types';
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
import { cn } from '@documenso/ui/lib/utils';
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 {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZEditEnvelopeItemFormSchema = z.object({
title: ZDocumentTitleSchema,
});
type TEditEnvelopeItemFormSchema = z.infer<typeof ZEditEnvelopeItemFormSchema>;
/**
* Note: This should only be visible if the envelope item is editable.
*/
export type EnvelopeItemEditDialogProps = {
envelopeItem: { id: string; title: string };
allowConfigureTitle: boolean;
trigger: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const EnvelopeItemEditDialog = ({
envelopeItem,
allowConfigureTitle,
trigger,
...props
}: EnvelopeItemEditDialogProps) => {
const { t, i18n } = useLingui();
const { toast } = useToast();
const { envelope, editorFields, setLocalEnvelope, isEmbedded } = useCurrentEnvelopeEditor();
const [isOpen, setIsOpen] = useState(false);
const [replacementFile, setReplacementFile] = useState<{ file: File; pageCount: number } | null>(
null,
);
const [isDropping, setIsDropping] = useState(false);
const form = useForm<TEditEnvelopeItemFormSchema>({
resolver: zodResolver(ZEditEnvelopeItemFormSchema),
defaultValues: {
title: envelopeItem.title,
},
});
const { mutateAsync: replaceEnvelopeItemPdf } = trpc.envelope.item.replacePdf.useMutation({
onSuccess: ({ data, fields }) => {
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.map((item) =>
item.id === data.id
? { ...item, documentDataId: data.documentDataId, title: data.title }
: item,
),
});
if (fields) {
setLocalEnvelope({ fields });
editorFields.resetForm(fields);
}
},
});
const fieldsOnExcessPages =
replacementFile !== null
? envelope.fields.filter(
(field) =>
field.envelopeItemId === envelopeItem.id && field.page > replacementFile.pageCount,
)
: [];
const onFileDropRejected = (fileRejections: FileRejection[]) => {
toast({
title: t`Upload failed`,
description: i18n._(buildDropzoneRejectionDescription(fileRejections)),
duration: 5000,
variant: 'destructive',
});
};
const onFileDrop = async (files: File[]) => {
const file = files[0];
if (!file || isDropping) {
return;
}
setIsDropping(true);
try {
const arrayBuffer = await file.arrayBuffer();
const fileData = new Uint8Array(arrayBuffer.slice(0));
const { PDF } = await import('@libpdf/core');
const pdfDoc = await PDF.load(fileData);
setReplacementFile({
file,
pageCount: pdfDoc.getPageCount(),
});
} catch (err) {
console.error(err);
toast({
title: t`Failed to read file`,
description: t`The file is not a valid PDF.`,
variant: 'destructive',
});
}
setIsDropping(false);
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: { 'application/pdf': ['.pdf'] },
maxFiles: 1,
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
disabled: form.formState.isSubmitting,
onDrop: (files) => void onFileDrop(files),
onDropRejected: onFileDropRejected,
});
const onSubmit = async (data: TEditEnvelopeItemFormSchema) => {
if (isDropping || !replacementFile) {
return;
}
try {
const { file, pageCount } = replacementFile;
if (isEmbedded) {
const arrayBuffer = await file.arrayBuffer();
const fileData = new Uint8Array(arrayBuffer.slice(0));
const remainingFields = envelope.fields.filter(
(field) => field.envelopeItemId !== envelopeItem.id || field.page <= pageCount,
);
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.map((item) =>
item.id === envelopeItem.id ? { ...item, title: data.title, data: fileData } : item,
),
fields: remainingFields,
});
editorFields.resetForm(remainingFields);
} else {
const payload = {
envelopeId: envelope.id,
envelopeItemId: envelopeItem.id,
title: data.title,
} satisfies TReplaceEnvelopeItemPdfPayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
await replaceEnvelopeItemPdf(formData);
}
setIsOpen(false);
} catch {
toast({
title: t`Failed to update item`,
description: t`Something went wrong while updating the envelope item.`,
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isOpen) {
form.reset({ title: envelopeItem.title });
setReplacementFile(null);
setIsDropping(false);
}
}, [isOpen, form, envelopeItem.title]);
const formatFileSize = (bytes: number) => {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<Dialog
{...props}
open={isOpen}
onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Edit Item</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Update the title or replace the PDF file.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Document Title</Trans>
</FormLabel>
<FormControl>
<Input
data-testid="envelope-item-edit-title-input"
placeholder={t`Document Title`}
disabled={!allowConfigureTitle}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel>
<Trans>Replace PDF</Trans>
</FormLabel>
{replacementFile ? (
<div className="mt-1.5 space-y-2">
<div
data-testid="envelope-item-edit-selected-file"
className="flex items-center justify-between rounded-md border border-border bg-muted/50 px-3 py-2"
>
<div className="flex min-w-0 items-center space-x-2">
<FileIcon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{replacementFile.file.name}
</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(replacementFile.file.size)}
{isDropping ? ' · …' : ' · '}
{!isDropping && replacementFile.pageCount !== null && (
<Plural
one="1 page"
other="# pages"
value={replacementFile.pageCount}
/>
)}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
data-testid="envelope-item-edit-clear-file"
onClick={() => {
setReplacementFile(null);
}}
>
<XIcon className="h-4 w-4" />
</Button>
</div>
{fieldsOnExcessPages.length > 0 && (
<Alert variant="warning" padding="tight">
<AlertTriangleIcon className="h-4 w-4" />
<AlertDescription data-testid="envelope-item-edit-field-warning">
<Plural
one="1 field will be deleted because the new PDF has fewer pages than the current one."
other="# fields will be deleted because the new PDF has fewer pages than the current one."
value={fieldsOnExcessPages.length}
/>
</AlertDescription>
</Alert>
)}
</div>
) : (
<div
data-testid="envelope-item-edit-dropzone"
{...getRootProps()}
className={cn(
'mt-1.5 flex cursor-pointer items-center justify-center rounded-md border border-dashed border-border px-4 py-4 transition-colors',
isDragActive
? 'border-primary/50 bg-primary/5'
: 'hover:border-muted-foreground/50 hover:bg-muted/50',
)}
>
<input {...getInputProps()} />
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<UploadIcon className="h-4 w-4" />
<span>
<Trans>Drop PDF here or click to select</Trans>
</span>
</div>
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
type="submit"
loading={form.formState.isSubmitting}
disabled={isDropping || !replacementFile}
data-testid="envelope-item-edit-update-button"
>
<Trans>Update</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View file

@ -4,10 +4,11 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Cloud, FileText, Loader, X } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { type FileRejection, useDropzone } from 'react-dropzone';
import { useFormContext } from 'react-hook-form';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -82,10 +83,10 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
}
};
const onDropRejected = () => {
const onDropRejected = (fileRejections: FileRejection[]) => {
toast({
title: _(msg`Your document failed to upload.`),
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
description: _(buildDropzoneRejectionDescription(fileRejections)),
duration: 5000,
variant: 'destructive',
});
@ -144,7 +145,7 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
<div
{...getRootProps()}
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,
'hover:bg-muted/30':
@ -193,21 +194,21 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
</FormControl>
{isLoading && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
<Loader className="text-muted-foreground h-10 w-10 animate-spin" />
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background/50">
<Loader className="h-10 w-10 animate-spin text-muted-foreground" />
</div>
)}
</div>
) : (
<div className="mt-2 rounded-lg border p-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" />
</div>
<div className="flex-1">
<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)}
</div>
</div>

View file

@ -4,6 +4,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import type { FileRejection } from 'react-dropzone';
import { useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern';
@ -11,13 +12,13 @@ import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
import { cn } from '@documenso/ui/lib/utils';
import { DocumentUploadButton as DocumentUploadButtonPrimitive } from '@documenso/ui/primitives/document-upload-button';
import {
@ -162,10 +163,10 @@ export const DocumentUploadButtonLegacy = ({
}
};
const onFileDropRejected = () => {
const onFileDropRejected = (fileRejections: FileRejection[]) => {
toast({
title: _(msg`Your document failed to upload.`),
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
description: _(buildDropzoneRejectionDescription(fileRejections)),
duration: 5000,
variant: 'destructive',
});

View file

@ -5,7 +5,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon, SparklesIcon } from 'lucide-react';
import { FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
@ -28,14 +28,17 @@ import {
type TSignatureFieldMeta,
type TTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
import { AiFieldDetectionDialog } from '~/components/dialogs/ai-field-detection-dialog';
import { EnvelopeItemEditDialog } from '~/components/dialogs/envelope-item-edit-dialog';
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
import { EditorFieldDropdownForm } from '~/components/forms/editor/editor-field-dropdown-form';
@ -85,6 +88,11 @@ export const EnvelopeEditorFieldsPage = () => {
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
const { revalidate } = useRevalidator();
const canItemsBeModified = useMemo(
() => canEnvelopeItemsBeModified(envelope, envelope.recipients),
[envelope, envelope.recipients],
);
const selectedField = useMemo(
() => structuredClone(editorFields.selectedField),
[editorFields.selectedField],
@ -157,7 +165,39 @@ export const EnvelopeEditorFieldsPage = () => {
ref={scrollableContainerRef}
>
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector className="px-0" fields={editorFields.localFields} />
<EnvelopeRendererFileSelector
className="px-0"
fields={editorFields.localFields}
renderItemAction={
editorConfig.envelopeItems !== null &&
editorConfig.envelopeItems.allowReplace &&
canItemsBeModified
? (item) => (
<div className="relative flex h-5 w-5 flex-shrink-0 items-center justify-center">
<div
className={cn(
'h-2 w-2 rounded-full transition-opacity duration-150 group-hover:opacity-0',
{ 'bg-green-500': currentEnvelopeItem?.id === item.id },
)}
/>
<EnvelopeItemEditDialog
envelopeItem={item}
allowConfigureTitle={editorConfig.envelopeItems?.allowConfigureTitle ?? false}
trigger={
<span
className="absolute inset-0 flex cursor-pointer items-center justify-center opacity-0 transition-opacity duration-150 group-hover:opacity-100"
onClick={(e) => e.stopPropagation()}
data-testid={`envelope-item-edit-button-${item.id}`}
>
<PencilIcon className="h-3.5 w-3.5" />
</span>
}
/>
</div>
)
: undefined
}
/>
{/* Document View */}
<div className="mt-4 flex h-full flex-col items-center justify-center">

View file

@ -5,9 +5,8 @@ import type { DropResult } from '@hello-pangea/dnd';
import { msg, plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
import { X } from 'lucide-react';
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
import { FileWarningIcon, GripVerticalIcon, Loader2Icon, PencilIcon, XIcon } from 'lucide-react';
import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useEnvelopeAutosave } from '@documenso/lib/client-only/hooks/use-envelope-autosave';
@ -16,10 +15,13 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
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 { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
import type { TReplaceEnvelopeItemPdfPayload } from '@documenso/trpc/server/envelope-router/replace-envelope-item-pdf.types';
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
import { Button } from '@documenso/ui/primitives/button';
import {
Card,
@ -41,13 +43,14 @@ type LocalFile = {
title: string;
envelopeItemId: string | null;
isUploading: boolean;
isReplacing: boolean;
isError: boolean;
};
export const EnvelopeEditorUploadPage = () => {
const organisation = useCurrentOrganisation();
const { t } = useLingui();
const { t, i18n } = useLingui();
const { maximumEnvelopeItemCount, remaining } = useLimits();
const { toast } = useToast();
@ -72,10 +75,36 @@ export const EnvelopeEditorUploadPage = () => {
title: item.title,
envelopeItemId: item.id,
isUploading: false,
isReplacing: false,
isError: false,
})),
);
const replacingItemIdRef = useRef<string | null>(null);
const { open: openReplaceFilePicker, getInputProps: getReplaceInputProps } = useDropzone({
accept: { 'application/pdf': ['.pdf'] },
maxFiles: 1,
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
multiple: false,
noClick: true,
noKeyboard: true,
noDrag: true,
onDrop: (acceptedFiles) => {
const file = acceptedFiles[0];
const replacingItemId = replacingItemIdRef.current;
if (file && replacingItemId) {
void onReplacePdf(replacingItemId, file);
replacingItemIdRef.current = null;
}
},
onDropRejected: (fileRejections) => void onFileDropRejected(fileRejections),
onFileDialogCancel: () => {
replacingItemIdRef.current = null;
},
});
const { mutateAsync: createEnvelopeItems, isPending: isCreatingEnvelopeItems } =
trpc.envelope.item.createMany.useMutation({
onSuccess: ({ data }) => {
@ -108,6 +137,24 @@ export const EnvelopeEditorUploadPage = () => {
},
});
const { mutateAsync: replaceEnvelopeItemPdf } = trpc.envelope.item.replacePdf.useMutation({
onSuccess: ({ data, fields }) => {
// Update the envelope item with the new documentDataId.
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.map((item) =>
item.id === data.id ? { ...item, documentDataId: data.documentDataId } : item,
),
});
// When fields were created or deleted during the replacement,
// the server returns the full updated field list.
if (fields) {
setLocalEnvelope({ fields });
editorFields.resetForm(fields);
}
},
});
const canItemsBeModified = useMemo(
() => canEnvelopeItemsBeModified(envelope, envelope.recipients),
[envelope, envelope.recipients],
@ -125,6 +172,7 @@ export const EnvelopeEditorUploadPage = () => {
title: file.name,
file,
isUploading: isEmbedded ? false : true,
isReplacing: false,
// 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,
@ -197,12 +245,77 @@ export const EnvelopeEditorUploadPage = () => {
envelopeItemId: item.id,
title: item.title,
isUploading: false,
isReplacing: false,
isError: false,
})),
);
});
};
const onReplacePdf = async (envelopeItemId: string, file: File) => {
setLocalFiles((prev) =>
prev.map((f) => (f.envelopeItemId === envelopeItemId ? { ...f, isReplacing: true } : f)),
);
try {
if (isEmbedded) {
// For embedded mode, store the file data locally on the envelope item.
// The actual replacement will happen when the embed flow submits.
const arrayBuffer = await file.arrayBuffer();
const data = new Uint8Array(arrayBuffer.slice(0));
// Count pages in the new PDF to remove out-of-bounds fields.
const { PDF } = await import('@libpdf/core');
const pdfDoc = await PDF.load(data);
const newPageCount = pdfDoc.getPageCount();
// Remove fields that are on pages beyond the new PDF's page count.
const remainingFields = envelope.fields.filter(
(field) => field.envelopeItemId !== envelopeItemId || field.page <= newPageCount,
);
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.map((item) =>
item.id === envelopeItemId ? { ...item, data } : item,
),
fields: remainingFields,
});
editorFields.resetForm(remainingFields);
return;
}
// Normal mode: upload immediately via tRPC.
const payload = {
envelopeId: envelope.id,
envelopeItemId,
} satisfies TReplaceEnvelopeItemPdfPayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const replacePromise = replaceEnvelopeItemPdf(formData);
registerPendingMutation(replacePromise);
await replacePromise;
} catch (error) {
console.error(error);
toast({
title: t`Replace failed`,
description: t`Something went wrong while replacing the PDF`,
duration: 5000,
variant: 'destructive',
});
} finally {
setLocalFiles((prev) =>
prev.map((f) => (f.envelopeItemId === envelopeItemId ? { ...f, isReplacing: false } : f)),
);
}
};
/**
* Hide the envelope item from the list on deletion.
*/
@ -346,7 +459,7 @@ export const EnvelopeEditorUploadPage = () => {
toast({
title: t`Upload failed`,
description: t`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
description: i18n._(buildDropzoneRejectionDescription(fileRejections)),
duration: 5000,
variant: 'destructive',
});
@ -354,6 +467,7 @@ export const EnvelopeEditorUploadPage = () => {
return (
<div className="mx-auto max-w-4xl space-y-6 p-8">
<input {...getReplaceInputProps()} />
<Card backdropBlur={false} className="border">
<CardHeader className="pb-3">
<CardTitle>
@ -395,6 +509,7 @@ export const EnvelopeEditorUploadPage = () => {
key={localFile.id}
isDragDisabled={
isCreatingEnvelopeItems ||
localFile.isReplacing ||
!canItemsBeModified ||
!uploadConfig?.allowConfigureOrder
}
@ -427,7 +542,8 @@ export const EnvelopeEditorUploadPage = () => {
<EnvelopeItemTitleInput
disabled={
envelope.status !== DocumentStatus.DRAFT ||
!uploadConfig?.allowConfigureTitle
!uploadConfig?.allowConfigureTitle ||
localFile.isReplacing
}
value={localFile.title}
dataTestId={`envelope-item-title-input-${localFile.id}`}
@ -445,15 +561,14 @@ export const EnvelopeEditorUploadPage = () => {
<Trans>Uploading</Trans>
) : localFile.isError ? (
<Trans>Something went wrong while uploading this file</Trans>
) : // <div className="text-xs text-gray-500">2.4 MB • 3 pages</div>
null}
) : null}
</div>
</div>
</div>
<div className="flex items-center space-x-2">
{localFile.isUploading && (
<div className="flex h-6 w-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<Loader2Icon className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
@ -463,8 +578,28 @@ export const EnvelopeEditorUploadPage = () => {
</div>
)}
{!localFile.isUploading &&
localFile.envelopeItemId &&
{localFile.envelopeItemId &&
canItemsBeModified &&
uploadConfig?.allowReplace && (
<Button
variant="ghost"
size="sm"
data-testid={`envelope-item-replace-button-${localFile.id}`}
disabled={localFile.isReplacing || localFile.isUploading}
onClick={() => {
replacingItemIdRef.current = localFile.envelopeItemId;
openReplaceFilePicker();
}}
>
{localFile.isReplacing ? (
<Loader2Icon className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<PencilIcon className="h-4 w-4" />
)}
</Button>
)}
{localFile.envelopeItemId &&
uploadConfig?.allowDelete &&
(isEmbedded ? (
<Button
@ -472,8 +607,9 @@ export const EnvelopeEditorUploadPage = () => {
size="sm"
data-testid={`envelope-item-remove-button-${localFile.id}`}
onClick={() => onFileDelete(localFile.envelopeItemId!)}
disabled={localFile.isReplacing || localFile.isUploading}
>
<X className="h-4 w-4" />
<XIcon className="h-4 w-4" />
</Button>
) : (
<EnvelopeItemDeleteDialog
@ -487,8 +623,9 @@ export const EnvelopeEditorUploadPage = () => {
variant="ghost"
size="sm"
data-testid={`envelope-item-remove-button-${localFile.id}`}
disabled={localFile.isReplacing || localFile.isUploading}
>
<X className="h-4 w-4" />
<XIcon className="h-4 w-4" />
</Button>
}
/>

View file

@ -9,6 +9,7 @@ type EnvelopeItemSelectorProps = {
secondaryText: React.ReactNode;
isSelected: boolean;
buttonProps: React.ButtonHTMLAttributes<HTMLButtonElement>;
actionSlot?: React.ReactNode;
};
export const EnvelopeItemSelector = ({
@ -17,11 +18,12 @@ export const EnvelopeItemSelector = ({
secondaryText,
isSelected,
buttonProps,
actionSlot,
}: EnvelopeItemSelectorProps) => {
return (
<button
title={typeof primaryText === 'string' ? primaryText : undefined}
className={`flex h-fit max-w-72 flex-shrink-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
className={`group flex h-fit max-w-72 flex-shrink-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
isSelected
? 'border-green-200 bg-green-50 text-green-900 dark:border-green-400/30 dark:bg-green-400/10 dark:text-green-400'
: 'border-border bg-muted/50 hover:bg-muted/70'
@ -39,11 +41,13 @@ export const EnvelopeItemSelector = ({
<div className="truncate text-sm font-medium">{primaryText}</div>
<div className="text-xs text-gray-500">{secondaryText}</div>
</div>
<div
className={cn('h-2 w-2 flex-shrink-0 rounded-full', {
'bg-green-500': isSelected,
})}
></div>
{actionSlot ?? (
<div
className={cn('h-2 w-2 flex-shrink-0 rounded-full', {
'bg-green-500': isSelected,
})}
/>
)}
</button>
);
};
@ -52,12 +56,14 @@ type EnvelopeRendererFileSelectorProps = {
fields: { envelopeItemId: string }[];
className?: string;
secondaryOverride?: React.ReactNode;
renderItemAction?: (item: { id: string; title: string }) => React.ReactNode;
};
export const EnvelopeRendererFileSelector = ({
fields,
className,
secondaryOverride,
renderItemAction,
}: EnvelopeRendererFileSelectorProps) => {
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
@ -86,6 +92,7 @@ export const EnvelopeRendererFileSelector = ({
buttonProps={{
onClick: () => setCurrentEnvelopeItem(doc.id),
}}
actionSlot={renderItemAction?.(doc)}
/>
))}
</div>

View file

@ -5,12 +5,7 @@ import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { Loader } from 'lucide-react';
import {
ErrorCode as DropzoneErrorCode,
ErrorCode,
type FileRejection,
useDropzone,
} from 'react-dropzone';
import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
import { Link, useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern';
@ -25,6 +20,7 @@ import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
@ -41,7 +37,7 @@ export const EnvelopeDropZoneWrapper = ({
type,
className,
}: EnvelopeDropZoneWrapperProps) => {
const { t } = useLingui();
const { t, i18n } = useLingui();
const { toast } = useToast();
const { user } = useSession();
const { folderId } = useParams();
@ -167,42 +163,9 @@ export const EnvelopeDropZoneWrapper = ({
return;
}
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
const { file, errors } = fileRejections[0];
if (!errors.length) {
return;
}
const errorNodes = errors.map((error, index) => (
<span key={index} className="block">
{match(error.code)
.with(ErrorCode.FileTooLarge, () => (
<Trans>File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB</Trans>
))
.with(ErrorCode.FileInvalidType, () => <Trans>Only PDF files are allowed</Trans>)
.with(ErrorCode.FileTooSmall, () => <Trans>File is too small</Trans>)
.with(ErrorCode.TooManyFiles, () => (
<Trans>Only one file can be uploaded at a time</Trans>
))
.otherwise(() => (
<Trans>Unknown error</Trans>
))}
</span>
));
const description = (
<>
<span className="font-medium">
<Trans>{file.name} couldn't be uploaded:</Trans>
</span>
{errorNodes}
</>
);
toast({
title: t`Upload failed`,
description,
description: i18n._(buildDropzoneRejectionDescription(fileRejections)),
duration: 5000,
variant: 'destructive',
});

View file

@ -11,12 +11,12 @@ import { match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
import { cn } from '@documenso/ui/lib/utils';
import { DocumentUploadButton } from '@documenso/ui/primitives/document-upload-button';
import {
@ -39,7 +39,7 @@ export type EnvelopeUploadButtonProps = {
* Upload an envelope
*/
export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUploadButtonProps) => {
const { t } = useLingui();
const { t, i18n } = useLingui();
const { toast } = useToast();
const { user } = useSession();
@ -168,7 +168,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
toast({
title: t`Upload failed`,
description: t`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
description: i18n._(buildDropzoneRejectionDescription(fileRejections)),
duration: 5000,
variant: 'destructive',
});

View file

@ -79,6 +79,7 @@ export default function EmbedPlaygroundPage() {
allowConfigureOrder: true,
allowUpload: true,
allowDelete: true,
allowReplace: true,
});
const [recipientsFeatures, setRecipientsFeatures] = useState({

View file

@ -17,7 +17,10 @@ import {
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 {
PRESIGNED_ENVELOPE_ITEM_ID_PREFIX,
buildEmbeddedEditorOptions,
} from '@documenso/lib/utils/embed-config';
import { prisma } from '@documenso/prisma';
import { trpc } from '@documenso/trpc/react';
import type { TUpdateEmbeddingEnvelopePayload } from '@documenso/trpc/server/embedding-router/update-embedding-envelope.types';
@ -172,7 +175,9 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
const files: File[] = [];
const envelopeItems = envelope.envelopeItems.map((item) => {
// Attach any new envelope item files to the request.
const isNewItem = item.id.startsWith(PRESIGNED_ENVELOPE_ITEM_ID_PREFIX);
// Attach any new or replacement envelope item files to the request.
if (item.data) {
files.push(
new File(
@ -189,7 +194,10 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
id: item.id,
title: item.title,
order: item.order,
index: item.data ? files.length - 1 : undefined,
// For new items, use `index` to reference the file.
index: isNewItem && item.data ? files.length - 1 : undefined,
// For existing items being replaced, use `replaceFileIndex`.
replaceFileIndex: !isNewItem && item.data ? files.length - 1 : undefined,
};
});

View file

@ -59,6 +59,8 @@ export default defineConfig({
'playwright-core',
'@playwright/browser-chromium',
'pdfjs-dist',
'@google-cloud/kms',
'@google-cloud/secret-manager',
],
},
optimizeDeps: {
@ -101,6 +103,8 @@ export default defineConfig({
'@napi-rs/canvas',
'@node-rs/bcrypt',
'@aws-sdk/cloudfront-signer',
'@google-cloud/kms',
'@google-cloud/secret-manager',
'nodemailer',
/playwright/,
'@playwright/browser-chromium',

View file

@ -0,0 +1,321 @@
import { type Page, expect, test } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
clickAddMyselfButton,
clickEnvelopeEditorStep,
getEnvelopeEditorSettingsTrigger,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
setRecipientEmail,
setRecipientName,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
test.use({
storageState: {
cookies: [],
origins: [],
},
});
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
const multiPagePdfBuffer = fs.readFileSync(
path.join(__dirname, '../../../../assets/field-font-alignment.pdf'),
);
// --- Shared helpers ---
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 getEditButton = (root: Page, index: number) =>
root.locator('[data-testid^="envelope-item-edit-button-"]').nth(index);
const getEditDialog = (root: Page) => root.getByRole('dialog');
const getEditDialogTitleInput = (root: Page) =>
root.locator('[data-testid="envelope-item-edit-title-input"]');
const getEditDialogDropzone = (root: Page) =>
root.locator('[data-testid="envelope-item-edit-dropzone"]');
const getEditDialogSelectedFile = (root: Page) =>
root.locator('[data-testid="envelope-item-edit-selected-file"]');
const getEditDialogClearFileButton = (root: Page) =>
root.locator('[data-testid="envelope-item-edit-clear-file"]');
const getEditDialogUpdateButton = (root: Page) =>
root.locator('[data-testid="envelope-item-edit-update-button"]');
const assertPdfPageCount = async (root: Page, expectedCount: number) => {
await expect(root.locator('[data-pdf-content]').first()).toHaveAttribute(
'data-page-count',
String(expectedCount),
{ timeout: 15000 },
);
};
const navigateToFieldsPage = async (surface: TEnvelopeEditorSurface) => {
// Set up a recipient first so the fields page is functional.
if (surface.isEmbedded) {
await setRecipientEmail(surface.root, 0, `test-${nanoid(4)}@example.com`);
await setRecipientName(surface.root, 0, 'Test User');
} else {
await clickAddMyselfButton(surface.root);
}
await clickEnvelopeEditorStep(surface.root, 'addFields');
// Wait for the file selector to be visible on the fields page.
await expect(getEditButton(surface.root, 0)).toBeAttached({ timeout: 10000 });
};
const openEditDialogOnFieldsPage = async (root: Page, index = 0) => {
// Hover to reveal the edit button, then click it.
const editButton = getEditButton(root, index);
await editButton.click({ force: true });
await expect(getEditDialog(root)).toBeVisible();
};
// --- Flows ---
const runRenameFlow = async (surface: TEnvelopeEditorSurface) => {
const externalId = `e2e-edit-rename-${nanoid()}`;
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(surface.root, 'rename-test.pdf');
}
await updateExternalId(surface, externalId);
await navigateToFieldsPage(surface);
// Open edit dialog and change the title.
await openEditDialogOnFieldsPage(surface.root);
const titleInput = getEditDialogTitleInput(surface.root);
await expect(titleInput).toBeVisible();
await titleInput.clear();
await titleInput.fill('Renamed Document');
// Update button should be disabled without a replacement file.
await expect(getEditDialogUpdateButton(surface.root)).toBeDisabled();
// A replacement file is required for the Update button to be enabled.
const dropzone = getEditDialogDropzone(surface.root);
await expect(dropzone).toBeVisible();
const fileInput = dropzone.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'example.pdf',
mimeType: 'application/pdf',
buffer: examplePdfBuffer,
});
await expect(getEditDialogSelectedFile(surface.root)).toBeVisible();
await getEditDialogUpdateButton(surface.root).click();
// Dialog should close.
await expect(getEditDialog(surface.root)).not.toBeVisible({ timeout: 10000 });
return { externalId };
};
const runReplacePdfFlow = async (surface: TEnvelopeEditorSurface) => {
const externalId = `e2e-edit-replace-${nanoid()}`;
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(surface.root, 'replace-test.pdf');
}
await updateExternalId(surface, externalId);
await navigateToFieldsPage(surface);
// First, assert the page count is 1 (example.pdf has 1 page).
await assertPdfPageCount(surface.root, 1);
// Open edit dialog.
await openEditDialogOnFieldsPage(surface.root);
// Select a multi-page PDF via the dropzone.
const dropzone = getEditDialogDropzone(surface.root);
await expect(dropzone).toBeVisible();
const fileInput = dropzone.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'field-font-alignment.pdf',
mimeType: 'application/pdf',
buffer: multiPagePdfBuffer,
});
// Verify file is shown in the selected file display.
await expect(getEditDialogSelectedFile(surface.root)).toBeVisible();
// Click Update.
await getEditDialogUpdateButton(surface.root).click();
// Dialog should close.
await expect(getEditDialog(surface.root)).not.toBeVisible({ timeout: 15000 });
// After replacement, the page count should be 3 (field-font-alignment.pdf has 3 pages).
await assertPdfPageCount(surface.root, 3);
return { externalId };
};
const runClearFileSelectionFlow = async (surface: TEnvelopeEditorSurface) => {
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(surface.root, 'clear-file-test.pdf');
}
await navigateToFieldsPage(surface);
// Open edit dialog.
await openEditDialogOnFieldsPage(surface.root);
// Select a file.
const dropzone = getEditDialogDropzone(surface.root);
await expect(dropzone).toBeVisible();
const fileInput = dropzone.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'to-be-cleared.pdf',
mimeType: 'application/pdf',
buffer: examplePdfBuffer,
});
// Verify file appears.
await expect(getEditDialogSelectedFile(surface.root)).toBeVisible();
await expect(getEditDialogDropzone(surface.root)).not.toBeVisible();
// Click X to clear.
await getEditDialogClearFileButton(surface.root).click();
// Dropzone should reappear.
await expect(getEditDialogDropzone(surface.root)).toBeVisible();
await expect(getEditDialogSelectedFile(surface.root)).not.toBeVisible();
};
// --- DB assertion ---
const assertRenamePersistedInDatabase = async ({
surface,
externalId,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
include: {
envelopeItems: {
orderBy: { order: 'asc' },
},
},
orderBy: { createdAt: 'desc' },
});
expect(envelope.envelopeItems.length).toBeGreaterThanOrEqual(1);
expect(envelope.envelopeItems[0].title).toBe('Renamed Document');
};
// --- Tests ---
test.describe('document editor', () => {
test('should rename an envelope item from the fields page', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runRenameFlow(surface);
await assertRenamePersistedInDatabase({ surface, ...result });
});
test('should replace a PDF from the fields page', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
await runReplacePdfFlow(surface);
});
test('should clear a selected file before submitting', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
await runClearFileSelectionFlow(surface);
});
});
test.describe('template editor', () => {
test('should rename an envelope item from the fields page', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runRenameFlow(surface);
await assertRenamePersistedInDatabase({ surface, ...result });
});
});
test.describe('embedded create', () => {
test('should rename an envelope item from the fields page', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-edit-dialog-create',
});
const result = await runRenameFlow(surface);
await clickEnvelopeEditorStep(surface.root, 'upload');
await persistEmbeddedEnvelope(surface);
await assertRenamePersistedInDatabase({ surface, ...result });
});
test('should replace a PDF from the fields page', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-edit-dialog-replace',
});
await runReplacePdfFlow(surface);
});
});
test.describe('embedded edit', () => {
test('should rename an envelope item from the fields page', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-edit-dialog-edit',
});
const result = await runRenameFlow(surface);
await clickEnvelopeEditorStep(surface.root, 'upload');
await persistEmbeddedEnvelope(surface);
await assertRenamePersistedInDatabase({ surface, ...result });
});
});

View file

@ -0,0 +1,532 @@
import { type Page, expect, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
clickAddMyselfButton,
clickEnvelopeEditorStep,
getEnvelopeEditorSettingsTrigger,
getEnvelopeItemDropzoneInput,
getEnvelopeItemReplaceButtons,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
setRecipientEmail,
setRecipientName,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
import { getKonvaElementCountForPage } from '../fixtures/konva';
test.use({
storageState: {
cookies: [],
origins: [],
},
});
type TestFilePayload = {
name: string;
mimeType: string;
buffer: Buffer;
};
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
const multiPagePdfBuffer = fs.readFileSync(
path.join(__dirname, '../../../../assets/field-font-alignment.pdf'),
);
const createPdfPayload = (name: string, buffer: Buffer = examplePdfBuffer): TestFilePayload => ({
name,
mimeType: 'application/pdf',
buffer,
});
// --- Shared helpers ---
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 replaceEnvelopeItemPdf = async (
root: Page,
index: number,
file: TestFilePayload,
options?: { isEmbedded?: boolean },
) => {
const replaceButton = getEnvelopeItemReplaceButtons(root).nth(index);
await expect(replaceButton).toBeVisible();
// Listen for the file chooser event before clicking so the native dialog
// is intercepted and never actually shown to the user.
const [fileChooser] = await Promise.all([
root.waitForEvent('filechooser'),
replaceButton.click(),
]);
await fileChooser.setFiles(file);
// The button stays in the DOM but becomes disabled while the replace
// mutation is in flight, then re-enables once the mutation completes.
// Wait for both transitions to guarantee the replace has fully finished.
//
// For embedded surfaces the replacement is purely local state with no
// network round-trip, so it can complete before Playwright observes the
// disabled state. Skip the disabled assertion for embedded surfaces.
if (!options?.isEmbedded) {
await expect(replaceButton).toBeDisabled({ timeout: 15000 });
}
await expect(replaceButton).toBeEnabled({ timeout: 15000 });
};
const assertPdfPageCount = async (root: Page, expectedCount: number) => {
await expect(root.locator('[data-pdf-content]').first()).toHaveAttribute(
'data-page-count',
String(expectedCount),
{ timeout: 15000 },
);
};
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 scrollToPage = async (root: Page, pageNumber: number) => {
const pageImage = root.locator(`img[data-page-number="${pageNumber}"]`);
await pageImage.scrollIntoViewIfNeeded();
await expect(pageImage).toBeVisible({ timeout: 10000 });
// Wait for the Konva stage to initialize after virtualized rendering.
await root.waitForTimeout(1000);
};
const placeFieldOnPage = async (
root: Page,
pageNumber: number,
fieldName: 'Signature' | 'Text',
position: { x: number; y: number },
) => {
await root.getByRole('button', { name: fieldName, exact: true }).click();
if (pageNumber > 1) {
await scrollToPage(root, pageNumber);
}
// Find the canvas corresponding to this page number via Konva stages.
// Since the virtualized list may have multiple canvases, we target the one
// that belongs to the correct page by using the evaluate approach.
const canvas = root.locator('.konva-container canvas');
if (pageNumber === 1) {
await expect(canvas.first()).toBeVisible();
await canvas.first().click({ position });
} else {
// For multi-page, find the canvas at the page position.
// The canvases are rendered in page order within the viewport.
// After scrolling to page N, it should be visible. We use nth based on
// the page index among currently rendered canvases.
// A more reliable approach: click on the page's img and offset into canvas.
const pageImg = root.locator(`img[data-page-number="${pageNumber}"]`);
const pageBox = await pageImg.boundingBox();
if (!pageBox) {
throw new Error(`Could not find bounding box for page ${pageNumber}`);
}
// Click at the desired position relative to the page image.
// The Konva canvas overlays the image, so clicking at the same coordinates works.
await root.mouse.click(pageBox.x + position.x, pageBox.y + position.y);
}
};
// --- Test 1: Basic replace flow ---
type BasicReplaceFlowResult = {
externalId: string;
originalDocumentDataId: string | null;
};
const runBasicReplaceFlow = async (
surface: TEnvelopeEditorSurface,
): Promise<BasicReplaceFlowResult> => {
const { root, isEmbedded } = surface;
const externalId = `e2e-replace-${nanoid()}`;
// For embedded create, upload a PDF first.
if (isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(root, 'replace-test-original.pdf');
}
await updateExternalId(surface, externalId);
// Record the original documentDataId so we can assert it changed after replace.
// For embedded flows the externalId only lives in client state until persist,
// so query by envelope ID when available, and skip entirely for embedded create.
let originalDocumentDataId: string | null = null;
if (!isEmbedded) {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
include: {
envelopeItems: { orderBy: { order: 'asc' } },
},
});
originalDocumentDataId = envelope.envelopeItems[0].documentDataId;
} else if (surface.envelopeId) {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
id: surface.envelopeId,
},
include: {
envelopeItems: { orderBy: { order: 'asc' } },
},
});
originalDocumentDataId = envelope.envelopeItems[0].documentDataId;
}
// Navigate to addFields step to verify initial page count.
await clickEnvelopeEditorStep(root, 'addFields');
await root.locator('[data-pdf-content]').first().waitFor({ state: 'visible', timeout: 15000 });
await assertPdfPageCount(root, 1);
// Navigate back to upload step.
await clickEnvelopeEditorStep(root, 'upload');
await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Replace the PDF.
await replaceEnvelopeItemPdf(root, 0, createPdfPayload('replace-test-new.pdf'), { isEmbedded });
// Navigate to addFields step to verify the PDF loaded correctly.
await clickEnvelopeEditorStep(root, 'addFields');
await root.locator('[data-pdf-content]').first().waitFor({ state: 'visible', timeout: 15000 });
await assertPdfPageCount(root, 1);
// Navigate back to upload step.
await clickEnvelopeEditorStep(root, 'upload');
await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible();
return { externalId, originalDocumentDataId };
};
const assertBasicReplaceInDatabase = async ({
surface,
externalId,
originalDocumentDataId,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
originalDocumentDataId: string | null;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
include: {
envelopeItems: { orderBy: { order: 'asc' } },
},
orderBy: { createdAt: 'desc' },
});
expect(envelope.envelopeItems).toHaveLength(1);
const item = envelope.envelopeItems[0];
// The documentDataId should have changed after replace.
if (originalDocumentDataId) {
expect(item.documentDataId).not.toBe(originalDocumentDataId);
}
// Verify documentDataId is a valid non-empty string.
expect(item.documentDataId).toBeTruthy();
expect(typeof item.documentDataId).toBe('string');
// Title and order should be unchanged.
expect(item.order).toBeGreaterThanOrEqual(1);
};
// --- Test 2: Field cleanup replace flow ---
const TEST_FIELD_VALUES = {
embeddedRecipient: {
email: 'embedded-replace-recipient@documenso.com',
name: 'Embedded Replace Recipient',
},
};
type FieldCleanupFlowResult = {
externalId: string;
recipientEmail: string;
};
const setupRecipient = async (surface: TEnvelopeEditorSurface): Promise<string> => {
if (surface.isEmbedded) {
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 clickAddMyselfButton(surface.root);
return surface.userEmail;
};
const uploadMultiPagePdf = async (root: Page) => {
await getEnvelopeItemDropzoneInput(root).setInputFiles({
name: 'multi-page.pdf',
mimeType: 'application/pdf',
buffer: multiPagePdfBuffer,
});
};
const runFieldCleanupReplaceFlow = async (
surface: TEnvelopeEditorSurface,
): Promise<FieldCleanupFlowResult> => {
const { root, isEmbedded } = surface;
const externalId = `e2e-replace-fields-${nanoid()}`;
// Step 1: Get a 3-page PDF loaded.
if (isEmbedded && !surface.envelopeId) {
// Embedded create: upload the multi-page PDF directly.
await uploadMultiPagePdf(root);
} else {
// All other surfaces: replace the existing 1-page PDF with the 3-page one.
await replaceEnvelopeItemPdf(root, 0, createPdfPayload('multi-page.pdf', multiPagePdfBuffer), {
isEmbedded,
});
}
await updateExternalId(surface, externalId);
// Step 2: Add a recipient.
const recipientEmail = await setupRecipient(surface);
// Step 3: Navigate to addFields step and verify 3-page PDF is loaded.
await clickEnvelopeEditorStep(root, 'addFields');
await root.locator('[data-pdf-content]').first().waitFor({ state: 'visible', timeout: 15000 });
await assertPdfPageCount(root, 3);
// Step 4: Place a Signature field on page 1.
await placeFieldOnPdf(root, 'Signature', { x: 120, y: 140 });
let fieldCountPage1 = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCountPage1).toBe(1);
// Step 5: Scroll to page 2 and place a Text field.
await placeFieldOnPage(root, 2, 'Text', { x: 120, y: 140 });
const fieldCountPage2 = await getKonvaElementCountForPage(root, 2, '.field-group');
expect(fieldCountPage2).toBe(1);
// Verify file selector shows "2 Fields".
await expect(root.getByText('2 Fields')).toBeVisible();
// Step 6: Navigate back to upload step.
await clickEnvelopeEditorStep(root, 'upload');
await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Step 7: Replace with 1-page PDF.
await replaceEnvelopeItemPdf(root, 0, createPdfPayload('single-page.pdf'), { isEmbedded });
// Step 8: Navigate to addFields step to verify.
await clickEnvelopeEditorStep(root, 'addFields');
await root.locator('[data-pdf-content]').first().waitFor({ state: 'visible', timeout: 15000 });
// PDF should now be 1 page.
await assertPdfPageCount(root, 1);
// Page 1 field should survive.
fieldCountPage1 = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCountPage1).toBe(1);
// File selector should show "1 Field".
await expect(root.getByText('1 Field')).toBeVisible();
return { externalId, recipientEmail };
};
const assertFieldCleanupInDatabase = 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,
},
include: {
fields: true,
recipients: true,
envelopeItems: { orderBy: { order: 'asc' } },
},
orderBy: { createdAt: 'desc' },
});
const recipient = envelope.recipients.find((r) => r.email === recipientEmail);
expect(recipient).toBeDefined();
// Only the page-1 field should remain.
expect(envelope.fields).toHaveLength(1);
expect(envelope.fields[0].page).toBe(1);
expect(envelope.fields[0].type).toBe(FieldType.SIGNATURE);
expect(envelope.fields[0].recipientId).toBe(recipient?.id);
// The envelope item should have a 1-page PDF (documentDataId should exist).
expect(envelope.envelopeItems).toHaveLength(1);
expect(envelope.envelopeItems[0].documentDataId).toBeTruthy();
};
// --- Test describe blocks ---
test.describe('document editor', () => {
test('replace PDF on an envelope item', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runBasicReplaceFlow(surface);
await assertBasicReplaceInDatabase({
surface,
...result,
});
});
test('replace PDF deletes fields on out-of-bounds pages', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runFieldCleanupReplaceFlow(surface);
await assertFieldCleanupInDatabase({
surface,
...result,
});
});
});
test.describe('template editor', () => {
test('replace PDF on an envelope item', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runBasicReplaceFlow(surface);
await assertBasicReplaceInDatabase({
surface,
...result,
});
});
test('replace PDF deletes fields on out-of-bounds pages', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runFieldCleanupReplaceFlow(surface);
await assertFieldCleanupInDatabase({
surface,
...result,
});
});
});
test.describe('embedded create', () => {
test('replace PDF on an envelope item', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-replace',
});
const result = await runBasicReplaceFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertBasicReplaceInDatabase({
surface,
...result,
});
});
test('replace PDF deletes fields on out-of-bounds pages', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-replace-fields',
});
const result = await runFieldCleanupReplaceFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertFieldCleanupInDatabase({
surface,
...result,
});
});
});
test.describe('embedded edit', () => {
test('replace PDF on an envelope item', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-replace',
});
const result = await runBasicReplaceFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertBasicReplaceInDatabase({
surface,
...result,
});
});
test('replace PDF deletes fields on out-of-bounds pages', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-replace-fields',
});
const result = await runFieldCleanupReplaceFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertFieldCleanupInDatabase({
surface,
...result,
});
});
});

View file

@ -247,6 +247,9 @@ export const getEnvelopeItemDragHandles = (root: Page) =>
export const getEnvelopeItemRemoveButtons = (root: Page) =>
root.locator('[data-testid^="envelope-item-remove-button-"]');
export const getEnvelopeItemReplaceButtons = (root: Page) =>
root.locator('[data-testid^="envelope-item-replace-button-"]');
export const getEnvelopeItemDropzoneInput = (root: Page) =>
root.locator('[data-testid="envelope-item-dropzone"] input[type="file"]');

View file

@ -491,7 +491,7 @@ const decorateAndSignPdf = async ({
// Add suffix based on document status
const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf';
const newDocumentData = await putPdfFileServerSide(
const { documentData: newDocumentData } = await putPdfFileServerSide(
{
name: `${name}${suffix}`,
type: 'application/pdf',

View file

@ -61,7 +61,7 @@ export const UNSAFE_createEnvelopeItems = async ({
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { id: documentDataId } = await putPdfFileServerSide({
const { documentData } = await putPdfFileServerSide({
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(cleanedPdf),
@ -71,7 +71,7 @@ export const UNSAFE_createEnvelopeItems = async ({
id: prefixedId('envelope_item'),
title: file.name,
clientId,
documentDataId,
documentDataId: documentData.id,
placeholders,
order: orderOverride ?? currentHighestOrderValue + index + 1,
};

View file

@ -0,0 +1,233 @@
import type { Envelope, Field, Recipient } from '@prisma/client';
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { convertPlaceholdersToFieldInputs, extractPdfPlaceholders } from '../pdf/auto-place-fields';
import { findRecipientByPlaceholder } from '../pdf/helpers';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
type UnsafeReplaceEnvelopeItemPdfOptions = {
envelope: Pick<Envelope, 'id' | 'type' | 'formValues'>;
/**
* Recipients used to resolve placeholder field assignments.
* When provided and placeholders are found in the replacement PDF,
* fields will be auto-created for matching recipients.
*/
recipients: Recipient[];
/**
* The ID of the envelope item which we will be replacing the PDF for.
*/
envelopeItemId: string;
/**
* The ID of the old document data we will be deleting.
*/
oldDocumentDataId: string;
/**
* The data we will be replacing.
*/
data: {
title?: string;
order?: number;
file: File;
};
user: {
id: number;
name: string | null;
email: string;
};
apiRequestMetadata: ApiRequestMetadata;
};
type UnsafeReplaceEnvelopeItemPdfResult = {
updatedItem: {
id: string;
title: string;
envelopeId: string;
order: number;
documentDataId: string;
};
/**
* The full list of fields for the envelope after the replacement.
*
* Only returned when fields were created or deleted during the replacement,
* otherwise `undefined`.
*/
fields: Field[] | undefined;
};
export const UNSAFE_replaceEnvelopeItemPdf = async ({
envelope,
recipients,
envelopeItemId,
oldDocumentDataId,
data,
user,
apiRequestMetadata,
}: UnsafeReplaceEnvelopeItemPdfOptions): Promise<UnsafeReplaceEnvelopeItemPdfResult> => {
let buffer = Buffer.from(await data.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);
// Upload the new PDF and get a new DocumentData record.
const { documentData: newDocumentData, filePageCount } = await putPdfFileServerSide({
name: data.file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(cleanedPdf),
});
let didFieldsChange = false;
const updatedEnvelopeItem = await prisma.$transaction(async (tx) => {
const updatedItem = await tx.envelopeItem.update({
where: {
id: envelopeItemId,
envelopeId: envelope.id,
},
data: {
documentDataId: newDocumentData.id,
title: data.title,
order: data.order,
},
});
// Todo: Audit log if we're updating the title or order.
// Delete fields that reference pages beyond the new PDF's page count.
const outOfBoundsFields = await tx.field.findMany({
where: {
envelopeId: envelope.id,
envelopeItemId,
page: {
gt: filePageCount,
},
},
select: {
id: true,
},
});
const deletedFieldIds = outOfBoundsFields.map((f) => f.id);
if (deletedFieldIds.length > 0) {
await tx.field.deleteMany({
where: {
id: {
in: deletedFieldIds,
},
},
});
didFieldsChange = true;
}
if (recipients.length > 0 && placeholders.length > 0) {
const orderedRecipients = [...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;
});
const fieldsToCreate = convertPlaceholdersToFieldInputs(
placeholders,
(recipientPlaceholder, placeholder) =>
findRecipientByPlaceholder(
recipientPlaceholder,
placeholder,
orderedRecipients,
orderedRecipients,
),
updatedItem.id,
);
if (fieldsToCreate.length > 0) {
await tx.field.createMany({
data: fieldsToCreate.map((field) => ({
envelopeId: envelope.id,
envelopeItemId: updatedItem.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,
})),
});
didFieldsChange = true;
}
}
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_PDF_REPLACED,
envelopeId: envelope.id,
data: {
envelopeItemId: updatedItem.id,
envelopeItemTitle: updatedItem.title,
},
user: {
name: user.name,
email: user.email,
},
requestMetadata: apiRequestMetadata.requestMetadata,
}),
});
return updatedItem;
});
// Delete the old DocumentData (now orphaned).
await prisma.documentData.delete({
where: {
id: oldDocumentDataId,
},
});
let fields: Field[] | undefined = undefined;
if (didFieldsChange) {
try {
fields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
},
});
} catch (err) {
// Do nothing.
console.error(err);
}
}
return {
updatedItem: updatedEnvelopeItem,
fields,
};
};

View file

@ -207,7 +207,7 @@ export const createEnvelope = async ({
const titleToUse = item.title || title;
const newDocumentData = await putPdfFileServerSide({
const { documentData: newDocumentData } = await putPdfFileServerSide({
name: titleToUse,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(normalizedPdf),

View file

@ -308,7 +308,7 @@ export const createEnvelopeFields = async ({
continue;
}
const newDocumentData = await putPdfFileServerSide({
const { documentData: newDocumentData } = await putPdfFileServerSide({
name: 'document.pdf',
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(Buffer.from(modifiedPdfBytes)),

View file

@ -307,20 +307,12 @@ export const createDocumentFromDirectTemplate = async ({
const titleToUse = item.title || directTemplateEnvelope.title;
const duplicatedFile = await putPdfFileServerSide({
const { documentData: newDocumentData } = await putPdfFileServerSide({
name: titleToUse,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(buffer),
});
const newDocumentData = await prisma.documentData.create({
data: {
type: duplicatedFile.type,
data: duplicatedFile.data,
initialData: duplicatedFile.initialData,
},
});
const newEnvelopeItemId = prefixedId('envelope_item');
oldEnvelopeItemToNewEnvelopeItemIdMap[item.id] = newEnvelopeItemId;

View file

@ -23,6 +23,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'ENVELOPE_ITEM_CREATED',
'ENVELOPE_ITEM_DELETED',
'ENVELOPE_ITEM_PDF_REPLACED',
// Document events.
'DOCUMENT_COMPLETED', // When the document is sealed and fully completed.
@ -209,6 +210,17 @@ export const ZDocumentAuditLogEventEnvelopeItemDeletedSchema = z.object({
}),
});
/**
* Event: Envelope item PDF replaced.
*/
export const ZDocumentAuditLogEventEnvelopeItemPdfReplacedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_PDF_REPLACED),
data: z.object({
envelopeItemId: z.string(),
envelopeItemTitle: z.string(),
}),
});
/**
* Event: Email sent.
*/
@ -722,6 +734,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
z.union([
ZDocumentAuditLogEventEnvelopeItemCreatedSchema,
ZDocumentAuditLogEventEnvelopeItemDeletedSchema,
ZDocumentAuditLogEventEnvelopeItemPdfReplacedSchema,
ZDocumentAuditLogEventEmailSentSchema,
ZDocumentAuditLogEventDocumentCompletedSchema,
ZDocumentAuditLogEventDocumentCreatedSchema,

View file

@ -71,6 +71,7 @@ export const ZEnvelopeEditorSettingsSchema = z.object({
allowConfigureOrder: z.boolean(),
allowUpload: z.boolean(),
allowDelete: z.boolean(),
allowReplace: z.boolean(),
})
.nullable(),
@ -136,6 +137,7 @@ export const DEFAULT_EDITOR_CONFIG: EnvelopeEditorConfig = {
allowConfigureOrder: true,
allowUpload: true,
allowDelete: true,
allowReplace: true,
},
recipients: {
allowAIDetection: true,
@ -192,6 +194,7 @@ export const DEFAULT_EMBEDDED_EDITOR_CONFIG = {
allowConfigureOrder: true,
allowUpload: true,
allowDelete: true,
allowReplace: true,
},
recipients: {
allowAIDetection: false, // These are not supported for embeds, and are directly excluded in the embedded repo.

View file

@ -41,7 +41,12 @@ export const putPdfFileServerSide = async (file: File, initialData?: string) =>
const { type, data } = await putFileServerSide(file);
return await createDocumentData({ type, data, initialData });
const createdData = await createDocumentData({ type, data, initialData });
return {
documentData: createdData,
filePageCount: pdf.getPageCount(),
};
};
/**

View file

@ -571,6 +571,14 @@ export const formatDocumentAuditLogAction = (
you: msg`You deleted an envelope item with title ${data.envelopeItemTitle}`,
user: msg`${user} deleted an envelope item with title ${data.envelopeItemTitle}`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_PDF_REPLACED }, ({ data }) => ({
anonymous: msg({
message: `Envelope item PDF replaced`,
context: `Audit log format`,
}),
you: msg`You replaced the PDF for envelope item ${data.envelopeItemTitle}`,
user: msg`${user} replaced the PDF for envelope item ${data.envelopeItemTitle}`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRED }, ({ data }) => ({
anonymous: msg({
message: `Recipient signing window expired`,

View file

@ -110,6 +110,9 @@ export const buildEmbeddedFeatures = (
allowDelete:
features.envelopeItems?.allowDelete ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowDelete,
allowReplace:
features.envelopeItems?.allowReplace ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowReplace,
}
: null,

View file

@ -18,7 +18,7 @@ import {
} from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { zodFormData } from '../../utils/zod-form-data';
import { zfdFile, zodFormData } from '../../utils/zod-form-data';
import { ZCreateRecipientSchema } from '../recipient-router/schema';
import type { TrpcRouteMeta } from '../trpc';
import { ZDocumentExternalIdSchema, ZDocumentTitleSchema } from './schema';
@ -79,7 +79,7 @@ export const ZCreateDocumentPayloadSchema = z.object({
export const ZCreateDocumentRequestSchema = zodFormData({
payload: zfd.json(ZCreateDocumentPayloadSchema),
file: zfd.file(),
file: zfdFile(),
});
export const ZCreateDocumentResponseSchema = z.object({

View file

@ -6,6 +6,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { UNSAFE_createEnvelopeItems } from '@documenso/lib/server-only/envelope-item/create-envelope-items';
import { UNSAFE_deleteEnvelopeItem } from '@documenso/lib/server-only/envelope-item/delete-envelope-item';
import { UNSAFE_replaceEnvelopeItemPdf } from '@documenso/lib/server-only/envelope-item/replace-envelope-item-pdf';
import { UNSAFE_updateEnvelopeItems } from '@documenso/lib/server-only/envelope-item/update-envelope-items';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { updateEnvelope } from '@documenso/lib/server-only/envelope/update-envelope';
@ -95,8 +96,9 @@ export const updateEmbeddingEnvelopeRoute = procedure
// Step 1: Update the envelope items.
const envelopeItemsToUpdate: EnvelopeItemUpdateOptions[] = [];
const envelopeItemsToCreate: EnvelopeItemCreateOptions[] = [];
const envelopeItemsToReplace: EnvelopeItemReplaceOptions[] = [];
// Sort and group envelope items to update and create.
// Sort and group envelope items to update, create, and replace.
data.envelopeItems.forEach((item) => {
const isNewEnvelopeItem = item.id.startsWith(PRESIGNED_ENVELOPE_ITEM_ID_PREFIX);
@ -112,6 +114,27 @@ export const updateEmbeddingEnvelopeRoute = procedure
});
}
// Check if this existing item has a replacement file.
if (item.replaceFileIndex !== undefined) {
const replaceFile = files[item.replaceFileIndex];
if (!replaceFile) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid replace file index',
});
}
envelopeItemsToReplace.push({
envelopeItemId: envelopeItem.id,
oldDocumentDataId: envelopeItem.documentDataId,
title: item.title,
order: item.order,
file: replaceFile,
});
return;
}
const hasEnvelopeItemChanged =
envelopeItem.title !== item.title || envelopeItem.order !== item.order;
@ -152,7 +175,8 @@ export const updateEmbeddingEnvelopeRoute = procedure
const willEnvelopeItemsBeModified =
envelopeItemIdsToDelete.length > 0 ||
envelopeItemsToCreate.length > 0 ||
envelopeItemsToUpdate.length > 0;
envelopeItemsToUpdate.length > 0 ||
envelopeItemsToReplace.length > 0;
const organisationClaim = envelope.team.organisation.organisationClaim;
const resultingEnvelopeItemCount =
@ -232,6 +256,30 @@ export const updateEmbeddingEnvelopeRoute = procedure
});
}
// Replace PDFs for existing envelope items without creating placeholder fields
// field cleanup is handled in later steps.
if (envelopeItemsToReplace.length > 0) {
await pMap(
envelopeItemsToReplace,
async (item) => {
await UNSAFE_replaceEnvelopeItemPdf({
envelope,
recipients: [],
envelopeItemId: item.envelopeItemId,
oldDocumentDataId: item.oldDocumentDataId,
data: {
title: item.title,
order: item.order,
file: item.file,
},
user: apiToken.user,
apiRequestMetadata: ctx.metadata,
});
},
{ concurrency: 2 },
);
}
// Step 2: Update the general envelope data and meta.
await updateEnvelope({
userId: apiToken.userId,
@ -426,3 +474,11 @@ type EnvelopeItemCreateOptions = {
order: number;
file: File;
};
type EnvelopeItemReplaceOptions = {
envelopeItemId: string;
oldDocumentDataId: string;
title: string;
order: number;
file: File;
};

View file

@ -17,7 +17,7 @@ import { ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { EnvelopeAttachmentSchema } from '@documenso/prisma/generated/zod/modelSchema/EnvelopeAttachmentSchema';
import { ZSetEnvelopeRecipientSchema } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
import { zodFormData } from '../../utils/zod-form-data';
import { zfdFile, zodFormData } from '../../utils/zod-form-data';
import {
ZDocumentExternalIdSchema,
ZDocumentTitleSchema,
@ -60,6 +60,16 @@ export const ZUpdateEmbeddingEnvelopePayloadSchema = z.object({
* The file index for items that are not yet uploaded.
*/
index: z.number().int().min(0).optional(),
/**
* The file index for existing items that need their PDF replaced.
* Only applicable to items with real IDs (not PRESIGNED_ prefix).
*/
replaceFileIndex: z.number().int().min(0).optional(),
})
.refine((item) => !(item.index !== undefined && item.replaceFileIndex !== undefined), {
message: 'Cannot provide both index and replaceFileIndex on the same envelope item',
path: ['replaceFileIndex'],
})
.array(),
@ -102,7 +112,7 @@ export const ZUpdateEmbeddingEnvelopePayloadSchema = z.object({
export const ZUpdateEmbeddingEnvelopeRequestSchema = zodFormData({
payload: zfd.json(ZUpdateEmbeddingEnvelopePayloadSchema),
files: zfd.repeatableOfType(zfd.file()),
files: zfd.repeatableOfType(zfdFile()),
});
export const ZUpdateEmbeddingEnvelopeResponseSchema = z.void();

View file

@ -3,7 +3,7 @@ import { zfd } from 'zod-form-data';
import EnvelopeItemSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
import { zodFormData } from '../../utils/zod-form-data';
import { zfdFile, zodFormData } from '../../utils/zod-form-data';
import type { TrpcRouteMeta } from '../trpc';
export const createEnvelopeItemsMeta: TrpcRouteMeta = {
@ -24,7 +24,7 @@ export const ZCreateEnvelopeItemsPayloadSchema = z.object({
export const ZCreateEnvelopeItemsRequestSchema = zodFormData({
payload: zfd.json(ZCreateEnvelopeItemsPayloadSchema),
files: zfd.repeatableOfType(zfd.file()),
files: zfd.repeatableOfType(zfdFile()),
});
export const ZCreateEnvelopeItemsResponseSchema = z.object({

View file

@ -124,7 +124,7 @@ export const createEnvelopeRouteCaller = async ({
// Todo: Embeds - Might need to add this for client-side embeds in the future.
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { id: documentDataId } = await putPdfFileServerSide({
const { documentData } = await putPdfFileServerSide({
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(cleanedPdf),
@ -132,7 +132,7 @@ export const createEnvelopeRouteCaller = async ({
return {
title: file.name,
documentDataId,
documentDataId: documentData.id,
placeholders,
};
}),

View file

@ -18,7 +18,7 @@ import {
} from '@documenso/lib/types/field';
import { ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { zodFormData } from '../../utils/zod-form-data';
import { zfdFile, zodFormData } from '../../utils/zod-form-data';
import {
ZDocumentExternalIdSchema,
ZDocumentTitleSchema,
@ -94,7 +94,7 @@ export const ZCreateEnvelopePayloadSchema = z.object({
export const ZCreateEnvelopeRequestSchema = zodFormData({
payload: zfd.json(ZCreateEnvelopePayloadSchema),
files: zfd.repeatableOfType(zfd.file()),
files: zfd.repeatableOfType(zfdFile()),
});
export const ZCreateEnvelopeResponseSchema = z.object({

View file

@ -0,0 +1,103 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { UNSAFE_replaceEnvelopeItemPdf } from '@documenso/lib/server-only/envelope-item/replace-envelope-item-pdf';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZReplaceEnvelopeItemPdfRequestSchema,
ZReplaceEnvelopeItemPdfResponseSchema,
} from './replace-envelope-item-pdf.types';
/**
* Keep this internal for the envelope editor.
*
* If we want to make this public then create a separate one that only allows
* the PDF to be replaced & doesn't return deleted fields, etc.
*/
export const replaceEnvelopeItemPdfRoute = authenticatedProcedure
.input(ZReplaceEnvelopeItemPdfRequestSchema)
.output(ZReplaceEnvelopeItemPdfResponseSchema)
.mutation(async ({ input, ctx }) => {
const { user, teamId, metadata } = ctx;
const { payload, file } = input;
const { envelopeId, envelopeItemId, title } = payload;
ctx.logger.info({
input: {
envelopeId,
envelopeItemId,
},
});
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: envelopeId,
},
type: null,
userId: user.id,
teamId,
});
const envelope = await prisma.envelope.findUnique({
where: envelopeWhereInput,
include: {
recipients: true,
envelopeItems: {
orderBy: {
order: 'asc',
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
if (envelope.internalVersion !== 2) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'PDF replacement is only supported for version 2 envelopes',
});
}
if (!canEnvelopeItemsBeModified(envelope, envelope.recipients)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope item is not editable',
});
}
const envelopeItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
if (!envelopeItem) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope item not found',
});
}
const { updatedItem, fields } = await UNSAFE_replaceEnvelopeItemPdf({
envelope,
recipients: envelope.recipients,
envelopeItemId,
oldDocumentDataId: envelopeItem.documentDataId,
data: {
file,
title,
},
user: {
id: user.id,
name: user.name,
email: user.email,
},
apiRequestMetadata: metadata,
});
return {
data: updatedItem,
fields,
};
});

View file

@ -0,0 +1,42 @@
import { z } from 'zod';
import { zfd } from 'zod-form-data';
import { ZEnvelopeFieldSchema } from '@documenso/lib/types/field';
import EnvelopeItemSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
import { zfdFile, zodFormData } from '../../utils/zod-form-data';
import { ZDocumentTitleSchema } from '../document-router/schema';
export const ZReplaceEnvelopeItemPdfPayloadSchema = z.object({
envelopeId: z.string(),
envelopeItemId: z.string(),
title: ZDocumentTitleSchema.optional(),
});
export const ZReplaceEnvelopeItemPdfRequestSchema = zodFormData({
payload: zfd.json(ZReplaceEnvelopeItemPdfPayloadSchema),
file: zfdFile(),
});
export const ZReplaceEnvelopeItemPdfResponseSchema = z.object({
data: EnvelopeItemSchema.pick({
id: true,
title: true,
envelopeId: true,
order: true,
documentDataId: true,
}),
/**
* The full list of fields for the envelope after the replacement.
*
* This is only populated if fields have been changed or deleted. It will
* return undefined otherwise.
*
* Done this way to reduce number of queries.
*/
fields: ZEnvelopeFieldSchema.array().optional(),
});
export type TReplaceEnvelopeItemPdfPayload = z.infer<typeof ZReplaceEnvelopeItemPdfPayloadSchema>;
export type TReplaceEnvelopeItemPdfRequest = z.infer<typeof ZReplaceEnvelopeItemPdfRequestSchema>;
export type TReplaceEnvelopeItemPdfResponse = z.infer<typeof ZReplaceEnvelopeItemPdfResponseSchema>;

View file

@ -28,6 +28,7 @@ import { getEnvelopeItemsRoute } from './get-envelope-items';
import { getEnvelopeItemsByTokenRoute } from './get-envelope-items-by-token';
import { getEnvelopesByIdsRoute } from './get-envelopes-by-ids';
import { redistributeEnvelopeRoute } from './redistribute-envelope';
import { replaceEnvelopeItemPdfRoute } from './replace-envelope-item-pdf';
import { setEnvelopeFieldsRoute } from './set-envelope-fields';
import { setEnvelopeRecipientsRoute } from './set-envelope-recipients';
import { signEnvelopeFieldRoute } from './sign-envelope-field';
@ -55,6 +56,7 @@ export const envelopeRouter = router({
updateMany: updateEnvelopeItemsRoute,
delete: deleteEnvelopeItemRoute,
download: downloadEnvelopeItemRoute,
replacePdf: replaceEnvelopeItemPdfRoute,
},
recipient: {
get: getEnvelopeRecipientRoute,

View file

@ -20,7 +20,7 @@ import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-att
import { ZFieldMetaPrefillFieldsSchema } from '@documenso/lib/types/field-meta';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { zodFormData } from '../../utils/zod-form-data';
import { zfdFile, zodFormData } from '../../utils/zod-form-data';
import type { TrpcRouteMeta } from '../trpc';
import { ZRecipientWithSigningUrlSchema } from './schema';
@ -117,7 +117,7 @@ export const ZUseEnvelopePayloadSchema = z.object({
export const ZUseEnvelopeRequestSchema = zodFormData({
payload: zfd.json(ZUseEnvelopePayloadSchema),
files: zfd.repeatableOfType(zfd.file()).optional(),
files: zfd.repeatableOfType(zfdFile()).optional(),
});
export const ZUseEnvelopeResponseSchema = z.object({

View file

@ -35,7 +35,7 @@ import {
import { LegacyTemplateDirectLinkSchema } from '@documenso/prisma/types/template-legacy-schema';
import { ZDocumentExternalIdSchema } from '@documenso/trpc/server/document-router/schema';
import { zodFormData } from '../../utils/zod-form-data';
import { zfdFile, zodFormData } from '../../utils/zod-form-data';
import { ZSignFieldWithTokenMutationSchema } from '../field-router/schema';
export const MAX_TEMPLATE_PUBLIC_TITLE_LENGTH = 50;
@ -267,7 +267,7 @@ export const ZCreateTemplatePayloadSchema = ZCreateTemplateV2RequestSchema;
export const ZCreateTemplateMutationSchema = zodFormData({
payload: zfd.json(ZCreateTemplatePayloadSchema),
file: zfd.file(),
file: zfdFile(),
});
export const ZUpdateTemplateRequestSchema = z.object({

View file

@ -1,5 +1,22 @@
import type { ZodRawShape } from 'zod';
import z from 'zod';
import { zfd } from 'zod-form-data';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
/**
* A `zfd.file()` schema with a max file size constraint based on
* `APP_DOCUMENT_UPLOAD_SIZE_LIMIT`. Use this instead of bare `zfd.file()`
* to ensure server-side file size validation.
*/
export const zfdFile = () => {
const maxBytes = megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT);
return zfd.file().refine((file) => file.size <= maxBytes, {
message: `File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
});
};
/**
* This helper takes the place of the `z.object` at the root of your schema.

View file

@ -0,0 +1,19 @@
import { msg } from '@lingui/core/macro';
import { ErrorCode, type FileRejection } from 'react-dropzone';
import { match } from 'ts-pattern';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
export const buildDropzoneRejectionDescription = (fileRejections: FileRejection[]) => {
const errorCode = fileRejections[0]?.errors[0]?.code;
return match(errorCode)
.with(
ErrorCode.FileTooLarge,
() => msg`File is larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
)
.with(ErrorCode.FileInvalidType, () => msg`Only PDF files are allowed`)
.with(ErrorCode.FileTooSmall, () => msg`File is too small`)
.with(ErrorCode.TooManyFiles, () => msg`Only one file can be uploaded at a time`)
.otherwise(() => msg`Unknown error`);
};