mirror of
https://github.com/documenso/documenso
synced 2026-04-21 13:27:18 +00:00
feat: add envelope pdf replacement (#2602)
This commit is contained in:
parent
5dcdac7ecd
commit
0b605d61c6
39 changed files with 2003 additions and 110 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
368
apps/remix/app/components/dialogs/envelope-item-edit-dialog.tsx
Normal file
368
apps/remix/app/components/dialogs/envelope-item-edit-dialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ export default function EmbedPlaygroundPage() {
|
|||
allowConfigureOrder: true,
|
||||
allowUpload: true,
|
||||
allowDelete: true,
|
||||
allowReplace: true,
|
||||
});
|
||||
|
||||
const [recipientsFeatures, setRecipientsFeatures] = useState({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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"]');
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
19
packages/ui/lib/handle-dropzone-rejection.tsx
Normal file
19
packages/ui/lib/handle-dropzone-rejection.tsx
Normal 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`);
|
||||
};
|
||||
Loading…
Reference in a new issue