mirror of
https://github.com/documenso/documenso.git
synced 2026-07-12 22:15:01 +10:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d22b43c98 | |||
| e2794ec42b | |||
| e2b3597c36 | |||
| 3acc029fef | |||
| 2a53104644 | |||
| 6541f2778b | |||
| ab3e8a4074 | |||
| cb6d6e46d0 | |||
| c20affa286 | |||
| a69fe940b5 | |||
| 8186d2817f | |||
| 4fb3c2cb0f |
@@ -1,3 +1,5 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
@@ -5,6 +7,7 @@ import { useNavigate } from 'react-router';
|
|||||||
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
|
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -13,7 +16,6 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
@@ -38,6 +40,8 @@ export const DocumentDuplicateDialog = ({
|
|||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
|
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
|
||||||
trpcReact.envelope.item.getManyByToken.useQuery(
|
trpcReact.envelope.item.getManyByToken.useQuery(
|
||||||
{
|
{
|
||||||
@@ -95,12 +99,13 @@ export const DocumentDuplicateDialog = ({
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
|
<div ref={scrollContainerRef} className="h-[50vh] overflow-y-scroll p-2">
|
||||||
<PDFViewerLazy
|
<PDFViewer
|
||||||
key={envelopeItems[0].id}
|
key={envelopeItems[0].id}
|
||||||
envelopeItem={envelopeItems[0]}
|
envelopeItem={envelopeItems[0]}
|
||||||
token={undefined}
|
token={undefined}
|
||||||
version="original"
|
version="initial"
|
||||||
|
scrollParentRef={scrollContainerRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||||
|
import { P, match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
type EnvelopeDeleteDialogProps = {
|
||||||
|
id: string;
|
||||||
|
type: EnvelopeType;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
onDelete?: () => Promise<void> | void;
|
||||||
|
status: DocumentStatus;
|
||||||
|
title: string;
|
||||||
|
canManageDocument: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EnvelopeDeleteDialog = ({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
trigger,
|
||||||
|
onDelete,
|
||||||
|
status,
|
||||||
|
title,
|
||||||
|
canManageDocument,
|
||||||
|
}: EnvelopeDeleteDialogProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { refreshLimits } = useLimits();
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const deleteMessage = msg`delete`;
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
|
||||||
|
|
||||||
|
const { mutateAsync: deleteEnvelope, isPending } = trpcReact.envelope.delete.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
void refreshLimits();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Document deleted`,
|
||||||
|
description: t`"${title}" has been successfully deleted`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await onDelete?.();
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: t`Something went wrong`,
|
||||||
|
description: t`This document could not be deleted at this time. Please try again.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 7500,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setInputValue('');
|
||||||
|
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
|
||||||
|
}
|
||||||
|
}, [open, status]);
|
||||||
|
|
||||||
|
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setInputValue(event.target.value);
|
||||||
|
setIsDeleteEnabled(event.target.value === t(deleteMessage));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
|
||||||
|
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Are you sure?</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
{canManageDocument ? (
|
||||||
|
<Trans>
|
||||||
|
You are about to delete <strong>"{title}"</strong>
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
You are about to hide <strong>"{title}"</strong>
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{canManageDocument ? (
|
||||||
|
<Alert variant="warning" className="-mt-1">
|
||||||
|
{match(status)
|
||||||
|
.with(DocumentStatus.DRAFT, () => (
|
||||||
|
<AlertDescription>
|
||||||
|
{type === EnvelopeType.DOCUMENT ? (
|
||||||
|
<Trans>
|
||||||
|
Please note that this action is <strong>irreversible</strong>. Once confirmed,
|
||||||
|
this document will be permanently deleted.
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
Please note that this action is <strong>irreversible</strong>. Once confirmed,
|
||||||
|
this template will be permanently deleted.
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
))
|
||||||
|
.with(DocumentStatus.PENDING, () => (
|
||||||
|
<AlertDescription>
|
||||||
|
<p>
|
||||||
|
<Trans>
|
||||||
|
Please note that this action is <strong>irreversible</strong>.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-1">
|
||||||
|
<Trans>Once confirmed, the following will occur:</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="mt-0.5 list-inside list-disc">
|
||||||
|
<li>
|
||||||
|
<Trans>Document will be permanently deleted</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Document signing process will be cancelled</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>All inserted signatures will be voided</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>All recipients will be notified</Trans>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
))
|
||||||
|
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
|
||||||
|
<AlertDescription>
|
||||||
|
<p>
|
||||||
|
<Trans>By deleting this document, the following will occur:</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="mt-0.5 list-inside list-disc">
|
||||||
|
<li>
|
||||||
|
<Trans>The document will be hidden from your account</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Recipients will still retain their copy of the document</Trans>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
))
|
||||||
|
.exhaustive()}
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Alert variant="warning" className="-mt-1">
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans>Please contact support if you would like to revert this action.</Trans>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status !== DocumentStatus.DRAFT && canManageDocument && (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={onInputChange}
|
||||||
|
placeholder={t`Please type ${`'${t(deleteMessage)}'`} to confirm`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary" disabled={isPending}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
loading={isPending}
|
||||||
|
onClick={() => void deleteEnvelope({ envelopeId: id })}
|
||||||
|
disabled={!isDeleteEnabled && canManageDocument}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
{canManageDocument ? <Trans>Delete</Trans> : <Trans>Hide</Trans>}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -13,6 +13,7 @@ import * as z from 'zod';
|
|||||||
|
|
||||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
|
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
|
||||||
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
@@ -116,10 +117,15 @@ export const EnvelopeDistributeDialog = ({
|
|||||||
} = form;
|
} = form;
|
||||||
|
|
||||||
const { data: emailData, isLoading: isLoadingEmails } =
|
const { data: emailData, isLoading: isLoadingEmails } =
|
||||||
trpc.enterprise.organisation.email.find.useQuery({
|
trpc.enterprise.organisation.email.find.useQuery(
|
||||||
organisationId: organisation.id,
|
{
|
||||||
perPage: 100,
|
organisationId: organisation.id,
|
||||||
});
|
perPage: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const emails = emailData?.data || [];
|
const emails = emailData?.data || [];
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { useDropzone } from 'react-dropzone';
|
|||||||
import { useFormContext } from 'react-hook-form';
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||||
|
import { PDF_IMAGE_RENDER_SCALE } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
import { pdfToImagesClientSide } from '@documenso/lib/server-only/ai/pdf-to-images.client';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@@ -52,12 +54,17 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
|
|||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const uint8Array = new Uint8Array(arrayBuffer);
|
const uint8Array = new Uint8Array(arrayBuffer);
|
||||||
|
|
||||||
|
const pdfImages = await pdfToImagesClientSide(uint8Array, {
|
||||||
|
scale: PDF_IMAGE_RENDER_SCALE,
|
||||||
|
});
|
||||||
|
|
||||||
// Store file metadata and UInt8Array in form data
|
// Store file metadata and UInt8Array in form data
|
||||||
form.setValue('documentData', {
|
form.setValue('documentData', {
|
||||||
name: file.name,
|
name: file.name,
|
||||||
type: file.type,
|
type: file.type,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
data: uint8Array, // Store as UInt8Array
|
data: uint8Array, // Store as UInt8Array
|
||||||
|
images: pdfImages,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-populate title if it's empty
|
// Auto-populate title if it's empty
|
||||||
@@ -144,7 +151,7 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
|
|||||||
<div
|
<div
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-border bg-background relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition',
|
'relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-border bg-background transition',
|
||||||
{
|
{
|
||||||
'border-primary/50 bg-primary/5': isDragActive,
|
'border-primary/50 bg-primary/5': isDragActive,
|
||||||
'hover:bg-muted/30':
|
'hover:bg-muted/30':
|
||||||
@@ -193,21 +200,21 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
|
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background/50">
|
||||||
<Loader className="text-muted-foreground h-10 w-10 animate-spin" />
|
<Loader className="h-10 w-10 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 rounded-lg border p-4">
|
<div className="mt-2 rounded-lg border p-4">
|
||||||
<div className="flex items-center gap-x-4">
|
<div className="flex items-center gap-x-4">
|
||||||
<div className="bg-primary/10 text-primary flex h-12 w-12 items-center justify-center rounded-md">
|
<div className="flex h-12 w-12 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||||
<FileText className="h-6 w-6" />
|
<FileText className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-sm font-medium">{documentData.name}</div>
|
<div className="text-sm font-medium">{documentData.name}</div>
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-xs text-muted-foreground">
|
||||||
{formatFileSize(documentData.size)}
|
{formatFileSize(documentData.size)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ export const ZConfigureEmbedFormSchema = z.object({
|
|||||||
type: z.string(),
|
type: z.string(),
|
||||||
size: z.number(),
|
size: z.number(),
|
||||||
data: z.instanceof(Uint8Array), // UInt8Array can't be directly validated by zod
|
data: z.instanceof(Uint8Array), // UInt8Array can't be directly validated by zod
|
||||||
|
images: z
|
||||||
|
.object({
|
||||||
|
width: z.number(),
|
||||||
|
height: z.number(),
|
||||||
|
image: z.string(),
|
||||||
|
})
|
||||||
|
.array(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useLingui } from '@lingui/react';
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { EnvelopeItem, FieldType } from '@prisma/client';
|
import type { EnvelopeItem, FieldType } from '@prisma/client';
|
||||||
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
|
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
|
||||||
import { base64 } from '@scure/base';
|
|
||||||
import { ChevronsUpDown } from 'lucide-react';
|
import { ChevronsUpDown } from 'lucide-react';
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
@@ -16,6 +15,7 @@ import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
|||||||
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
|
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
|
||||||
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
|
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -24,7 +24,6 @@ import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/type
|
|||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { FieldSelector } from '@documenso/ui/primitives/field-selector';
|
import { FieldSelector } from '@documenso/ui/primitives/field-selector';
|
||||||
import { Form } from '@documenso/ui/primitives/form/form';
|
import { Form } from '@documenso/ui/primitives/form/form';
|
||||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
|
||||||
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
||||||
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
|
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
@@ -84,7 +83,7 @@ export const ConfigureFieldsView = ({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const normalizedDocumentData = useMemo(() => {
|
const overrideImages = useMemo(() => {
|
||||||
if (envelopeItem) {
|
if (envelopeItem) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -93,7 +92,7 @@ export const ConfigureFieldsView = ({
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return base64.encode(configData.documentData.data);
|
return configData.documentData.images;
|
||||||
}, [configData.documentData]);
|
}, [configData.documentData]);
|
||||||
|
|
||||||
const normalizedEnvelopeItem = useMemo(() => {
|
const normalizedEnvelopeItem = useMemo(() => {
|
||||||
@@ -546,12 +545,13 @@ export const ConfigureFieldsView = ({
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<div>
|
<div>
|
||||||
<PDFViewerLazy
|
<PDFViewer
|
||||||
presignToken={presignToken}
|
presignToken={presignToken}
|
||||||
overrideData={normalizedDocumentData}
|
overrideImages={overrideImages}
|
||||||
envelopeItem={normalizedEnvelopeItem}
|
envelopeItem={normalizedEnvelopeItem}
|
||||||
token={undefined}
|
token={undefined}
|
||||||
version="signed"
|
version="current"
|
||||||
|
scrollParentRef="window"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ElementVisible
|
<ElementVisible
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ import type {
|
|||||||
TSignFieldWithTokenMutationSchema,
|
TSignFieldWithTokenMutationSchema,
|
||||||
} from '@documenso/trpc/server/field-router/schema';
|
} from '@documenso/trpc/server/field-router/schema';
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
|
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
|
||||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@@ -341,10 +341,11 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||||
{/* Viewer */}
|
{/* Viewer */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<PDFViewerLazy
|
<PDFViewer
|
||||||
envelopeItem={envelopeItems[0]}
|
envelopeItem={envelopeItems[0]}
|
||||||
token={recipient.token}
|
token={recipient.token}
|
||||||
version="signed"
|
version="current"
|
||||||
|
scrollParentRef="window"
|
||||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ import {
|
|||||||
DocumentReadOnlyFields,
|
DocumentReadOnlyFields,
|
||||||
} from '@documenso/ui/components/document/document-read-only-fields';
|
} from '@documenso/ui/components/document/document-read-only-fields';
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
|
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
|
||||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
@@ -287,10 +287,11 @@ export const EmbedSignDocumentV1ClientPage = ({
|
|||||||
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||||
{/* Viewer */}
|
{/* Viewer */}
|
||||||
<div className="embed--DocumentViewer flex-1">
|
<div className="embed--DocumentViewer flex-1">
|
||||||
<PDFViewerLazy
|
<PDFViewer
|
||||||
envelopeItem={envelopeItems[0]}
|
envelopeItem={envelopeItems[0]}
|
||||||
token={token}
|
token={token}
|
||||||
version="signed"
|
version="current"
|
||||||
|
scrollParentRef="window"
|
||||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
@@ -17,12 +17,12 @@ import type {
|
|||||||
} from '@documenso/trpc/server/field-router/schema';
|
} from '@documenso/trpc/server/field-router/schema';
|
||||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
|
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
|
||||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@@ -66,6 +66,8 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
|
|
||||||
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||||
|
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||||
@@ -179,7 +181,11 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen overflow-hidden bg-background">
|
<div className="min-h-screen overflow-hidden bg-background">
|
||||||
<div id="document-field-portal-root" className="relative h-full w-full overflow-y-auto p-8">
|
<div
|
||||||
|
id="document-field-portal-root"
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
className="relative h-full w-full overflow-y-auto p-8"
|
||||||
|
>
|
||||||
{match({ isLoading, document })
|
{match({ isLoading, document })
|
||||||
.with({ isLoading: true }, () => (
|
.with({ isLoading: true }, () => (
|
||||||
<div className="flex min-h-[400px] w-full items-center justify-center">
|
<div className="flex min-h-[400px] w-full items-center justify-center">
|
||||||
@@ -226,10 +232,11 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
'md:mx-auto md:max-w-2xl': document.status === DocumentStatus.COMPLETED,
|
'md:mx-auto md:max-w-2xl': document.status === DocumentStatus.COMPLETED,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<PDFViewerLazy
|
<PDFViewer
|
||||||
envelopeItem={document.envelopeItems[0]}
|
envelopeItem={document.envelopeItems[0]}
|
||||||
token={token}
|
token={token}
|
||||||
version="signed"
|
version="current"
|
||||||
|
scrollParentRef={scrollContainerRef}
|
||||||
onDocumentLoad={() => {
|
onDocumentLoad={() => {
|
||||||
setHasDocumentLoaded(true);
|
setHasDocumentLoaded(true);
|
||||||
onDocumentReady?.();
|
onDocumentReady?.();
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-
|
|||||||
import type { TTemplate } from '@documenso/lib/types/template';
|
import type { TTemplate } from '@documenso/lib/types/template';
|
||||||
import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
|
||||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@@ -151,11 +151,12 @@ export const DirectTemplatePageView = ({
|
|||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewerLazy
|
<PDFViewer
|
||||||
key={template.id}
|
key={template.id}
|
||||||
envelopeItem={template.envelopeItems[0]}
|
envelopeItem={template.envelopeItems[0]}
|
||||||
token={directTemplateRecipient.token}
|
token={directTemplateRecipient.token}
|
||||||
version="signed"
|
version="current"
|
||||||
|
scrollParentRef="window"
|
||||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
+4
-3
@@ -27,10 +27,10 @@ import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/fie
|
|||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||||
|
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
|
||||||
|
|
||||||
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
|
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
|
||||||
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
|
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
|
||||||
@@ -274,11 +274,12 @@ export const DocumentSigningPageViewV1 = ({
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewerLazy
|
<PDFViewer
|
||||||
key={document.envelopeItems[0].id}
|
key={document.envelopeItems[0].id}
|
||||||
envelopeItem={document.envelopeItems[0]}
|
envelopeItem={document.envelopeItems[0]}
|
||||||
token={recipient.token}
|
token={recipient.token}
|
||||||
version="signed"
|
version="current"
|
||||||
|
scrollParentRef="window"
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
+13
-6
@@ -1,4 +1,4 @@
|
|||||||
import { lazy, useMemo } from 'react';
|
import { lazy, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { Plural, Trans } from '@lingui/react/macro';
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
import { EnvelopeType, RecipientRole } from '@prisma/client';
|
import { EnvelopeType, RecipientRole } from '@prisma/client';
|
||||||
@@ -8,8 +8,9 @@ import { Link } from 'react-router';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
|
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Separator } from '@documenso/ui/primitives/separator';
|
import { Separator } from '@documenso/ui/primitives/separator';
|
||||||
|
|
||||||
@@ -40,6 +41,8 @@ const EnvelopeSignerPageRenderer = lazy(
|
|||||||
export const DocumentSigningPageViewV2 = () => {
|
export const DocumentSigningPageViewV2 = () => {
|
||||||
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
|
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isDirectTemplate,
|
isDirectTemplate,
|
||||||
envelope,
|
envelope,
|
||||||
@@ -199,7 +202,10 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="embed--DocumentContainer flex-1 overflow-y-auto">
|
<div
|
||||||
|
className="embed--DocumentContainer flex-1 overflow-y-auto"
|
||||||
|
ref={scrollableContainerRef}
|
||||||
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{/* Horizontal envelope item selector */}
|
{/* Horizontal envelope item selector */}
|
||||||
{envelopeItems.length > 1 && (
|
{envelopeItems.length > 1 && (
|
||||||
@@ -228,15 +234,16 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
{/* Document View */}
|
{/* Document View */}
|
||||||
<div className="embed--DocumentViewer flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
|
<div className="embed--DocumentViewer flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
|
||||||
{currentEnvelopeItem ? (
|
{currentEnvelopeItem ? (
|
||||||
<PDFViewerKonvaLazy
|
<EnvelopePdfViewer
|
||||||
renderer="signing"
|
|
||||||
key={currentEnvelopeItem.id}
|
key={currentEnvelopeItem.id}
|
||||||
customPageRenderer={EnvelopeSignerPageRenderer}
|
customPageRenderer={EnvelopeSignerPageRenderer}
|
||||||
|
scrollParentRef={scrollableContainerRef}
|
||||||
|
errorMessage={PDF_VIEWER_ERROR_MESSAGES.signing}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-32">
|
<div className="flex flex-col items-center justify-center py-32">
|
||||||
<p className="text-sm text-foreground">
|
<p className="text-sm text-foreground">
|
||||||
<Trans>No documents found</Trans>
|
<Trans>No document selected</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Paperclip, Plus, X } from 'lucide-react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -49,9 +50,16 @@ export const DocumentAttachmentsPopover = ({
|
|||||||
|
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
|
const { data: attachments } = trpc.envelope.attachment.find.useQuery(
|
||||||
envelopeId,
|
{
|
||||||
});
|
envelopeId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Note: The invalidation of the query is manually handled by the onSuccess
|
||||||
|
// callbacks below for create and delete mutations.
|
||||||
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const { mutateAsync: createAttachment, isPending: isCreating } =
|
const { mutateAsync: createAttachment, isPending: isCreating } =
|
||||||
trpc.envelope.attachment.create.useMutation({
|
trpc.envelope.attachment.create.useMutation({
|
||||||
@@ -143,7 +151,7 @@ export const DocumentAttachmentsPopover = ({
|
|||||||
<h4 className="font-medium">
|
<h4 className="font-medium">
|
||||||
<Trans>Attachments</Trans>
|
<Trans>Attachments</Trans>
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-muted-foreground mt-1 text-sm">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
<Trans>Add links to relevant documents or resources.</Trans>
|
<Trans>Add links to relevant documents or resources.</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,7 +161,7 @@ export const DocumentAttachmentsPopover = ({
|
|||||||
{attachments?.data.map((attachment) => (
|
{attachments?.data.map((attachment) => (
|
||||||
<div
|
<div
|
||||||
key={attachment.id}
|
key={attachment.id}
|
||||||
className="border-border flex items-center justify-between rounded-md border p-2"
|
className="flex items-center justify-between rounded-md border border-border p-2"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-medium">{attachment.label}</p>
|
<p className="truncate text-sm font-medium">{attachment.label}</p>
|
||||||
@@ -161,7 +169,7 @@ export const DocumentAttachmentsPopover = ({
|
|||||||
href={attachment.data}
|
href={attachment.data}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-muted-foreground hover:text-foreground truncate text-xs underline"
|
className="truncate text-xs text-muted-foreground underline hover:text-foreground"
|
||||||
>
|
>
|
||||||
{attachment.data}
|
{attachment.data}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { lazy, useEffect, useState } from 'react';
|
import { lazy, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client';
|
import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client';
|
||||||
@@ -9,9 +9,11 @@ import {
|
|||||||
EnvelopeRenderProvider,
|
EnvelopeRenderProvider,
|
||||||
useCurrentEnvelopeRender,
|
useCurrentEnvelopeRender,
|
||||||
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
|
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
|
||||||
|
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -21,7 +23,6 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
|
||||||
|
|
||||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ export type DocumentCertificateQRViewProps = {
|
|||||||
documentId: number;
|
documentId: number;
|
||||||
title: string;
|
title: string;
|
||||||
internalVersion: number;
|
internalVersion: number;
|
||||||
envelopeItems: (EnvelopeItem & { documentData: DocumentData })[];
|
envelopeItems: (EnvelopeItem & { documentData: Omit<DocumentData, 'metadata'> })[];
|
||||||
documentTeamUrl: string;
|
documentTeamUrl: string;
|
||||||
recipientCount?: number;
|
recipientCount?: number;
|
||||||
completedDate?: Date;
|
completedDate?: Date;
|
||||||
@@ -104,11 +105,13 @@ export const DocumentCertificateQRView = ({
|
|||||||
|
|
||||||
{internalVersion === 2 ? (
|
{internalVersion === 2 ? (
|
||||||
<EnvelopeRenderProvider
|
<EnvelopeRenderProvider
|
||||||
|
version="current"
|
||||||
envelope={{
|
envelope={{
|
||||||
envelopeItems,
|
id: envelopeItems[0].envelopeId,
|
||||||
status: DocumentStatus.COMPLETED,
|
status: DocumentStatus.COMPLETED,
|
||||||
type: EnvelopeType.DOCUMENT,
|
type: EnvelopeType.DOCUMENT,
|
||||||
}}
|
}}
|
||||||
|
envelopeItems={envelopeItems}
|
||||||
token={token}
|
token={token}
|
||||||
>
|
>
|
||||||
<DocumentCertificateQrV2
|
<DocumentCertificateQrV2
|
||||||
@@ -149,11 +152,12 @@ export const DocumentCertificateQRView = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 w-full">
|
<div className="mt-12 w-full">
|
||||||
<PDFViewerLazy
|
<PDFViewer
|
||||||
key={envelopeItems[0].id}
|
key={envelopeItems[0].id}
|
||||||
envelopeItem={envelopeItems[0]}
|
envelopeItem={envelopeItems[0]}
|
||||||
token={token}
|
token={token}
|
||||||
version="signed"
|
version="current"
|
||||||
|
scrollParentRef="window"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -175,7 +179,9 @@ const DocumentCertificateQrV2 = ({
|
|||||||
formattedDate,
|
formattedDate,
|
||||||
token,
|
token,
|
||||||
}: DocumentCertificateQrV2Props) => {
|
}: DocumentCertificateQrV2Props) => {
|
||||||
const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender();
|
const { envelopeItems } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
|
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-start">
|
<div className="flex min-h-screen flex-col items-start">
|
||||||
@@ -207,10 +213,14 @@ const DocumentCertificateQrV2 = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 w-full">
|
<div className="mt-12 max-h-[80vh] w-full overflow-y-auto" ref={scrollableContainerRef}>
|
||||||
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
|
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
|
||||||
|
|
||||||
<PDFViewerKonvaLazy renderer="preview" customPageRenderer={EnvelopeGenericPageRenderer} />
|
<EnvelopePdfViewer
|
||||||
|
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||||
|
scrollParentRef={scrollableContainerRef}
|
||||||
|
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import type { TDocument } from '@documenso/lib/types/document';
|
import type { TDocument } from '@documenso/lib/types/document';
|
||||||
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||||
@@ -27,7 +28,6 @@ import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/ad
|
|||||||
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
|
||||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@@ -440,11 +440,12 @@ export const DocumentEditForm = ({
|
|||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewerLazy
|
<PDFViewer
|
||||||
key={document.envelopeItems[0].id}
|
key={document.envelopeItems[0].id}
|
||||||
envelopeItem={document.envelopeItems[0]}
|
envelopeItem={document.envelopeItems[0]}
|
||||||
token={undefined}
|
token={undefined}
|
||||||
version="signed"
|
version="current"
|
||||||
|
scrollParentRef="window"
|
||||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
+3
-12
@@ -175,15 +175,6 @@ export const EnvelopeEditorFieldDragDrop = ({
|
|||||||
|
|
||||||
const { top, left, height, width } = getBoundingClientRect($page);
|
const { top, left, height, width } = getBoundingClientRect($page);
|
||||||
|
|
||||||
console.log({
|
|
||||||
top,
|
|
||||||
left,
|
|
||||||
height,
|
|
||||||
width,
|
|
||||||
rawPageX: event.pageX,
|
|
||||||
rawPageY: event.pageY,
|
|
||||||
});
|
|
||||||
|
|
||||||
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
|
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
|
||||||
|
|
||||||
// Calculate x and y as a percentage of the page width and height
|
// Calculate x and y as a percentage of the page width and height
|
||||||
@@ -278,13 +269,13 @@ export const EnvelopeEditorFieldDragDrop = ({
|
|||||||
onMouseDown={() => setSelectedField(field.type)}
|
onMouseDown={() => setSelectedField(field.type)}
|
||||||
data-selected={selectedField === field.type ? true : undefined}
|
data-selected={selectedField === field.type ? true : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-border group flex h-12 cursor-pointer items-center justify-center rounded-lg border px-4 transition-colors',
|
'group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-border px-4 transition-colors',
|
||||||
RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton,
|
RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-muted-foreground font-noto group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
'flex items-center justify-center gap-x-1.5 font-noto text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
|
||||||
field.className,
|
field.className,
|
||||||
{
|
{
|
||||||
'group-hover:text-recipient-green': selectedRecipientColor === 'green',
|
'group-hover:text-recipient-green': selectedRecipientColor === 'green',
|
||||||
@@ -306,7 +297,7 @@ export const EnvelopeEditorFieldDragDrop = ({
|
|||||||
{selectedField && (
|
{selectedField && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-muted-foreground dark:text-muted-background font-noto pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
|
'dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size]',
|
||||||
RECIPIENT_COLOR_STYLES[selectedRecipientColor].base,
|
RECIPIENT_COLOR_STYLES[selectedRecipientColor].base,
|
||||||
selectedField === FieldType.SIGNATURE && 'font-signature',
|
selectedField === FieldType.SIGNATURE && 'font-signature',
|
||||||
{
|
{
|
||||||
|
|||||||
+20
-29
@@ -10,7 +10,10 @@ import { CopyPlusIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide
|
|||||||
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
|
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
|
||||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import {
|
||||||
|
type PageRenderData,
|
||||||
|
useCurrentEnvelopeRender,
|
||||||
|
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||||
import {
|
import {
|
||||||
MIN_FIELD_HEIGHT_PX,
|
MIN_FIELD_HEIGHT_PX,
|
||||||
@@ -22,10 +25,15 @@ import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
|
|||||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||||
import { CommandDialog } from '@documenso/ui/primitives/command';
|
import { CommandDialog } from '@documenso/ui/primitives/command';
|
||||||
|
|
||||||
|
import { EnvelopePageImage } from '../envelope/envelope-page-image';
|
||||||
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
|
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
|
||||||
import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector';
|
import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector';
|
||||||
|
|
||||||
export default function EnvelopeEditorFieldsPageRenderer() {
|
export default function EnvelopeEditorFieldsPageRenderer({
|
||||||
|
pageData,
|
||||||
|
}: {
|
||||||
|
pageData: PageRenderData;
|
||||||
|
}) {
|
||||||
const { t, i18n } = useLingui();
|
const { t, i18n } = useLingui();
|
||||||
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||||
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
||||||
@@ -40,31 +48,24 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
const {
|
const {
|
||||||
stage,
|
stage,
|
||||||
pageLayer,
|
pageLayer,
|
||||||
canvasElement,
|
|
||||||
konvaContainer,
|
konvaContainer,
|
||||||
pageContext,
|
|
||||||
scaledViewport,
|
scaledViewport,
|
||||||
unscaledViewport,
|
unscaledViewport,
|
||||||
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
|
renderStatus,
|
||||||
|
imageProps,
|
||||||
|
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer), pageData);
|
||||||
|
|
||||||
const { _className, scale } = pageContext;
|
const { scale, pageNumber } = pageData;
|
||||||
|
|
||||||
const localPageFields = useMemo(
|
const localPageFields = useMemo(
|
||||||
() =>
|
() =>
|
||||||
editorFields.localFields.filter(
|
editorFields.localFields.filter(
|
||||||
(field) =>
|
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
|
||||||
),
|
),
|
||||||
[editorFields.localFields, pageContext.pageNumber],
|
[editorFields.localFields, pageNumber],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
|
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
|
||||||
const { current: container } = canvasElement;
|
|
||||||
|
|
||||||
if (!container) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDragEvent = event.type === 'dragend';
|
const isDragEvent = event.type === 'dragend';
|
||||||
|
|
||||||
const fieldGroup = event.target as Konva.Group;
|
const fieldGroup = event.target as Konva.Group;
|
||||||
@@ -344,7 +345,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
// Create a field if no items are selected or the size is too small.
|
// Create a field if no items are selected or the size is too small.
|
||||||
if (
|
if (
|
||||||
selectedFieldGroups.length === 0 &&
|
selectedFieldGroups.length === 0 &&
|
||||||
canvasElement.current &&
|
|
||||||
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
|
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
|
||||||
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
|
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
|
||||||
editorFields.selectedRecipient &&
|
editorFields.selectedRecipient &&
|
||||||
@@ -515,7 +515,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
|
|
||||||
removePendingField();
|
removePendingField();
|
||||||
|
|
||||||
if (!canvasElement.current || !currentEnvelopeItem || !editorFields.selectedRecipient) {
|
if (!currentEnvelopeItem || !editorFields.selectedRecipient) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,7 +530,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
|
|
||||||
editorFields.addField({
|
editorFields.addField({
|
||||||
envelopeItemId: currentEnvelopeItem.id,
|
envelopeItemId: currentEnvelopeItem.id,
|
||||||
page: pageContext.pageNumber,
|
page: pageNumber,
|
||||||
type,
|
type,
|
||||||
positionX: fieldX,
|
positionX: fieldX,
|
||||||
positionY: fieldY,
|
positionY: fieldY,
|
||||||
@@ -559,10 +559,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="relative w-full" key={`${currentEnvelopeItem.id}-renderer-${pageNumber}`}>
|
||||||
className="relative w-full"
|
|
||||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
|
||||||
>
|
|
||||||
{selectedKonvaFieldGroups.length > 0 &&
|
{selectedKonvaFieldGroups.length > 0 &&
|
||||||
interactiveTransformer.current &&
|
interactiveTransformer.current &&
|
||||||
!isFieldChanging && (
|
!isFieldChanging && (
|
||||||
@@ -625,13 +622,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
{/* The element Konva will inject it's canvas into. */}
|
{/* The element Konva will inject it's canvas into. */}
|
||||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
||||||
|
|
||||||
{/* Canvas the PDF will be rendered on. */}
|
<EnvelopePageImage renderStatus={renderStatus} imageProps={imageProps} />
|
||||||
<canvas
|
|
||||||
className={`${_className}__canvas z-0`}
|
|
||||||
ref={canvasElement}
|
|
||||||
height={scaledViewport.height}
|
|
||||||
width={scaledViewport.width}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { lazy, useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import type { MessageDescriptor } from '@lingui/core';
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@@ -6,12 +6,13 @@ import { useLingui } from '@lingui/react';
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
||||||
import { FileTextIcon, SparklesIcon } from 'lucide-react';
|
import { FileTextIcon, SparklesIcon } from 'lucide-react';
|
||||||
import { Link, useRevalidator, useSearchParams } from 'react-router';
|
import { useRevalidator, useSearchParams } from 'react-router';
|
||||||
import { isDeepEqual } from 'remeda';
|
import { isDeepEqual } from 'remeda';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
|
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||||
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
||||||
import {
|
import {
|
||||||
FIELD_META_DEFAULT_VALUES,
|
FIELD_META_DEFAULT_VALUES,
|
||||||
@@ -29,7 +30,7 @@ import {
|
|||||||
} from '@documenso/lib/types/field-meta';
|
} from '@documenso/lib/types/field-meta';
|
||||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Separator } from '@documenso/ui/primitives/separator';
|
import { Separator } from '@documenso/ui/primitives/separator';
|
||||||
@@ -49,13 +50,10 @@ import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text
|
|||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
|
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
|
||||||
|
import EnvelopeEditorFieldsPageRenderer from './envelope-editor-fields-page-renderer';
|
||||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||||
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
|
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
|
||||||
|
|
||||||
const EnvelopeEditorFieldsPageRenderer = lazy(
|
|
||||||
async () => import('~/components/general/envelope-editor/envelope-editor-fields-page-renderer'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
||||||
[FieldType.SIGNATURE]: msg`Signature Settings`,
|
[FieldType.SIGNATURE]: msg`Signature Settings`,
|
||||||
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
|
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
|
||||||
@@ -75,7 +73,9 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
|
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
@@ -97,14 +97,10 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
|
|
||||||
const isMetaSame = isDeepEqual(selectedField.fieldMeta, fieldMeta);
|
const isMetaSame = isDeepEqual(selectedField.fieldMeta, fieldMeta);
|
||||||
|
|
||||||
// Todo: Envelopes - Clean up console logs.
|
|
||||||
if (!isMetaSame) {
|
if (!isMetaSame) {
|
||||||
console.log('TRIGGER UPDATE');
|
|
||||||
editorFields.updateFieldByFormId(selectedField.formId, {
|
editorFields.updateFieldByFormId(selectedField.formId, {
|
||||||
fieldMeta,
|
fieldMeta,
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
console.log('DATA IS SAME, NO UPDATE');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -156,12 +152,12 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full">
|
<div className="relative flex h-full">
|
||||||
<div className="flex w-full flex-col overflow-y-auto">
|
<div className="flex h-full w-full flex-col overflow-y-auto" ref={scrollableContainerRef}>
|
||||||
{/* Horizontal envelope item selector */}
|
{/* Horizontal envelope item selector */}
|
||||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||||
|
|
||||||
{/* Document View */}
|
{/* Document View */}
|
||||||
<div className="mt-4 flex flex-col items-center justify-center">
|
<div className="mt-4 flex h-full flex-col items-center justify-center">
|
||||||
{envelope.recipients.length === 0 && (
|
{envelope.recipients.length === 0 && (
|
||||||
<Alert
|
<Alert
|
||||||
variant="neutral"
|
variant="neutral"
|
||||||
@@ -176,18 +172,17 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button asChild variant="outline">
|
<Button variant="outline" onClick={() => void navigateToStep('upload')}>
|
||||||
<Link to={`${relativePath.editorPath}`}>
|
<Trans>Add Recipients</Trans>
|
||||||
<Trans>Add Recipients</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentEnvelopeItem !== null ? (
|
{currentEnvelopeItem !== null ? (
|
||||||
<PDFViewerKonvaLazy
|
<EnvelopePdfViewer
|
||||||
renderer="editor"
|
|
||||||
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
|
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
|
||||||
|
scrollParentRef={scrollableContainerRef}
|
||||||
|
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-32">
|
<div className="flex flex-col items-center justify-center py-32">
|
||||||
@@ -249,36 +244,40 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
|
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
{editorConfig.fields?.allowAIDetection && (
|
||||||
type="button"
|
<>
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
type="button"
|
||||||
className="mt-4 w-full"
|
variant="outline"
|
||||||
onClick={onDetectClick}
|
size="sm"
|
||||||
disabled={envelope.status !== DocumentStatus.DRAFT}
|
className="mt-4 w-full"
|
||||||
title={
|
onClick={onDetectClick}
|
||||||
envelope.status !== DocumentStatus.DRAFT
|
disabled={envelope.status !== DocumentStatus.DRAFT}
|
||||||
? _(msg`You can only detect fields in draft envelopes`)
|
title={
|
||||||
: undefined
|
envelope.status !== DocumentStatus.DRAFT
|
||||||
}
|
? _(msg`You can only detect fields in draft envelopes`)
|
||||||
>
|
: undefined
|
||||||
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
|
}
|
||||||
<Trans>Detect with AI</Trans>
|
>
|
||||||
</Button>
|
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
<Trans>Detect with AI</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<AiFieldDetectionDialog
|
<AiFieldDetectionDialog
|
||||||
open={isAiFieldDialogOpen}
|
open={isAiFieldDialogOpen}
|
||||||
onOpenChange={setIsAiFieldDialogOpen}
|
onOpenChange={setIsAiFieldDialogOpen}
|
||||||
onComplete={onFieldDetectionComplete}
|
onComplete={onFieldDetectionComplete}
|
||||||
envelopeId={envelope.id}
|
envelopeId={envelope.id}
|
||||||
teamId={envelope.teamId}
|
teamId={envelope.teamId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AiFeaturesEnableDialog
|
<AiFeaturesEnableDialog
|
||||||
open={isAiEnableDialogOpen}
|
open={isAiEnableDialogOpen}
|
||||||
onOpenChange={setIsAiEnableDialogOpen}
|
onOpenChange={setIsAiEnableDialogOpen}
|
||||||
onEnabled={onAiFeaturesEnabled}
|
onEnabled={onAiFeaturesEnabled}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Field details section. */}
|
{/* Field details section. */}
|
||||||
|
|||||||
@@ -30,21 +30,56 @@ import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
|
|||||||
export default function EnvelopeEditorHeader() {
|
export default function EnvelopeEditorHeader() {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
const { envelope, isDocument, isTemplate, updateEnvelope, autosaveError, relativePath } =
|
const {
|
||||||
useCurrentEnvelopeEditor();
|
envelope,
|
||||||
|
isDocument,
|
||||||
|
isTemplate,
|
||||||
|
isEmbedded,
|
||||||
|
updateEnvelope,
|
||||||
|
autosaveError,
|
||||||
|
relativePath,
|
||||||
|
editorConfig,
|
||||||
|
flushAutosave,
|
||||||
|
} = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
|
const {
|
||||||
|
embeded,
|
||||||
|
general: { allowConfigureEnvelopeTitle },
|
||||||
|
actions: { allowAttachments, allowDistributing },
|
||||||
|
} = editorConfig;
|
||||||
|
|
||||||
|
const handleCreateEmbeddedEnvelope = async () => {
|
||||||
|
const latestEnvelope = await flushAutosave();
|
||||||
|
|
||||||
|
embeded?.onCreate?.(latestEnvelope);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateEmbeddedEnvelope = async () => {
|
||||||
|
const latestEnvelope = await flushAutosave();
|
||||||
|
|
||||||
|
embeded?.onUpdate?.(latestEnvelope);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="w-full border-b border-border bg-background px-4 py-3 md:px-6">
|
<nav className="w-full border-b border-border bg-background px-4 py-3 md:px-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<Link to="/">
|
{editorConfig.embeded?.customBrandingLogo ? (
|
||||||
<BrandingLogo className="h-6 w-auto" />
|
<img
|
||||||
</Link>
|
src={`/api/branding/logo/team/${envelope.teamId}`}
|
||||||
|
alt="Logo"
|
||||||
|
className="h-6 w-auto"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Link to="/">
|
||||||
|
<BrandingLogo className="h-6 w-auto" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<Separator orientation="vertical" className="h-6" />
|
<Separator orientation="vertical" className="h-6" />
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<EnvelopeItemTitleInput
|
<EnvelopeItemTitleInput
|
||||||
disabled={envelope.status !== DocumentStatus.DRAFT}
|
disabled={envelope.status !== DocumentStatus.DRAFT || !allowConfigureEnvelopeTitle}
|
||||||
value={envelope.title}
|
value={envelope.title}
|
||||||
onChange={(title) => {
|
onChange={(title) => {
|
||||||
updateEnvelope({
|
updateEnvelope({
|
||||||
@@ -127,54 +162,72 @@ export default function EnvelopeEditorHeader() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
|
{allowAttachments && (
|
||||||
|
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
|
||||||
<EnvelopeEditorSettingsDialog
|
|
||||||
trigger={
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<SettingsIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isDocument && (
|
|
||||||
<>
|
|
||||||
<EnvelopeDistributeDialog
|
|
||||||
documentRootPath={relativePath.documentRootPath}
|
|
||||||
trigger={
|
|
||||||
<Button size="sm">
|
|
||||||
<SendIcon className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Send Document</Trans>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EnvelopeRedistributeDialog
|
|
||||||
envelope={envelope}
|
|
||||||
trigger={
|
|
||||||
<Button size="sm">
|
|
||||||
<SendIcon className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Resend Document</Trans>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isTemplate && (
|
{editorConfig.settings && (
|
||||||
<TemplateUseDialog
|
<EnvelopeEditorSettingsDialog
|
||||||
envelopeId={envelope.id}
|
|
||||||
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
|
|
||||||
templateSigningOrder={envelope.documentMeta?.signingOrder}
|
|
||||||
recipients={envelope.recipients}
|
|
||||||
documentRootPath={relativePath.documentRootPath}
|
|
||||||
trigger={
|
trigger={
|
||||||
<Button size="sm">
|
<Button variant="outline" size="sm">
|
||||||
<Trans>Use Template</Trans>
|
<SettingsIcon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{match({ isEmbedded, isDocument, isTemplate, allowDistributing })
|
||||||
|
.with({ isEmbedded: false, isDocument: true, allowDistributing: true }, () => (
|
||||||
|
<>
|
||||||
|
<EnvelopeDistributeDialog
|
||||||
|
documentRootPath={relativePath.documentRootPath}
|
||||||
|
trigger={
|
||||||
|
<Button size="sm">
|
||||||
|
<SendIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Send Document</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EnvelopeRedistributeDialog
|
||||||
|
envelope={envelope}
|
||||||
|
trigger={
|
||||||
|
<Button size="sm">
|
||||||
|
<SendIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Resend Document</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
.with({ isEmbedded: false, isTemplate: true, allowDistributing: true }, () => (
|
||||||
|
<TemplateUseDialog
|
||||||
|
envelopeId={envelope.id}
|
||||||
|
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
|
||||||
|
templateSigningOrder={envelope.documentMeta?.signingOrder}
|
||||||
|
recipients={envelope.recipients}
|
||||||
|
documentRootPath={relativePath.documentRootPath}
|
||||||
|
trigger={
|
||||||
|
<Button size="sm">
|
||||||
|
<Trans>Use Template</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
.otherwise(() => null)}
|
||||||
|
|
||||||
|
{embeded?.mode === 'create' && (
|
||||||
|
<Button size="sm" onClick={handleCreateEmbeddedEnvelope}>
|
||||||
|
{isDocument ? <Trans>Create Document</Trans> : <Trans>Create Template</Trans>}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{embeded?.mode === 'edit' && (
|
||||||
|
<Button size="sm" onClick={handleUpdateEmbeddedEnvelope}>
|
||||||
|
{isDocument ? <Trans>Update Document</Trans> : <Trans>Update Template</Trans>}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { lazy, useEffect, useMemo, useState } from 'react';
|
import { lazy, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { faker } from '@faker-js/faker/locale/en';
|
import { faker } from '@faker-js/faker/locale/en';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
@@ -11,12 +11,13 @@ import {
|
|||||||
EnvelopeRenderProvider,
|
EnvelopeRenderProvider,
|
||||||
useCurrentEnvelopeRender,
|
useCurrentEnvelopeRender,
|
||||||
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
|
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||||
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
|
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
|
||||||
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
|
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
|
||||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
||||||
import { Separator } from '@documenso/ui/primitives/separator';
|
import { Separator } from '@documenso/ui/primitives/separator';
|
||||||
@@ -33,6 +34,8 @@ export const EnvelopeEditorPreviewPage = () => {
|
|||||||
|
|
||||||
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
|
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
|
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
|
||||||
'recipient',
|
'recipient',
|
||||||
);
|
);
|
||||||
@@ -200,7 +203,9 @@ export const EnvelopeEditorPreviewPage = () => {
|
|||||||
// Override the parent renderer provider so we can inject custom fields.
|
// Override the parent renderer provider so we can inject custom fields.
|
||||||
return (
|
return (
|
||||||
<EnvelopeRenderProvider
|
<EnvelopeRenderProvider
|
||||||
|
version="current"
|
||||||
envelope={envelope}
|
envelope={envelope}
|
||||||
|
envelopeItems={envelope.envelopeItems}
|
||||||
token={undefined}
|
token={undefined}
|
||||||
fields={fieldsWithPlaceholders}
|
fields={fieldsWithPlaceholders}
|
||||||
recipients={envelope.recipients.map((recipient) => ({
|
recipients={envelope.recipients.map((recipient) => ({
|
||||||
@@ -212,12 +217,12 @@ export const EnvelopeEditorPreviewPage = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative flex h-full">
|
<div className="relative flex h-full">
|
||||||
<div className="flex w-full flex-col overflow-y-auto">
|
<div className="flex w-full flex-col overflow-y-auto" ref={scrollableContainerRef}>
|
||||||
{/* Horizontal envelope item selector */}
|
{/* Horizontal envelope item selector */}
|
||||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||||
|
|
||||||
{/* Document View */}
|
{/* Document View */}
|
||||||
<div className="mt-4 flex flex-col items-center justify-center">
|
<div className="mt-4 flex h-full flex-col items-center justify-center">
|
||||||
<Alert variant="warning" className="mb-4 max-w-[800px]">
|
<Alert variant="warning" className="mb-4 max-w-[800px]">
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
<Trans>Preview Mode</Trans>
|
<Trans>Preview Mode</Trans>
|
||||||
@@ -228,9 +233,10 @@ export const EnvelopeEditorPreviewPage = () => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
{currentEnvelopeItem !== null ? (
|
{currentEnvelopeItem !== null ? (
|
||||||
<PDFViewerKonvaLazy
|
<EnvelopePdfViewer
|
||||||
renderer="editor"
|
|
||||||
customPageRenderer={EnvelopeGenericPageRenderer}
|
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||||
|
scrollParentRef={scrollableContainerRef}
|
||||||
|
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-32">
|
<div className="flex flex-col items-center justify-center py-32">
|
||||||
|
|||||||
+128
-95
@@ -21,7 +21,7 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
|
|||||||
import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
|
import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
|
||||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||||
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
@@ -63,8 +63,14 @@ import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-de
|
|||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export const EnvelopeEditorRecipientForm = () => {
|
export const EnvelopeEditorRecipientForm = () => {
|
||||||
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients } =
|
const {
|
||||||
useCurrentEnvelopeEditor();
|
envelope,
|
||||||
|
setRecipientsDebounced,
|
||||||
|
updateEnvelope,
|
||||||
|
editorRecipients,
|
||||||
|
isEmbedded,
|
||||||
|
editorConfig,
|
||||||
|
} = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
@@ -72,7 +78,9 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { remaining } = useLimits();
|
const { remaining } = useLimits();
|
||||||
const { user } = useSession();
|
const { sessionData } = useOptionalSession();
|
||||||
|
|
||||||
|
const user = sessionData?.user;
|
||||||
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
|
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
|
||||||
@@ -133,6 +141,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: debouncedRecipientSearchQuery.length > 1,
|
enabled: debouncedRecipientSearchQuery.length > 1,
|
||||||
|
retry: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -603,37 +612,41 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row items-center space-x-2">
|
<div className="flex flex-row items-center space-x-2">
|
||||||
<Tooltip>
|
{editorConfig.recipients?.allowAIDetection && (
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<Button
|
<TooltipTrigger asChild>
|
||||||
variant="outline"
|
<Button
|
||||||
type="button"
|
variant="outline"
|
||||||
size="sm"
|
type="button"
|
||||||
disabled={isSubmitting}
|
size="sm"
|
||||||
onClick={onDetectRecipientsClick}
|
disabled={isSubmitting}
|
||||||
>
|
onClick={onDetectRecipientsClick}
|
||||||
<SparklesIcon className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<SparklesIcon className="h-4 w-4" />
|
||||||
</TooltipTrigger>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{team.preferences.aiFeaturesEnabled ? (
|
{team.preferences.aiFeaturesEnabled ? (
|
||||||
<Trans>Detect recipients with AI</Trans>
|
<Trans>Detect recipients with AI</Trans>
|
||||||
) : (
|
) : (
|
||||||
<Trans>Enable AI detection</Trans>
|
<Trans>Enable AI detection</Trans>
|
||||||
)}
|
)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
{!isEmbedded && (
|
||||||
variant="outline"
|
<Button
|
||||||
className="flex flex-row items-center"
|
variant="outline"
|
||||||
size="sm"
|
className="flex flex-row items-center"
|
||||||
disabled={isSubmitting || isUserAlreadyARecipient}
|
size="sm"
|
||||||
onClick={() => onAddSelfSigner()}
|
disabled={isSubmitting || isUserAlreadyARecipient}
|
||||||
>
|
onClick={() => onAddSelfSigner()}
|
||||||
<Trans>Add Myself</Trans>
|
>
|
||||||
</Button>
|
<Trans>Add Myself</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -652,7 +665,13 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
|
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<div className="-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4">
|
<div
|
||||||
|
className={cn('-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4', {
|
||||||
|
hidden:
|
||||||
|
!editorConfig.recipients?.allowConfigureSigningOrder &&
|
||||||
|
!organisation.organisationClaim.flags.cfr21,
|
||||||
|
})}
|
||||||
|
>
|
||||||
{organisation.organisationClaim.flags.cfr21 && (
|
{organisation.organisationClaim.flags.cfr21 && (
|
||||||
<div className="flex flex-row items-center">
|
<div className="flex flex-row items-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -670,64 +689,66 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormField
|
{editorConfig.recipients?.allowConfigureSigningOrder && (
|
||||||
control={form.control}
|
<FormField
|
||||||
name="signingOrder"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="signingOrder"
|
||||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
render={({ field }) => (
|
||||||
<FormControl>
|
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||||
<Checkbox
|
<FormControl>
|
||||||
{...field}
|
<Checkbox
|
||||||
id="signingOrder"
|
{...field}
|
||||||
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
|
id="signingOrder"
|
||||||
onCheckedChange={(checked) => {
|
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
|
||||||
if (!checked && hasAssistantRole) {
|
onCheckedChange={(checked) => {
|
||||||
setShowSigningOrderConfirmation(true);
|
if (!checked && hasAssistantRole) {
|
||||||
return;
|
setShowSigningOrderConfirmation(true);
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
field.onChange(
|
field.onChange(
|
||||||
checked
|
checked
|
||||||
? DocumentSigningOrder.SEQUENTIAL
|
? DocumentSigningOrder.SEQUENTIAL
|
||||||
: DocumentSigningOrder.PARALLEL,
|
: DocumentSigningOrder.PARALLEL,
|
||||||
);
|
);
|
||||||
|
|
||||||
// If sequential signing is turned off, disable dictate next signer
|
// If sequential signing is turned off, disable dictate next signer
|
||||||
if (!checked) {
|
if (!checked) {
|
||||||
form.setValue('allowDictateNextSigner', false, {
|
form.setValue('allowDictateNextSigner', false, {
|
||||||
shouldValidate: true,
|
shouldValidate: true,
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isSubmitting || hasDocumentBeenSent}
|
disabled={isSubmitting || hasDocumentBeenSent}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
<div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||||
<FormLabel
|
<FormLabel
|
||||||
htmlFor="signingOrder"
|
htmlFor="signingOrder"
|
||||||
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
>
|
>
|
||||||
<Trans>Enable signing order</Trans>
|
<Trans>Enable signing order</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="ml-1 cursor-help text-muted-foreground">
|
<span className="ml-1 cursor-help text-muted-foreground">
|
||||||
<HelpCircleIcon className="h-3.5 w-3.5" />
|
<HelpCircleIcon className="h-3.5 w-3.5" />
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="max-w-80 p-4">
|
<TooltipContent className="max-w-80 p-4">
|
||||||
<p>
|
<p>
|
||||||
<Trans>Add 2 or more signers to enable signing order.</Trans>
|
<Trans>Add 2 or more signers to enable signing order.</Trans>
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{isSigningOrderSequential && (
|
{isSigningOrderSequential && (
|
||||||
<FormField
|
<FormField
|
||||||
@@ -987,6 +1008,16 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<RecipientRoleSelect
|
<RecipientRoleSelect
|
||||||
{...field}
|
{...field}
|
||||||
|
hideAssistantRole={
|
||||||
|
!editorConfig.recipients?.allowAssistantRole
|
||||||
|
}
|
||||||
|
hideCCerRole={!editorConfig.recipients?.allowCCerRole}
|
||||||
|
hideViewerRole={
|
||||||
|
!editorConfig.recipients?.allowViewerRole
|
||||||
|
}
|
||||||
|
hideApproverRole={
|
||||||
|
!editorConfig.recipients?.allowApproverRole
|
||||||
|
}
|
||||||
isAssistantEnabled={isSigningOrderSequential}
|
isAssistantEnabled={isSigningOrderSequential}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
@@ -1083,13 +1114,15 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
onConfirm={handleSigningOrderDisable}
|
onConfirm={handleSigningOrderDisable}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AiRecipientDetectionDialog
|
{editorConfig.recipients?.allowAIDetection && (
|
||||||
open={isAiDialogOpen}
|
<AiRecipientDetectionDialog
|
||||||
onOpenChange={onAiDialogOpenChange}
|
open={isAiDialogOpen}
|
||||||
onComplete={onAiDetectionComplete}
|
onOpenChange={onAiDialogOpenChange}
|
||||||
envelopeId={envelope.id}
|
onComplete={onAiDetectionComplete}
|
||||||
teamId={envelope.teamId}
|
envelopeId={envelope.id}
|
||||||
/>
|
teamId={envelope.teamId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<AiFeaturesEnableDialog
|
<AiFeaturesEnableDialog
|
||||||
open={isAiEnableDialogOpen}
|
open={isAiEnableDialogOpen}
|
||||||
|
|||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
|
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
|
|
||||||
|
export const EnvelopeEditorRenderProviderWrapper = ({
|
||||||
|
children,
|
||||||
|
token,
|
||||||
|
presignedToken,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
token?: string;
|
||||||
|
presignedToken?: string;
|
||||||
|
}) => {
|
||||||
|
const { envelope } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EnvelopeRenderProvider
|
||||||
|
envelope={envelope}
|
||||||
|
envelopeItems={envelope.envelopeItems}
|
||||||
|
token={token}
|
||||||
|
presignToken={presignedToken}
|
||||||
|
version="current"
|
||||||
|
fields={envelope.fields}
|
||||||
|
recipients={envelope.recipients}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</EnvelopeRenderProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
+382
-344
@@ -28,6 +28,7 @@ import {
|
|||||||
isValidLanguageCode,
|
isValidLanguageCode,
|
||||||
} from '@documenso/lib/constants/i18n';
|
} from '@documenso/lib/constants/i18n';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||||
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import {
|
import {
|
||||||
ZDocumentAccessAuthTypesSchema,
|
ZDocumentAccessAuthTypesSchema,
|
||||||
@@ -174,7 +175,9 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
const { t, i18n } = useLingui();
|
const { t, i18n } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { envelope, updateEnvelopeAsync } = useCurrentEnvelopeEditor();
|
const { envelope, updateEnvelopeAsync, editorConfig, isEmbedded } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
|
const { settings } = editorConfig;
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
@@ -223,10 +226,15 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
const emailSettings = form.watch('meta.emailSettings');
|
const emailSettings = form.watch('meta.emailSettings');
|
||||||
|
|
||||||
const { data: emailData, isLoading: isLoadingEmails } =
|
const { data: emailData, isLoading: isLoadingEmails } =
|
||||||
trpc.enterprise.organisation.email.find.useQuery({
|
trpc.enterprise.organisation.email.find.useQuery(
|
||||||
organisationId: organisation.id,
|
{
|
||||||
perPage: 100,
|
organisationId: organisation.id,
|
||||||
});
|
perPage: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const emails = emailData?.data || [];
|
const emails = emailData?.data || [];
|
||||||
|
|
||||||
@@ -278,11 +286,13 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
||||||
toast({
|
if (!isEmbedded) {
|
||||||
title: t`Success`,
|
toast({
|
||||||
description: t`Envelope updated`,
|
title: t`Success`,
|
||||||
duration: 5000,
|
description: t`Envelope updated`,
|
||||||
});
|
duration: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
@@ -319,7 +329,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
|
|
||||||
const selectedTab = tabs.find((tab) => tab.id === activeTab);
|
const selectedTab = tabs.find((tab) => tab.id === activeTab);
|
||||||
|
|
||||||
if (!selectedTab) {
|
if (!selectedTab || !settings) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,26 +350,32 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
<DialogContent className="flex w-full !max-w-5xl flex-row gap-0 p-0">
|
<DialogContent className="flex w-full !max-w-5xl flex-row gap-0 p-0">
|
||||||
{/* Sidebar. */}
|
{/* Sidebar. */}
|
||||||
<div className="flex w-80 flex-col border-r bg-accent/20">
|
<div className="flex w-80 flex-col border-r bg-accent/20">
|
||||||
<DialogHeader className="p-6 pb-4">
|
<DialogHeader className="p-6 pb-4" data-testid="envelope-editor-settings-dialog-header">
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Document Settings</Trans>
|
<Trans>Document Settings</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<nav className="col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 px-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2">
|
<nav className="col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 px-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => {
|
||||||
<Button
|
if (tab.id === 'email' && !settings.allowConfigureDistribution) {
|
||||||
key={tab.id}
|
return null;
|
||||||
onClick={() => setActiveTab(tab.id)}
|
}
|
||||||
variant="ghost"
|
|
||||||
className={cn('w-full justify-start', {
|
return (
|
||||||
'bg-secondary': activeTab === tab.id,
|
<Button
|
||||||
})}
|
key={tab.id}
|
||||||
>
|
onClick={() => setActiveTab(tab.id)}
|
||||||
<tab.icon className="mr-2 h-5 w-5" />
|
variant="ghost"
|
||||||
{t(tab.title)}
|
className={cn('w-full justify-start', {
|
||||||
</Button>
|
'bg-secondary': activeTab === tab.id,
|
||||||
))}
|
})}
|
||||||
|
>
|
||||||
|
<tab.icon className="mr-2 h-5 w-5" />
|
||||||
|
{t(tab.title)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -377,137 +393,151 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
disabled={form.formState.isSubmitting}
|
disabled={form.formState.isSubmitting}
|
||||||
key={activeTab}
|
key={activeTab}
|
||||||
>
|
>
|
||||||
{match(activeTab)
|
{match({ activeTab, settings })
|
||||||
.with('general', () => (
|
.with({ activeTab: 'general' }, () => (
|
||||||
<>
|
<>
|
||||||
<FormField
|
{settings.allowConfigureLanguage && (
|
||||||
control={form.control}
|
<FormField
|
||||||
name="meta.language"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="meta.language"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel className="inline-flex items-center">
|
<FormItem>
|
||||||
<Trans>Language</Trans>
|
<FormLabel className="inline-flex items-center">
|
||||||
<Tooltip>
|
<Trans>Language</Trans>
|
||||||
<TooltipTrigger>
|
<Tooltip>
|
||||||
<InfoIcon className="mx-2 h-4 w-4" />
|
<TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
|
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
|
||||||
<Trans>
|
<Trans>
|
||||||
Controls the language for the document, including the language
|
Controls the language for the document, including the language
|
||||||
to be used for email notifications, and the final certificate
|
to be used for email notifications, and the final certificate
|
||||||
that is generated and attached to the document.
|
that is generated and attached to the document.
|
||||||
</Trans>
|
</Trans>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Select
|
||||||
value={field.value}
|
value={field.value}
|
||||||
disabled={field.disabled}
|
disabled={field.disabled}
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-background">
|
<SelectTrigger className="bg-background">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
|
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
|
||||||
<SelectItem key={code} value={code}>
|
<SelectItem key={code} value={code}>
|
||||||
{language.full}
|
{language.full}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
)}
|
||||||
control={form.control}
|
|
||||||
name="meta.signatureTypes"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="flex flex-row items-center">
|
|
||||||
<Trans>Allowed Signature Types</Trans>
|
|
||||||
<DocumentSignatureSettingsTooltip />
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
{settings.allowConfigureSignatureTypes && (
|
||||||
<MultiSelectCombobox
|
<FormField
|
||||||
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
|
control={form.control}
|
||||||
label: t(option.label),
|
name="meta.signatureTypes"
|
||||||
value: option.value,
|
render={({ field }) => (
|
||||||
}))}
|
<FormItem>
|
||||||
selectedValues={field.value}
|
<FormLabel className="flex flex-row items-center">
|
||||||
onChange={field.onChange}
|
<Trans>Allowed Signature Types</Trans>
|
||||||
className="w-full bg-background"
|
<DocumentSignatureSettingsTooltip />
|
||||||
emptySelectionPlaceholder="Select signature types"
|
</FormLabel>
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
<FormControl>
|
||||||
</FormItem>
|
<MultiSelectCombobox
|
||||||
)}
|
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map(
|
||||||
/>
|
(option) => ({
|
||||||
<FormField
|
label: t(option.label),
|
||||||
control={form.control}
|
value: option.value,
|
||||||
name="meta.dateFormat"
|
}),
|
||||||
render={({ field }) => (
|
)}
|
||||||
<FormItem>
|
selectedValues={field.value}
|
||||||
<FormLabel>
|
onChange={field.onChange}
|
||||||
<Trans>Date Format</Trans>
|
className="w-full bg-background"
|
||||||
</FormLabel>
|
emptySelectionPlaceholder="Select signature types"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
<FormControl>
|
<FormMessage />
|
||||||
<Select
|
</FormItem>
|
||||||
value={field.value}
|
)}
|
||||||
onValueChange={field.onChange}
|
/>
|
||||||
disabled={envelopeHasBeenSent}
|
)}
|
||||||
>
|
|
||||||
<SelectTrigger className="bg-background">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent>
|
{settings.allowConfigureDateFormat && (
|
||||||
{DATE_FORMATS.map((format) => (
|
<FormField
|
||||||
<SelectItem key={format.key} value={format.value}>
|
control={form.control}
|
||||||
{format.label}
|
name="meta.dateFormat"
|
||||||
</SelectItem>
|
render={({ field }) => (
|
||||||
))}
|
<FormItem>
|
||||||
</SelectContent>
|
<FormLabel>
|
||||||
</Select>
|
<Trans>Date Format</Trans>
|
||||||
</FormControl>
|
</FormLabel>
|
||||||
|
|
||||||
<FormMessage />
|
<FormControl>
|
||||||
</FormItem>
|
<Select
|
||||||
)}
|
value={field.value}
|
||||||
/>
|
onValueChange={field.onChange}
|
||||||
<FormField
|
disabled={envelopeHasBeenSent}
|
||||||
control={form.control}
|
>
|
||||||
name="meta.timezone"
|
<SelectTrigger className="bg-background">
|
||||||
render={({ field }) => (
|
<SelectValue />
|
||||||
<FormItem>
|
</SelectTrigger>
|
||||||
<FormLabel>
|
|
||||||
<Trans>Time Zone</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
<SelectContent>
|
||||||
<Combobox
|
{DATE_FORMATS.map((format) => (
|
||||||
className="bg-background"
|
<SelectItem key={format.key} value={format.value}>
|
||||||
options={TIME_ZONES}
|
{format.label}
|
||||||
value={field.value}
|
</SelectItem>
|
||||||
onChange={(value) => value && field.onChange(value)}
|
))}
|
||||||
disabled={envelopeHasBeenSent}
|
</SelectContent>
|
||||||
/>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{settings.allowConfigureTimezone && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="meta.timezone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Time Zone</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Combobox
|
||||||
|
className="bg-background"
|
||||||
|
options={TIME_ZONES}
|
||||||
|
value={field.value}
|
||||||
|
onChange={(value) => value && field.onChange(value)}
|
||||||
|
disabled={envelopeHasBeenSent}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="externalId"
|
name="externalId"
|
||||||
@@ -538,143 +568,29 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
{settings.allowConfigureRedirectUrl && (
|
||||||
control={form.control}
|
|
||||||
name="meta.redirectUrl"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="flex flex-row items-center">
|
|
||||||
<Trans>Redirect URL</Trans>{' '}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<InfoIcon className="mx-2 h-4 w-4" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
|
|
||||||
<TooltipContent className="max-w-xs text-muted-foreground">
|
|
||||||
<Trans>
|
|
||||||
Add a URL to redirect the user to once the document is signed
|
|
||||||
</Trans>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="meta.distributionMethod"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="flex flex-row items-center">
|
|
||||||
<Trans>Document Distribution Method</Trans>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<InfoIcon className="mx-2 h-4 w-4" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
|
|
||||||
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
|
|
||||||
<h2>
|
|
||||||
<strong>
|
|
||||||
<Trans>Document Distribution Method</Trans>
|
|
||||||
</strong>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<Trans>
|
|
||||||
This is how the document will reach the recipients once the
|
|
||||||
document is ready for signing.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
|
|
||||||
<li>
|
|
||||||
<Trans>
|
|
||||||
<strong>Email</strong> - The recipient will be emailed the
|
|
||||||
document to sign, approve, etc.
|
|
||||||
</Trans>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Trans>
|
|
||||||
<strong>None</strong> - We will generate links which you can
|
|
||||||
send to the recipients manually.
|
|
||||||
</Trans>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<Trans>
|
|
||||||
<strong>Note</strong> - If you use Links in combination with
|
|
||||||
direct templates, you will need to manually send the links to
|
|
||||||
the remaining recipients.
|
|
||||||
</Trans>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<Select {...field} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger className="bg-background text-muted-foreground">
|
|
||||||
<SelectValue data-testid="documentDistributionMethodSelectValue" />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent position="popper">
|
|
||||||
{Object.values(DOCUMENT_DISTRIBUTION_METHODS).map(
|
|
||||||
({ value, description }) => (
|
|
||||||
<SelectItem key={value} value={value}>
|
|
||||||
{i18n._(description)}
|
|
||||||
</SelectItem>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
))
|
|
||||||
.with('email', () => (
|
|
||||||
<>
|
|
||||||
{organisation.organisationClaim.flags.emailDomains && (
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="meta.emailId"
|
name="meta.redirectUrl"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel className="flex flex-row items-center">
|
||||||
<Trans>Email Sender</Trans>
|
<Trans>Redirect URL</Trans>{' '}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
|
<TooltipContent className="max-w-xs text-muted-foreground">
|
||||||
|
<Trans>
|
||||||
|
Add a URL to redirect the user to once the document is signed
|
||||||
|
</Trans>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Input className="bg-background" {...field} />
|
||||||
{...field}
|
|
||||||
value={field.value === null ? '-1' : field.value}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
field.onChange(value === '-1' ? null : value)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
loading={isLoadingEmails}
|
|
||||||
className="bg-background"
|
|
||||||
>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent>
|
|
||||||
{emails.map((email) => (
|
|
||||||
<SelectItem key={email.id} value={email.id}>
|
|
||||||
{email.email}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<SelectItem value={'-1'}>Documenso</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -683,82 +599,204 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormField
|
{settings.allowConfigureDistribution && (
|
||||||
control={form.control}
|
<FormField
|
||||||
name="meta.emailReplyTo"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="meta.distributionMethod"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>
|
<FormItem>
|
||||||
<Trans>
|
<FormLabel className="flex flex-row items-center">
|
||||||
Reply To Email{' '}
|
<Trans>Document Distribution Method</Trans>
|
||||||
<span className="text-muted-foreground">(Optional)</span>
|
<Tooltip>
|
||||||
</Trans>
|
<TooltipTrigger>
|
||||||
</FormLabel>
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
<FormControl>
|
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
|
||||||
<Input {...field} />
|
<h2>
|
||||||
</FormControl>
|
<strong>
|
||||||
|
<Trans>Document Distribution Method</Trans>
|
||||||
|
</strong>
|
||||||
|
</h2>
|
||||||
|
|
||||||
<FormMessage />
|
<p>
|
||||||
</FormItem>
|
<Trans>
|
||||||
)}
|
This is how the document will reach the recipients once the
|
||||||
/>
|
document is ready for signing.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
<FormField
|
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
|
||||||
control={form.control}
|
<li>
|
||||||
name="meta.subject"
|
<Trans>
|
||||||
render={({ field }) => (
|
<strong>Email</strong> - The recipient will be emailed the
|
||||||
<FormItem>
|
document to sign, approve, etc.
|
||||||
<FormLabel>
|
</Trans>
|
||||||
<Trans>
|
</li>
|
||||||
Subject <span className="text-muted-foreground">(Optional)</span>
|
<li>
|
||||||
</Trans>
|
<Trans>
|
||||||
</FormLabel>
|
<strong>None</strong> - We will generate links which you
|
||||||
|
can send to the recipients manually.
|
||||||
|
</Trans>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<FormControl>
|
<Trans>
|
||||||
<Input {...field} />
|
<strong>Note</strong> - If you use Links in combination with
|
||||||
</FormControl>
|
direct templates, you will need to manually send the links to
|
||||||
|
the remaining recipients.
|
||||||
|
</Trans>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
<FormMessage />
|
<FormControl>
|
||||||
</FormItem>
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
)}
|
<SelectTrigger className="bg-background text-muted-foreground">
|
||||||
/>
|
<SelectValue data-testid="documentDistributionMethodSelectValue" />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
<FormField
|
<SelectContent position="popper">
|
||||||
control={form.control}
|
{Object.values(DOCUMENT_DISTRIBUTION_METHODS).map(
|
||||||
name="meta.message"
|
({ value, description }) => (
|
||||||
render={({ field }) => (
|
<SelectItem key={value} value={value}>
|
||||||
<FormItem>
|
{i18n._(description)}
|
||||||
<FormLabel className="flex flex-row items-center">
|
</SelectItem>
|
||||||
<Trans>
|
),
|
||||||
Message <span className="text-muted-foreground">(Optional)</span>
|
)}
|
||||||
</Trans>
|
</SelectContent>
|
||||||
<Tooltip>
|
</Select>
|
||||||
<TooltipTrigger>
|
</FormControl>
|
||||||
<InfoIcon className="mx-2 h-4 w-4" />
|
</FormItem>
|
||||||
</TooltipTrigger>
|
)}
|
||||||
<TooltipContent className="p-4 text-muted-foreground">
|
/>
|
||||||
<DocumentSendEmailMessageHelper />
|
)}
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<Textarea className="h-16 resize-none bg-background" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DocumentEmailCheckboxes
|
|
||||||
value={emailSettings}
|
|
||||||
onChange={(value) => form.setValue('meta.emailSettings', value)}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
))
|
))
|
||||||
.with('security', () => (
|
.with(
|
||||||
|
{ activeTab: 'email', settings: { allowConfigureDistribution: true } },
|
||||||
|
() => (
|
||||||
|
<>
|
||||||
|
{organisation.organisationClaim.flags.emailDomains && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="meta.emailId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Email Sender</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
value={field.value === null ? '-1' : field.value}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
field.onChange(value === '-1' ? null : value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
loading={isLoadingEmails}
|
||||||
|
className="bg-background"
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
{emails.map((email) => (
|
||||||
|
<SelectItem key={email.id} value={email.id}>
|
||||||
|
{email.email}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<SelectItem value={'-1'}>Documenso</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="meta.emailReplyTo"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>
|
||||||
|
Reply To Email{' '}
|
||||||
|
<span className="text-muted-foreground">(Optional)</span>
|
||||||
|
</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="meta.subject"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>
|
||||||
|
Subject <span className="text-muted-foreground">(Optional)</span>
|
||||||
|
</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="meta.message"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex flex-row items-center">
|
||||||
|
<Trans>
|
||||||
|
Message <span className="text-muted-foreground">(Optional)</span>
|
||||||
|
</Trans>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="p-4 text-muted-foreground">
|
||||||
|
<DocumentSendEmailMessageHelper />
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Textarea className="h-16 resize-none bg-background" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DocumentEmailCheckboxes
|
||||||
|
value={emailSettings}
|
||||||
|
onChange={(value) => form.setValue('meta.emailSettings', value)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with({ activeTab: 'security' }, () => (
|
||||||
<>
|
<>
|
||||||
{organisation.organisationClaim.flags.cfr21 && (
|
{organisation.organisationClaim.flags.cfr21 && (
|
||||||
<FormField
|
<FormField
|
||||||
@@ -827,7 +865,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
))
|
))
|
||||||
.exhaustive()}
|
.otherwise(() => null)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div className="flex flex-row justify-end gap-4 p-6">
|
<div className="flex flex-row justify-end gap-4 p-6">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type EnvelopeItemTitleInputProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
dataTestId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EnvelopeItemTitleInput = ({
|
export const EnvelopeItemTitleInput = ({
|
||||||
@@ -17,6 +18,7 @@ export const EnvelopeItemTitleInput = ({
|
|||||||
className,
|
className,
|
||||||
placeholder,
|
placeholder,
|
||||||
disabled,
|
disabled,
|
||||||
|
dataTestId,
|
||||||
}: EnvelopeItemTitleInputProps) => {
|
}: EnvelopeItemTitleInputProps) => {
|
||||||
const [envelopeItemTitle, setEnvelopeItemTitle] = useState(value);
|
const [envelopeItemTitle, setEnvelopeItemTitle] = useState(value);
|
||||||
const [isError, setIsError] = useState(false);
|
const [isError, setIsError] = useState(false);
|
||||||
@@ -63,6 +65,7 @@ export const EnvelopeItemTitleInput = ({
|
|||||||
{envelopeItemTitle || placeholder}
|
{envelopeItemTitle || placeholder}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
|
data-testid={dataTestId}
|
||||||
data-1p-ignore
|
data-1p-ignore
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -72,7 +75,7 @@ export const EnvelopeItemTitleInput = ({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
style={{ width: `${inputWidth}px` }}
|
style={{ width: `${inputWidth}px` }}
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-foreground hover:outline-muted-foreground focus:outline-muted-foreground rounded-sm border-0 bg-transparent p-1 text-sm font-medium outline-none hover:outline hover:outline-1 focus:outline focus:outline-1',
|
'rounded-sm border-0 bg-transparent p-1 text-sm font-medium text-foreground outline-none hover:outline hover:outline-1 hover:outline-muted-foreground focus:outline focus:outline-1 focus:outline-muted-foreground',
|
||||||
className,
|
className,
|
||||||
{
|
{
|
||||||
'outline-red-500': isError,
|
'outline-red-500': isError,
|
||||||
|
|||||||
+145
-51
@@ -8,7 +8,6 @@ import { DocumentStatus } from '@prisma/client';
|
|||||||
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
|
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
|
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
|
||||||
import { Link } from 'react-router';
|
|
||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +16,9 @@ import {
|
|||||||
} from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
} from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||||
|
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
|
import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
|
||||||
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
|
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
|
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
|
||||||
@@ -49,10 +50,14 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { envelope, setLocalEnvelope, relativePath, editorFields } = useCurrentEnvelopeEditor();
|
|
||||||
const { maximumEnvelopeItemCount, remaining } = useLimits();
|
const { maximumEnvelopeItemCount, remaining } = useLimits();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { envelope, setLocalEnvelope, editorFields, editorConfig, isEmbedded, navigateToStep } =
|
||||||
|
useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
|
const { envelopeItems: uploadConfig } = editorConfig;
|
||||||
|
|
||||||
const [localFiles, setLocalFiles] = useState<LocalFile[]>(
|
const [localFiles, setLocalFiles] = useState<LocalFile[]>(
|
||||||
envelope.envelopeItems
|
envelope.envelopeItems
|
||||||
.sort((a, b) => a.order - b.order)
|
.sort((a, b) => a.order - b.order)
|
||||||
@@ -103,17 +108,45 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onFileDrop = async (files: File[]) => {
|
const onFileDrop = async (files: File[]) => {
|
||||||
const newUploadingFiles: (LocalFile & { file: File })[] = files.map((file) => ({
|
const newUploadingFiles: (LocalFile & {
|
||||||
id: nanoid(),
|
file: File;
|
||||||
envelopeItemId: null,
|
data: TEditorEnvelope['envelopeItems'][number]['data'] | null;
|
||||||
title: file.name,
|
})[] = await Promise.all(
|
||||||
file,
|
files.map(async (file) => {
|
||||||
isUploading: true,
|
return {
|
||||||
isError: false,
|
id: nanoid(),
|
||||||
}));
|
envelopeItemId: isEmbedded ? `${PRESIGNED_ENVELOPE_ITEM_ID_PREFIX}${nanoid()}` : null,
|
||||||
|
title: file.name,
|
||||||
|
file,
|
||||||
|
isUploading: isEmbedded ? false : true,
|
||||||
|
// Clone the buffer so it can be read multiple times (File.arrayBuffer() consumes the stream once)
|
||||||
|
data: isEmbedded ? new Uint8Array((await file.arrayBuffer()).slice(0)) : null,
|
||||||
|
isError: false,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
setLocalFiles((prev) => [...prev, ...newUploadingFiles]);
|
setLocalFiles((prev) => [...prev, ...newUploadingFiles]);
|
||||||
|
|
||||||
|
// Directly commit the files for embedded documents since those are not uploaded
|
||||||
|
// until the end of the embedded flow.
|
||||||
|
if (isEmbedded) {
|
||||||
|
setLocalEnvelope({
|
||||||
|
envelopeItems: [
|
||||||
|
...envelope.envelopeItems,
|
||||||
|
...newUploadingFiles.map((file) => ({
|
||||||
|
id: file.envelopeItemId!,
|
||||||
|
title: file.title,
|
||||||
|
order: envelope.envelopeItems.length + 1,
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
data: file.data!,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
envelopeId: envelope.id,
|
envelopeId: envelope.id,
|
||||||
} satisfies TCreateEnvelopeItemsPayload;
|
} satisfies TCreateEnvelopeItemsPayload;
|
||||||
@@ -163,7 +196,9 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
* Hide the envelope item from the list on deletion.
|
* Hide the envelope item from the list on deletion.
|
||||||
*/
|
*/
|
||||||
const onFileDelete = (envelopeItemId: string) => {
|
const onFileDelete = (envelopeItemId: string) => {
|
||||||
setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.id !== envelopeItemId));
|
setLocalFiles((prev) =>
|
||||||
|
prev.filter((uploadingFile) => uploadingFile.envelopeItemId !== envelopeItemId),
|
||||||
|
);
|
||||||
|
|
||||||
const fieldsWithoutDeletedItem = envelope.fields.filter(
|
const fieldsWithoutDeletedItem = envelope.fields.filter(
|
||||||
(field) => field.envelopeItemId !== envelopeItemId,
|
(field) => field.envelopeItemId !== envelopeItemId,
|
||||||
@@ -195,6 +230,30 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => {
|
const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => {
|
||||||
|
if (isEmbedded) {
|
||||||
|
const nextEnvelopeItems = files
|
||||||
|
.filter((item) => item.envelopeItemId)
|
||||||
|
.map((item, index) => {
|
||||||
|
const originalEnvelopeItem = envelope.envelopeItems.find(
|
||||||
|
(envelopeItem) => envelopeItem.id === item.envelopeItemId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.envelopeItemId || '',
|
||||||
|
title: item.title,
|
||||||
|
order: index + 1,
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
data: originalEnvelopeItem?.data,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setLocalEnvelope({
|
||||||
|
envelopeItems: nextEnvelopeItems,
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void updateEnvelopeItems({
|
void updateEnvelopeItems({
|
||||||
envelopeId: envelope.id,
|
envelopeId: envelope.id,
|
||||||
data: files
|
data: files
|
||||||
@@ -277,32 +336,45 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<DocumentDropzone
|
{uploadConfig?.allowUpload && (
|
||||||
onDrop={onFileDrop}
|
<DocumentDropzone
|
||||||
allowMultiple
|
data-testid="envelope-item-dropzone"
|
||||||
className="pb-4 pt-6"
|
onDrop={onFileDrop}
|
||||||
disabled={dropzoneDisabledMessage !== null}
|
allowMultiple
|
||||||
disabledMessage={dropzoneDisabledMessage || undefined}
|
className="pb-4 pt-6"
|
||||||
disabledHeading={msg`Upload disabled`}
|
disabled={dropzoneDisabledMessage !== null}
|
||||||
maxFiles={maximumEnvelopeItemCount - localFiles.length}
|
disabledMessage={dropzoneDisabledMessage || undefined}
|
||||||
onDropRejected={onFileDropRejected}
|
disabledHeading={msg`Upload disabled`}
|
||||||
/>
|
maxFiles={maximumEnvelopeItemCount - localFiles.length}
|
||||||
|
onDropRejected={onFileDropRejected}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Uploaded Files List */}
|
{/* Uploaded Files List */}
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
<Droppable droppableId="files">
|
<Droppable droppableId="files">
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
<div {...provided.droppableProps} ref={provided.innerRef} className="space-y-2">
|
<div
|
||||||
|
data-testid="envelope-items-list"
|
||||||
|
{...provided.droppableProps}
|
||||||
|
ref={provided.innerRef}
|
||||||
|
className="space-y-2"
|
||||||
|
>
|
||||||
{localFiles.map((localFile, index) => (
|
{localFiles.map((localFile, index) => (
|
||||||
<Draggable
|
<Draggable
|
||||||
key={localFile.id}
|
key={localFile.id}
|
||||||
isDragDisabled={isCreatingEnvelopeItems || !canItemsBeModified}
|
isDragDisabled={
|
||||||
|
isCreatingEnvelopeItems ||
|
||||||
|
!canItemsBeModified ||
|
||||||
|
!uploadConfig?.allowConfigureOrder
|
||||||
|
}
|
||||||
draggableId={localFile.id}
|
draggableId={localFile.id}
|
||||||
index={index}
|
index={index}
|
||||||
>
|
>
|
||||||
{(provided, snapshot) => (
|
{(provided, snapshot) => (
|
||||||
<div
|
<div
|
||||||
|
data-testid={`envelope-item-row-${localFile.id}`}
|
||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
{...provided.draggableProps}
|
{...provided.draggableProps}
|
||||||
style={provided.draggableProps.style}
|
style={provided.draggableProps.style}
|
||||||
@@ -311,18 +383,25 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div
|
{uploadConfig?.allowConfigureOrder && (
|
||||||
{...provided.dragHandleProps}
|
<div
|
||||||
className="cursor-grab active:cursor-grabbing"
|
{...provided.dragHandleProps}
|
||||||
>
|
data-testid={`envelope-item-drag-handle-${localFile.id}`}
|
||||||
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
|
className="cursor-grab active:cursor-grabbing"
|
||||||
</div>
|
>
|
||||||
|
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{localFile.envelopeItemId !== null ? (
|
{localFile.envelopeItemId !== null ? (
|
||||||
<EnvelopeItemTitleInput
|
<EnvelopeItemTitleInput
|
||||||
disabled={envelope.status !== DocumentStatus.DRAFT}
|
disabled={
|
||||||
|
envelope.status !== DocumentStatus.DRAFT ||
|
||||||
|
!uploadConfig?.allowConfigureTitle
|
||||||
|
}
|
||||||
value={localFile.title}
|
value={localFile.title}
|
||||||
|
dataTestId={`envelope-item-title-input-${localFile.id}`}
|
||||||
placeholder={t`Document Title`}
|
placeholder={t`Document Title`}
|
||||||
onChange={(title) => {
|
onChange={(title) => {
|
||||||
onEnvelopeItemTitleChange(localFile.envelopeItemId!, title);
|
onEnvelopeItemTitleChange(localFile.envelopeItemId!, title);
|
||||||
@@ -355,20 +434,36 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!localFile.isUploading && localFile.envelopeItemId && (
|
{!localFile.isUploading &&
|
||||||
<EnvelopeItemDeleteDialog
|
localFile.envelopeItemId &&
|
||||||
canItemBeDeleted={canItemsBeModified}
|
uploadConfig?.allowDelete &&
|
||||||
envelopeId={envelope.id}
|
(isEmbedded ? (
|
||||||
envelopeItemId={localFile.envelopeItemId}
|
<Button
|
||||||
envelopeItemTitle={localFile.title}
|
variant="ghost"
|
||||||
onDelete={onFileDelete}
|
size="sm"
|
||||||
trigger={
|
data-testid={`envelope-item-remove-button-${localFile.id}`}
|
||||||
<Button variant="ghost" size="sm">
|
onClick={() => onFileDelete(localFile.envelopeItemId!)}
|
||||||
<X className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<X className="h-4 w-4" />
|
||||||
}
|
</Button>
|
||||||
/>
|
) : (
|
||||||
)}
|
<EnvelopeItemDeleteDialog
|
||||||
|
canItemBeDeleted={canItemsBeModified}
|
||||||
|
envelopeId={envelope.id}
|
||||||
|
envelopeItemId={localFile.envelopeItemId}
|
||||||
|
envelopeItemTitle={localFile.title}
|
||||||
|
onDelete={onFileDelete}
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
data-testid={`envelope-item-remove-button-${localFile.id}`}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -385,14 +480,13 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
|
|
||||||
{/* Recipients Section */}
|
{/* Recipients Section */}
|
||||||
<EnvelopeEditorRecipientForm />
|
<EnvelopeEditorRecipientForm />
|
||||||
|
{editorConfig.general.allowAddFieldsStep && (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button asChild>
|
<Button type="button" onClick={() => void navigateToStep('addFields')}>
|
||||||
<Link to={`${relativePath.editorPath}?step=addFields`}>
|
|
||||||
<Trans>Add Fields</Trans>
|
<Trans>Add Fields</Trans>
|
||||||
</Link>
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { EnvelopeType } from '@prisma/client';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
@@ -9,32 +11,31 @@ import {
|
|||||||
DownloadCloudIcon,
|
DownloadCloudIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
MousePointer,
|
type LucideIcon,
|
||||||
|
MousePointerIcon,
|
||||||
SendIcon,
|
SendIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
Upload,
|
UploadIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router';
|
import { useNavigate, useSearchParams } from 'react-router';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import type { EnvelopeEditorStep } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import {
|
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||||
mapSecondaryIdToDocumentId,
|
|
||||||
mapSecondaryIdToTemplateId,
|
|
||||||
} from '@documenso/lib/utils/envelope';
|
|
||||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Separator } from '@documenso/ui/primitives/separator';
|
import { Separator } from '@documenso/ui/primitives/separator';
|
||||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||||
|
|
||||||
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
|
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
|
||||||
import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribute-dialog';
|
import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribute-dialog';
|
||||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||||
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
|
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
|
||||||
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
|
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
|
||||||
import { TemplateDeleteDialog } from '~/components/dialogs/template-delete-dialog';
|
|
||||||
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
|
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
|
||||||
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
|
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
|
||||||
|
|
||||||
@@ -43,92 +44,108 @@ import EnvelopeEditorHeader from './envelope-editor-header';
|
|||||||
import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page';
|
import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page';
|
||||||
import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page';
|
import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page';
|
||||||
|
|
||||||
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
|
type EnvelopeEditorStepData = {
|
||||||
|
id: string;
|
||||||
|
title: MessageDescriptor;
|
||||||
|
icon: LucideIcon;
|
||||||
|
description: MessageDescriptor;
|
||||||
|
};
|
||||||
|
|
||||||
const envelopeEditorSteps = [
|
const UPLOAD_STEP = {
|
||||||
{
|
id: 'upload',
|
||||||
id: 'upload',
|
title: msg`Document & Recipients`,
|
||||||
order: 1,
|
icon: UploadIcon,
|
||||||
title: msg`Document & Recipients`,
|
description: msg`Upload documents and add recipients`,
|
||||||
icon: Upload,
|
};
|
||||||
description: msg`Upload documents and add recipients`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'addFields',
|
|
||||||
order: 2,
|
|
||||||
title: msg`Add Fields`,
|
|
||||||
icon: MousePointer,
|
|
||||||
description: msg`Place and configure form fields in the document`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'preview',
|
|
||||||
order: 3,
|
|
||||||
title: msg`Preview`,
|
|
||||||
icon: EyeIcon,
|
|
||||||
description: msg`Preview the document before sending`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function EnvelopeEditor() {
|
const ADD_FIELDS_STEP = {
|
||||||
|
id: 'addFields',
|
||||||
|
title: msg`Add Fields`,
|
||||||
|
icon: MousePointerIcon,
|
||||||
|
description: msg`Place and configure form fields in the document`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PREVIEW_STEP = {
|
||||||
|
id: 'preview',
|
||||||
|
title: msg`Preview`,
|
||||||
|
icon: EyeIcon,
|
||||||
|
description: msg`Preview the document before sending`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EnvelopeEditor = () => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
envelope,
|
envelope,
|
||||||
|
editorConfig,
|
||||||
isDocument,
|
isDocument,
|
||||||
isTemplate,
|
isTemplate,
|
||||||
isAutosaving,
|
|
||||||
flushAutosave,
|
|
||||||
relativePath,
|
relativePath,
|
||||||
syncEnvelope,
|
syncEnvelope,
|
||||||
|
navigateToStep,
|
||||||
} = useCurrentEnvelopeEditor();
|
} = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
||||||
const [isStepLoading, setIsStepLoading] = useState(false);
|
|
||||||
|
|
||||||
const [currentStep, setCurrentStep] = useState<EnvelopeEditorStep>(() => {
|
const {
|
||||||
const searchParamStep = searchParams.get('step') as EnvelopeEditorStep | undefined;
|
general: {
|
||||||
|
minimizeLeftSidebar,
|
||||||
|
allowUploadAndRecipientStep,
|
||||||
|
allowAddFieldsStep,
|
||||||
|
allowPreviewStep,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
allowDistributing,
|
||||||
|
allowDirectLink,
|
||||||
|
allowDuplication,
|
||||||
|
allowDownloadPDF,
|
||||||
|
allowDeletion,
|
||||||
|
allowReturnToPreviousPage,
|
||||||
|
},
|
||||||
|
} = editorConfig;
|
||||||
|
|
||||||
// Empty URL param equals upload, otherwise use the step URL param
|
const envelopeEditorSteps = useMemo(() => {
|
||||||
if (!searchParamStep) {
|
const steps: EnvelopeEditorStepData[] = [];
|
||||||
return 'upload';
|
|
||||||
|
if (allowUploadAndRecipientStep) {
|
||||||
|
steps.push(UPLOAD_STEP);
|
||||||
}
|
}
|
||||||
|
|
||||||
const validSteps: EnvelopeEditorStep[] = ['upload', 'addFields', 'preview'];
|
if (allowAddFieldsStep) {
|
||||||
|
steps.push(ADD_FIELDS_STEP);
|
||||||
if (validSteps.includes(searchParamStep)) {
|
|
||||||
return searchParamStep;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'upload';
|
if (allowPreviewStep) {
|
||||||
});
|
steps.push(PREVIEW_STEP);
|
||||||
|
|
||||||
const navigateToStep = (step: EnvelopeEditorStep) => {
|
|
||||||
setCurrentStep(step);
|
|
||||||
|
|
||||||
void flushAutosave();
|
|
||||||
|
|
||||||
if (!isStepLoading && isAutosaving) {
|
|
||||||
setIsStepLoading(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update URL params: empty for upload, otherwise set the step
|
return steps.map((step, index) => ({
|
||||||
if (step === 'upload') {
|
...step,
|
||||||
setSearchParams((prev) => {
|
order: index + 1,
|
||||||
const newParams = new URLSearchParams(prev);
|
}));
|
||||||
newParams.delete('step');
|
}, [editorConfig]);
|
||||||
return newParams;
|
|
||||||
});
|
const [currentStep, setCurrentStep] = useState<{ step: EnvelopeEditorStep; isLoading: boolean }>(
|
||||||
} else {
|
() => {
|
||||||
setSearchParams((prev) => {
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
const newParams = new URLSearchParams(prev);
|
const searchParamStep = searchParams.get('step') as EnvelopeEditorStep | undefined;
|
||||||
newParams.set('step', step);
|
|
||||||
return newParams;
|
// Empty URL param equals upload, otherwise use the step URL param
|
||||||
});
|
if (!searchParamStep) {
|
||||||
}
|
return { step: 'upload', isLoading: false };
|
||||||
};
|
}
|
||||||
|
|
||||||
|
const validSteps: EnvelopeEditorStep[] = ['upload', 'addFields', 'preview'];
|
||||||
|
|
||||||
|
if (validSteps.includes(searchParamStep)) {
|
||||||
|
return { step: searchParamStep, isLoading: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { step: 'upload', isLoading: false };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Watch the URL params and setStep if the step changes.
|
// Watch the URL params and setStep if the step changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -136,20 +153,19 @@ export default function EnvelopeEditor() {
|
|||||||
|
|
||||||
const foundStep = envelopeEditorSteps.find((step) => step.id === stepParam);
|
const foundStep = envelopeEditorSteps.find((step) => step.id === stepParam);
|
||||||
|
|
||||||
if (foundStep && foundStep.id !== currentStep) {
|
if (foundStep && foundStep.id !== currentStep.step) {
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
navigateToStep(foundStep.id as EnvelopeEditorStep);
|
void navigateToStep(foundStep.id as EnvelopeEditorStep).then(() => {
|
||||||
|
setCurrentStep({
|
||||||
|
step: foundStep.id as EnvelopeEditorStep,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isAutosaving) {
|
|
||||||
setIsStepLoading(false);
|
|
||||||
}
|
|
||||||
}, [isAutosaving]);
|
|
||||||
|
|
||||||
const currentStepData =
|
const currentStepData =
|
||||||
envelopeEditorSteps.find((step) => step.id === currentStep) || envelopeEditorSteps[0];
|
envelopeEditorSteps.find((step) => step.id === currentStep.step) || envelopeEditorSteps[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen bg-gray-50 dark:bg-background">
|
<div className="h-screen w-screen bg-gray-50 dark:bg-background">
|
||||||
@@ -158,57 +174,124 @@ export default function EnvelopeEditor() {
|
|||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex h-[calc(100vh-4rem)] w-screen">
|
<div className="flex h-[calc(100vh-4rem)] w-screen">
|
||||||
{/* Left Section - Step Navigation */}
|
{/* Left Section - Step Navigation */}
|
||||||
<div className="flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4',
|
||||||
|
{
|
||||||
|
'w-14': minimizeLeftSidebar,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
{/* Left section step selector. */}
|
{/* Left section step selector. */}
|
||||||
<div className="px-4">
|
{minimizeLeftSidebar ? (
|
||||||
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
|
<div className="flex justify-center px-4">
|
||||||
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
|
<div className="relative flex h-10 w-10 items-center justify-center">
|
||||||
|
<svg className="size-10 -rotate-90" viewBox="0 0 40 40" aria-hidden>
|
||||||
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
|
{/* Track circle */}
|
||||||
<Trans context="The step counter">
|
<circle
|
||||||
Step {currentStepData.order}/{envelopeEditorSteps.length}
|
cx="20"
|
||||||
</Trans>
|
cy="20"
|
||||||
</span>
|
r="16"
|
||||||
</h3>
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
<div className="relative my-4 h-[4px] rounded-md bg-muted">
|
strokeWidth="3"
|
||||||
<motion.div
|
className="text-muted"
|
||||||
layout="size"
|
/>
|
||||||
layoutId="document-flow-container-step"
|
{/* Progress arc */}
|
||||||
className="absolute inset-y-0 left-0 bg-primary"
|
<motion.circle
|
||||||
style={{
|
cx="20"
|
||||||
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
|
cy="20"
|
||||||
}}
|
r="16"
|
||||||
/>
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="text-primary"
|
||||||
|
strokeDasharray={2 * Math.PI * 16}
|
||||||
|
initial={false}
|
||||||
|
animate={{
|
||||||
|
strokeDashoffset:
|
||||||
|
2 *
|
||||||
|
Math.PI *
|
||||||
|
16 *
|
||||||
|
(1 - (currentStepData.order ?? 0) / envelopeEditorSteps.length),
|
||||||
|
}}
|
||||||
|
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-foreground">
|
||||||
|
<Trans context="The step counter">
|
||||||
|
{currentStepData.order}/{envelopeEditorSteps.length}
|
||||||
|
</Trans>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-4">
|
||||||
|
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
|
||||||
|
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
|
||||||
{envelopeEditorSteps.map((step) => {
|
<Trans context="The step counter">
|
||||||
const Icon = step.icon;
|
Step {currentStepData.order}/{envelopeEditorSteps.length}
|
||||||
const isActive = currentStep === step.id;
|
</Trans>
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
return (
|
<div className="relative my-4 h-[4px] rounded-md bg-muted">
|
||||||
<div
|
<motion.div
|
||||||
key={step.id}
|
layout="size"
|
||||||
className={`cursor-pointer rounded-lg p-3 transition-colors ${
|
layoutId="document-flow-container-step"
|
||||||
|
className="absolute inset-y-0 left-0 bg-primary"
|
||||||
|
style={{
|
||||||
|
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn('space-y-3', {
|
||||||
|
'px-4': !minimizeLeftSidebar,
|
||||||
|
'mt-4 flex flex-col items-center': minimizeLeftSidebar,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{envelopeEditorSteps.map((step) => {
|
||||||
|
const Icon = step.icon;
|
||||||
|
const isActive = currentStep.step === step.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={step.id}
|
||||||
|
data-testid={`envelope-editor-step-${step.id}`}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
`cursor-pointer rounded-lg text-left transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'border border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
|
? 'border border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
|
||||||
: 'border border-gray-200 hover:bg-gray-50 dark:border-gray-400/20 dark:hover:bg-gray-400/10'
|
: 'border border-gray-200 hover:bg-gray-50 dark:border-gray-400/20 dark:hover:bg-gray-400/10'
|
||||||
}`}
|
}`,
|
||||||
onClick={() => navigateToStep(step.id as EnvelopeEditorStep)}
|
{
|
||||||
>
|
'p-3': !minimizeLeftSidebar,
|
||||||
<div className="flex items-center space-x-3">
|
},
|
||||||
<div
|
)}
|
||||||
className={`rounded border p-2 ${
|
onClick={() => void navigateToStep(step.id as EnvelopeEditorStep)}
|
||||||
isActive
|
>
|
||||||
? 'border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
|
<div className="flex items-center space-x-3">
|
||||||
: 'border-gray-100 bg-gray-100 dark:border-gray-400/20 dark:bg-gray-400/10'
|
<div
|
||||||
}`}
|
className={`rounded border p-2 ${
|
||||||
>
|
isActive
|
||||||
<Icon
|
? 'border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
|
||||||
className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-gray-600'}`}
|
: 'border-gray-100 bg-gray-100 dark:border-gray-400/20 dark:bg-gray-400/10'
|
||||||
/>
|
}`}
|
||||||
</div>
|
>
|
||||||
|
<Icon
|
||||||
|
className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-gray-600'}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!minimizeLeftSidebar && (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
className={`text-sm font-medium ${
|
className={`text-sm font-medium ${
|
||||||
@@ -221,59 +304,101 @@ export default function EnvelopeEditor() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">{t(step.description)}</div>
|
<div className="text-xs text-muted-foreground">{t(step.description)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
</button>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className="my-6" />
|
<Separator
|
||||||
|
className={cn('my-6', {
|
||||||
|
'mx-auto mb-4 w-4/5': minimizeLeftSidebar,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Quick Actions. */}
|
{/* Quick Actions. */}
|
||||||
<div className="space-y-3 px-4">
|
<div
|
||||||
<h4 className="text-sm font-semibold text-foreground">
|
className={cn('space-y-3 px-4 [&_.lucide]:text-muted-foreground', {
|
||||||
<Trans>Quick Actions</Trans>
|
'px-2': minimizeLeftSidebar,
|
||||||
</h4>
|
})}
|
||||||
<EnvelopeEditorSettingsDialog
|
>
|
||||||
trigger={
|
{!minimizeLeftSidebar && (
|
||||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
<h4 className="text-sm font-semibold text-foreground">
|
||||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
<Trans>Quick Actions</Trans>
|
||||||
{isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>}
|
</h4>
|
||||||
</Button>
|
)}
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isDocument && (
|
{editorConfig.settings && (
|
||||||
<EnvelopeDistributeDialog
|
<EnvelopeEditorSettingsDialog
|
||||||
documentRootPath={relativePath.documentRootPath}
|
|
||||||
trigger={
|
trigger={
|
||||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
<Button
|
||||||
<SendIcon className="mr-2 h-4 w-4" />
|
variant="ghost"
|
||||||
<Trans>Send Document</Trans>
|
size="sm"
|
||||||
|
className="w-full justify-start"
|
||||||
|
title={t(msg`Settings`)}
|
||||||
|
>
|
||||||
|
<SettingsIcon className="h-4 w-4" />
|
||||||
|
|
||||||
|
{!minimizeLeftSidebar && (
|
||||||
|
<span className="ml-2">
|
||||||
|
{isDocument ? (
|
||||||
|
<Trans>Document Settings</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Template Settings</Trans>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isDocument && (
|
{isDocument && allowDistributing && (
|
||||||
<EnvelopeRedistributeDialog
|
<>
|
||||||
envelope={envelope}
|
<EnvelopeDistributeDialog
|
||||||
trigger={
|
documentRootPath={relativePath.documentRootPath}
|
||||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
trigger={
|
||||||
<SendIcon className="mr-2 h-4 w-4" />
|
<Button
|
||||||
<Trans>Resend Document</Trans>
|
variant="ghost"
|
||||||
</Button>
|
size="sm"
|
||||||
}
|
className="w-full justify-start"
|
||||||
/>
|
title={t(msg`Send Envelope`)}
|
||||||
|
>
|
||||||
|
<SendIcon className="h-4 w-4" />
|
||||||
|
|
||||||
|
{!minimizeLeftSidebar && (
|
||||||
|
<span className="ml-2">
|
||||||
|
<Trans>Send Document</Trans>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EnvelopeRedistributeDialog
|
||||||
|
envelope={envelope}
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start"
|
||||||
|
title={t(msg`Resend Envelope`)}
|
||||||
|
>
|
||||||
|
<SendIcon className="h-4 w-4" />
|
||||||
|
|
||||||
|
{!minimizeLeftSidebar && (
|
||||||
|
<span className="ml-2">
|
||||||
|
<Trans>Resend Document</Trans>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* <Button variant="ghost" size="sm" className="w-full justify-start">
|
{isTemplate && allowDirectLink && (
|
||||||
<FileText className="mr-2 h-4 w-4" />
|
|
||||||
Save as Template
|
|
||||||
</Button> */}
|
|
||||||
|
|
||||||
{isTemplate && (
|
|
||||||
<TemplateDirectLinkDialog
|
<TemplateDirectLinkDialog
|
||||||
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
|
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
|
||||||
directLink={envelope.directLink}
|
directLink={envelope.directLink}
|
||||||
@@ -281,100 +406,173 @@ export default function EnvelopeEditor() {
|
|||||||
onCreateSuccess={async () => await syncEnvelope()}
|
onCreateSuccess={async () => await syncEnvelope()}
|
||||||
onDeleteSuccess={async () => await syncEnvelope()}
|
onDeleteSuccess={async () => await syncEnvelope()}
|
||||||
trigger={
|
trigger={
|
||||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
<Button
|
||||||
<LinkIcon className="mr-2 h-4 w-4" />
|
variant="ghost"
|
||||||
<Trans>Direct Link</Trans>
|
size="sm"
|
||||||
|
className="w-full justify-start"
|
||||||
|
title={t(msg`Direct Link`)}
|
||||||
|
>
|
||||||
|
<LinkIcon className="h-4 w-4" />
|
||||||
|
|
||||||
|
{!minimizeLeftSidebar && (
|
||||||
|
<span className="ml-2">
|
||||||
|
<Trans>Direct Link</Trans>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<EnvelopeDuplicateDialog
|
{allowDuplication && (
|
||||||
envelopeId={envelope.id}
|
<EnvelopeDuplicateDialog
|
||||||
envelopeType={envelope.type}
|
envelopeId={envelope.id}
|
||||||
trigger={
|
envelopeType={envelope.type}
|
||||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
trigger={
|
||||||
<CopyPlusIcon className="mr-2 h-4 w-4" />
|
<Button
|
||||||
{isDocument ? (
|
variant="ghost"
|
||||||
<Trans>Duplicate Document</Trans>
|
size="sm"
|
||||||
) : (
|
className="w-full justify-start"
|
||||||
<Trans>Duplicate Template</Trans>
|
title={t(msg`Duplicate Envelope`)}
|
||||||
)}
|
>
|
||||||
</Button>
|
<CopyPlusIcon className="h-4 w-4" />
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EnvelopeDownloadDialog
|
{!minimizeLeftSidebar && (
|
||||||
envelopeId={envelope.id}
|
<span className="ml-2">
|
||||||
envelopeStatus={envelope.status}
|
{isDocument ? (
|
||||||
envelopeItems={envelope.envelopeItems}
|
<Trans>Duplicate Document</Trans>
|
||||||
trigger={
|
) : (
|
||||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
<Trans>Duplicate Template</Trans>
|
||||||
<DownloadCloudIcon className="mr-2 h-4 w-4" />
|
)}
|
||||||
<Trans>Download PDF</Trans>
|
</span>
|
||||||
</Button>
|
)}
|
||||||
}
|
</Button>
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
{allowDownloadPDF && (
|
||||||
variant="ghost"
|
<EnvelopeDownloadDialog
|
||||||
size="sm"
|
envelopeId={envelope.id}
|
||||||
className="w-full justify-start"
|
envelopeStatus={envelope.status}
|
||||||
onClick={() => setDeleteDialogOpen(true)}
|
envelopeItems={envelope.envelopeItems}
|
||||||
>
|
trigger={
|
||||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
<Button
|
||||||
{isDocument ? <Trans>Delete Document</Trans> : <Trans>Delete Template</Trans>}
|
variant="ghost"
|
||||||
</Button>
|
size="sm"
|
||||||
|
className="w-full justify-start"
|
||||||
|
title={t(msg`Download PDF`)}
|
||||||
|
>
|
||||||
|
<DownloadCloudIcon className="h-4 w-4" />
|
||||||
|
|
||||||
|
{!minimizeLeftSidebar && (
|
||||||
|
<span className="ml-2">
|
||||||
|
<Trans>Download PDF</Trans>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Check envelope ID since it can be in embedded create mode. */}
|
||||||
|
{allowDeletion && envelope.id && (
|
||||||
|
<EnvelopeDeleteDialog
|
||||||
|
id={envelope.id}
|
||||||
|
type={envelope.type}
|
||||||
|
status={envelope.status}
|
||||||
|
title={envelope.title}
|
||||||
|
canManageDocument={true}
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start"
|
||||||
|
title={t(msg`Delete Envelope`)}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="h-4 w-4" />
|
||||||
|
|
||||||
|
{!minimizeLeftSidebar && (
|
||||||
|
<span className="ml-2">
|
||||||
|
{isDocument ? (
|
||||||
|
<Trans>Delete Document</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Delete Template</Trans>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
onDelete={async () => {
|
||||||
|
// Todo: Embed - Where to navigate?
|
||||||
|
await navigate(
|
||||||
|
envelope.type === EnvelopeType.DOCUMENT
|
||||||
|
? relativePath.documentRootPath
|
||||||
|
: relativePath.templateRootPath,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isDocument ? (
|
|
||||||
<DocumentDeleteDialog
|
|
||||||
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
|
||||||
status={envelope.status}
|
|
||||||
documentTitle={envelope.title}
|
|
||||||
canManageDocument={true}
|
|
||||||
open={isDeleteDialogOpen}
|
|
||||||
onOpenChange={setDeleteDialogOpen}
|
|
||||||
onDelete={async () => {
|
|
||||||
await navigate(relativePath.documentRootPath);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<TemplateDeleteDialog
|
|
||||||
id={mapSecondaryIdToTemplateId(envelope.secondaryId)}
|
|
||||||
open={isDeleteDialogOpen}
|
|
||||||
onOpenChange={setDeleteDialogOpen}
|
|
||||||
onDelete={async () => {
|
|
||||||
await navigate(relativePath.templateRootPath);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer of left sidebar. */}
|
{/* Footer of left sidebar. */}
|
||||||
<div className="mt-auto px-4">
|
{allowReturnToPreviousPage && (
|
||||||
<Button variant="ghost" className="w-full justify-start" asChild>
|
<div
|
||||||
<Link to={relativePath.basePath}>
|
className={cn('mt-auto px-4', {
|
||||||
<ArrowLeftIcon className="mr-2 h-4 w-4" />
|
'px-2': minimizeLeftSidebar,
|
||||||
{isDocument ? (
|
})}
|
||||||
<Trans>Return to documents</Trans>
|
>
|
||||||
) : (
|
<Button
|
||||||
<Trans>Return to templates</Trans>
|
variant="ghost"
|
||||||
)}
|
className={cn('w-full justify-start', {
|
||||||
</Link>
|
'flex items-center justify-center': minimizeLeftSidebar,
|
||||||
</Button>
|
})}
|
||||||
</div>
|
asChild
|
||||||
|
>
|
||||||
|
<Link to={relativePath.basePath}>
|
||||||
|
<ArrowLeftIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
|
|
||||||
|
{!minimizeLeftSidebar && (
|
||||||
|
<span className="ml-2">
|
||||||
|
{isDocument ? (
|
||||||
|
<Trans>Return to documents</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Return to templates</Trans>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content - Changes based on current step */}
|
{/* Main Content - Changes based on current step */}
|
||||||
<AnimateGenericFadeInOut className="flex-1 overflow-y-auto" key={currentStep}>
|
<AnimateGenericFadeInOut
|
||||||
{match({ currentStep, isStepLoading })
|
className="flex-1 overflow-y-auto"
|
||||||
|
key={currentStep.isLoading ? `loading-${currentStep.step}` : currentStep.step}
|
||||||
|
>
|
||||||
|
{match({
|
||||||
|
isStepLoading: currentStep.isLoading,
|
||||||
|
currentStep: currentStep.step,
|
||||||
|
allowUploadAndRecipientStep,
|
||||||
|
allowAddFieldsStep,
|
||||||
|
allowPreviewStep,
|
||||||
|
})
|
||||||
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
|
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
|
||||||
.with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />)
|
.with({ currentStep: 'upload', allowUploadAndRecipientStep: true }, () => (
|
||||||
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />)
|
<EnvelopeEditorUploadPage />
|
||||||
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />)
|
))
|
||||||
.exhaustive()}
|
.with({ currentStep: 'addFields', allowAddFieldsStep: true }, () => (
|
||||||
|
<EnvelopeEditorFieldsPage />
|
||||||
|
))
|
||||||
|
.with({ currentStep: 'preview', allowPreviewStep: true }, () => (
|
||||||
|
<EnvelopeEditorPreviewPage />
|
||||||
|
))
|
||||||
|
.otherwise(() => null)}
|
||||||
</AnimateGenericFadeInOut>
|
</AnimateGenericFadeInOut>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
+16
-28
@@ -5,17 +5,22 @@ import { DocumentStatus, type Recipient, SigningStatus } from '@prisma/client';
|
|||||||
import type Konva from 'konva';
|
import type Konva from 'konva';
|
||||||
|
|
||||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import {
|
||||||
|
type PageRenderData,
|
||||||
|
useCurrentEnvelopeRender,
|
||||||
|
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||||
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
|
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
|
||||||
import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
|
import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
|
||||||
|
|
||||||
|
import { EnvelopePageImage } from '../envelope/envelope-page-image';
|
||||||
|
|
||||||
type GenericLocalField = TEnvelope['fields'][number] & {
|
type GenericLocalField = TEnvelope['fields'][number] & {
|
||||||
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
|
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function EnvelopeGenericPageRenderer() {
|
export default function EnvelopeGenericPageRenderer({ pageData }: { pageData: PageRenderData }) {
|
||||||
const { i18n } = useLingui();
|
const { i18n } = useLingui();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -28,19 +33,12 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
overrideSettings,
|
overrideSettings,
|
||||||
} = useCurrentEnvelopeRender();
|
} = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
const {
|
const { stage, pageLayer, imageProps, konvaContainer, unscaledViewport, renderStatus } =
|
||||||
stage,
|
usePageRenderer(({ stage, pageLayer }) => {
|
||||||
pageLayer,
|
createPageCanvas(stage, pageLayer);
|
||||||
canvasElement,
|
}, pageData);
|
||||||
konvaContainer,
|
|
||||||
pageContext,
|
|
||||||
scaledViewport,
|
|
||||||
unscaledViewport,
|
|
||||||
} = usePageRenderer(({ stage, pageLayer }) => {
|
|
||||||
createPageCanvas(stage, pageLayer);
|
|
||||||
});
|
|
||||||
|
|
||||||
const { _className, scale } = pageContext;
|
const { scale, pageNumber } = pageData;
|
||||||
|
|
||||||
const localPageFields = useMemo((): GenericLocalField[] => {
|
const localPageFields = useMemo((): GenericLocalField[] => {
|
||||||
if (envelopeStatus === DocumentStatus.COMPLETED) {
|
if (envelopeStatus === DocumentStatus.COMPLETED) {
|
||||||
@@ -49,8 +47,7 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
|
|
||||||
return fields
|
return fields
|
||||||
.filter(
|
.filter(
|
||||||
(field) =>
|
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
|
||||||
)
|
)
|
||||||
.map((field) => {
|
.map((field) => {
|
||||||
const recipient = recipients.find((recipient) => recipient.id === field.recipientId);
|
const recipient = recipients.find((recipient) => recipient.id === field.recipientId);
|
||||||
@@ -73,7 +70,7 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
(recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) ||
|
(recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) ||
|
||||||
fieldMeta?.readOnly,
|
fieldMeta?.readOnly,
|
||||||
);
|
);
|
||||||
}, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]);
|
}, [fields, pageNumber, currentEnvelopeItem?.id, recipients]);
|
||||||
|
|
||||||
const unsafeRenderFieldOnLayer = (field: GenericLocalField) => {
|
const unsafeRenderFieldOnLayer = (field: GenericLocalField) => {
|
||||||
if (!pageLayer.current) {
|
if (!pageLayer.current) {
|
||||||
@@ -160,10 +157,7 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="relative w-full" key={`${currentEnvelopeItem.id}-renderer-${pageNumber}`}>
|
||||||
className="relative w-full"
|
|
||||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
|
||||||
>
|
|
||||||
{overrideSettings?.showRecipientTooltip &&
|
{overrideSettings?.showRecipientTooltip &&
|
||||||
localPageFields.map((field) => (
|
localPageFields.map((field) => (
|
||||||
<EnvelopeRecipientFieldTooltip
|
<EnvelopeRecipientFieldTooltip
|
||||||
@@ -177,13 +171,7 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
{/* The element Konva will inject it's canvas into. */}
|
{/* The element Konva will inject it's canvas into. */}
|
||||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
||||||
|
|
||||||
{/* Canvas the PDF will be rendered on. */}
|
<EnvelopePageImage renderStatus={renderStatus} imageProps={imageProps} />
|
||||||
<canvas
|
|
||||||
className={`${_className}__canvas z-0`}
|
|
||||||
ref={canvasElement}
|
|
||||||
height={scaledViewport.height}
|
|
||||||
width={scaledViewport.width}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-29
@@ -14,7 +14,10 @@ import type { KonvaEventObject } from 'konva/lib/Node';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import {
|
||||||
|
type PageRenderData,
|
||||||
|
useCurrentEnvelopeRender,
|
||||||
|
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
|
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
|
||||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||||
@@ -44,12 +47,13 @@ import { handleTextFieldClick } from '~/utils/field-signing/text-field';
|
|||||||
|
|
||||||
import { useRequiredDocumentSigningAuthContext } from '../document-signing/document-signing-auth-provider';
|
import { useRequiredDocumentSigningAuthContext } from '../document-signing/document-signing-auth-provider';
|
||||||
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
||||||
|
import { EnvelopePageImage } from '../envelope/envelope-page-image';
|
||||||
|
|
||||||
type GenericLocalField = TEnvelope['fields'][number] & {
|
type GenericLocalField = TEnvelope['fields'][number] & {
|
||||||
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
|
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function EnvelopeSignerPageRenderer() {
|
export default function EnvelopeSignerPageRenderer({ pageData }: { pageData: PageRenderData }) {
|
||||||
const { t, i18n } = useLingui();
|
const { t, i18n } = useLingui();
|
||||||
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
||||||
const { sessionData } = useOptionalSession();
|
const { sessionData } = useOptionalSession();
|
||||||
@@ -77,17 +81,10 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
|
|
||||||
const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {};
|
const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {};
|
||||||
|
|
||||||
const {
|
const { stage, pageLayer, imageProps, konvaContainer, unscaledViewport, renderStatus } =
|
||||||
stage,
|
usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer), pageData);
|
||||||
pageLayer,
|
|
||||||
canvasElement,
|
|
||||||
konvaContainer,
|
|
||||||
pageContext,
|
|
||||||
scaledViewport,
|
|
||||||
unscaledViewport,
|
|
||||||
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
|
|
||||||
|
|
||||||
const { _className, scale } = pageContext;
|
const { scale, pageNumber } = pageData;
|
||||||
|
|
||||||
const { envelope } = envelopeData;
|
const { envelope } = envelopeData;
|
||||||
|
|
||||||
@@ -99,10 +96,9 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return fieldsToRender.filter(
|
return fieldsToRender.filter(
|
||||||
(field) =>
|
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
|
||||||
);
|
);
|
||||||
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
|
}, [recipientFields, selectedAssistantRecipientFields, pageNumber]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns fields that have been fully signed by other recipients for this specific
|
* Returns fields that have been fully signed by other recipients for this specific
|
||||||
@@ -117,7 +113,7 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
return recipient.fields
|
return recipient.fields
|
||||||
.filter(
|
.filter(
|
||||||
(field) =>
|
(field) =>
|
||||||
field.page === pageContext.pageNumber &&
|
field.page === pageNumber &&
|
||||||
field.envelopeItemId === currentEnvelopeItem?.id &&
|
field.envelopeItemId === currentEnvelopeItem?.id &&
|
||||||
(field.inserted || field.fieldMeta?.readOnly),
|
(field.inserted || field.fieldMeta?.readOnly),
|
||||||
)
|
)
|
||||||
@@ -132,7 +128,7 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}, [envelope.recipients, pageContext.pageNumber]);
|
}, [envelope.recipients, pageNumber]);
|
||||||
|
|
||||||
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
|
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
|
||||||
if (!pageLayer.current) {
|
if (!pageLayer.current) {
|
||||||
@@ -534,14 +530,11 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="relative w-full" key={`${currentEnvelopeItem.id}-renderer-${pageNumber}`}>
|
||||||
className="relative w-full"
|
|
||||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
|
||||||
>
|
|
||||||
{showPendingFieldTooltip &&
|
{showPendingFieldTooltip &&
|
||||||
recipientFieldsRemaining.length > 0 &&
|
recipientFieldsRemaining.length > 0 &&
|
||||||
recipientFieldsRemaining[0]?.envelopeItemId === currentEnvelopeItem?.id &&
|
recipientFieldsRemaining[0]?.envelopeItemId === currentEnvelopeItem?.id &&
|
||||||
recipientFieldsRemaining[0]?.page === pageContext.pageNumber && (
|
recipientFieldsRemaining[0]?.page === pageNumber && (
|
||||||
<EnvelopeFieldToolTip
|
<EnvelopeFieldToolTip
|
||||||
key={recipientFieldsRemaining[0].id}
|
key={recipientFieldsRemaining[0].id}
|
||||||
field={recipientFieldsRemaining[0]}
|
field={recipientFieldsRemaining[0]}
|
||||||
@@ -563,13 +556,7 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
{/* The element Konva will inject it's canvas into. */}
|
{/* The element Konva will inject it's canvas into. */}
|
||||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
||||||
|
|
||||||
{/* Canvas the PDF will be rendered on. */}
|
<EnvelopePageImage renderStatus={renderStatus} imageProps={imageProps} />
|
||||||
<canvas
|
|
||||||
className={`${_className}__canvas z-0`}
|
|
||||||
ref={canvasElement}
|
|
||||||
height={scaledViewport.height}
|
|
||||||
width={scaledViewport.width}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
|
import { Spinner } from '@documenso/ui/primitives/spinner';
|
||||||
|
|
||||||
|
type EnvelopePageImageProps = {
|
||||||
|
renderStatus: 'loading' | 'loaded' | 'error';
|
||||||
|
imageProps: React.ImgHTMLAttributes<HTMLImageElement> & Record<string, unknown> & { alt: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EnvelopePageImage = ({ renderStatus, imageProps }: EnvelopePageImageProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Loading State */}
|
||||||
|
{renderStatus === 'loading' && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderStatus === 'error' && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center">
|
||||||
|
<p>
|
||||||
|
<Trans>Error loading page</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* The PDF image. */}
|
||||||
|
<img {...imageProps} alt="" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -14,11 +14,11 @@ import {
|
|||||||
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
||||||
import type { TTemplate } from '@documenso/lib/types/template';
|
import type { TTemplate } from '@documenso/lib/types/template';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
|
||||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||||
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
|
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
|
||||||
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
|
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
|
||||||
@@ -312,11 +312,12 @@ export const TemplateEditForm = ({
|
|||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewerLazy
|
<PDFViewer
|
||||||
key={template.envelopeItems[0].id}
|
key={template.envelopeItems[0].id}
|
||||||
envelopeItem={template.envelopeItems[0]}
|
envelopeItem={template.envelopeItems[0]}
|
||||||
token={undefined}
|
token={undefined}
|
||||||
version="signed"
|
version="current"
|
||||||
|
scrollParentRef="window"
|
||||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||||
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@@ -16,12 +18,12 @@ import {
|
|||||||
DocumentReadOnlyFields,
|
DocumentReadOnlyFields,
|
||||||
mapFieldsWithRecipients,
|
mapFieldsWithRecipients,
|
||||||
} from '@documenso/ui/components/document/document-read-only-fields';
|
} from '@documenso/ui/components/document/document-read-only-fields';
|
||||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
|
||||||
|
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
|
||||||
import { Spinner } from '@documenso/ui/primitives/spinner';
|
import { Spinner } from '@documenso/ui/primitives/spinner';
|
||||||
|
|
||||||
import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
|
import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
|
||||||
@@ -55,9 +57,14 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
|||||||
data: envelope,
|
data: envelope,
|
||||||
isLoading: isLoadingEnvelope,
|
isLoading: isLoadingEnvelope,
|
||||||
isError: isErrorEnvelope,
|
isError: isErrorEnvelope,
|
||||||
} = trpc.envelope.get.useQuery({
|
} = trpc.envelope.get.useQuery(
|
||||||
envelopeId: params.id,
|
{
|
||||||
});
|
envelopeId: params.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoadingEnvelope) {
|
if (isLoadingEnvelope) {
|
||||||
return (
|
return (
|
||||||
@@ -154,7 +161,9 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
|||||||
{envelope.internalVersion === 2 ? (
|
{envelope.internalVersion === 2 ? (
|
||||||
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
|
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
|
||||||
<EnvelopeRenderProvider
|
<EnvelopeRenderProvider
|
||||||
|
version="current"
|
||||||
envelope={envelope}
|
envelope={envelope}
|
||||||
|
envelopeItems={envelope.envelopeItems}
|
||||||
token={undefined}
|
token={undefined}
|
||||||
fields={envelope.fields}
|
fields={envelope.fields}
|
||||||
recipients={envelope.recipients}
|
recipients={envelope.recipients}
|
||||||
@@ -169,9 +178,10 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
|||||||
|
|
||||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewerKonvaLazy
|
<EnvelopePdfViewer
|
||||||
renderer="preview"
|
|
||||||
customPageRenderer={EnvelopeGenericPageRenderer}
|
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||||
|
scrollParentRef="window"
|
||||||
|
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -193,11 +203,12 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PDFViewerLazy
|
<PDFViewer
|
||||||
envelopeItem={envelope.envelopeItems[0]}
|
envelopeItem={envelope.envelopeItems[0]}
|
||||||
token={undefined}
|
token={undefined}
|
||||||
key={envelope.envelopeItems[0].id}
|
key={envelope.envelopeItems[0].id}
|
||||||
version="signed"
|
version="current"
|
||||||
|
scrollParentRef="window"
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import { EnvelopeType } from '@prisma/client';
|
|||||||
import { Link, useNavigate } from 'react-router';
|
import { Link, useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Spinner } from '@documenso/ui/primitives/spinner';
|
import { Spinner } from '@documenso/ui/primitives/spinner';
|
||||||
|
|
||||||
import EnvelopeEditor from '~/components/general/envelope-editor/envelope-editor';
|
import { EnvelopeEditor } from '~/components/general/envelope-editor/envelope-editor';
|
||||||
|
import { EnvelopeEditorRenderProviderWrapper } from '~/components/general/envelope-editor/envelope-editor-renderer-provider-wrapper';
|
||||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
retry: false,
|
retry: false,
|
||||||
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -58,7 +60,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
|
|||||||
|
|
||||||
if (envelope && (envelope.teamId !== team.id || envelope.internalVersion !== 2)) {
|
if (envelope && (envelope.teamId !== team.id || envelope.internalVersion !== 2)) {
|
||||||
return (
|
return (
|
||||||
<div className="text-foreground flex h-screen w-screen flex-col items-center justify-center gap-2">
|
<div className="flex h-screen w-screen flex-col items-center justify-center gap-2 text-foreground">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
<Trans>Redirecting</Trans>
|
<Trans>Redirecting</Trans>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,7 +69,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
|
|||||||
|
|
||||||
if (isLoadingEnvelope) {
|
if (isLoadingEnvelope) {
|
||||||
return (
|
return (
|
||||||
<div className="text-foreground flex h-screen w-screen flex-col items-center justify-center gap-2">
|
<div className="flex h-screen w-screen flex-col items-center justify-center gap-2 text-foreground">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
<Trans>Loading</Trans>
|
<Trans>Loading</Trans>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,14 +100,9 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<EnvelopeEditorProvider initialEnvelope={envelope}>
|
<EnvelopeEditorProvider initialEnvelope={envelope}>
|
||||||
<EnvelopeRenderProvider
|
<EnvelopeEditorRenderProviderWrapper>
|
||||||
envelope={envelope}
|
|
||||||
token={undefined}
|
|
||||||
fields={envelope.fields}
|
|
||||||
recipients={envelope.recipients}
|
|
||||||
>
|
|
||||||
<EnvelopeEditor />
|
<EnvelopeEditor />
|
||||||
</EnvelopeRenderProvider>
|
</EnvelopeEditorRenderProviderWrapper>
|
||||||
</EnvelopeEditorProvider>
|
</EnvelopeEditorProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,17 @@ import { Link, useNavigate } from 'react-router';
|
|||||||
|
|
||||||
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||||
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
|
||||||
|
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
|
||||||
import { Spinner } from '@documenso/ui/primitives/spinner';
|
import { Spinner } from '@documenso/ui/primitives/spinner';
|
||||||
|
|
||||||
import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog';
|
import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog';
|
||||||
@@ -50,9 +52,14 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
|||||||
data: envelope,
|
data: envelope,
|
||||||
isLoading: isLoadingEnvelope,
|
isLoading: isLoadingEnvelope,
|
||||||
isError: isErrorEnvelope,
|
isError: isErrorEnvelope,
|
||||||
} = trpc.envelope.get.useQuery({
|
} = trpc.envelope.get.useQuery(
|
||||||
envelopeId: params.id,
|
{
|
||||||
});
|
envelopeId: params.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoadingEnvelope) {
|
if (isLoadingEnvelope) {
|
||||||
return (
|
return (
|
||||||
@@ -173,7 +180,9 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
|||||||
{envelope.internalVersion === 2 ? (
|
{envelope.internalVersion === 2 ? (
|
||||||
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
|
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
|
||||||
<EnvelopeRenderProvider
|
<EnvelopeRenderProvider
|
||||||
|
version="current"
|
||||||
envelope={envelope}
|
envelope={envelope}
|
||||||
|
envelopeItems={envelope.envelopeItems}
|
||||||
token={undefined}
|
token={undefined}
|
||||||
fields={envelope.fields}
|
fields={envelope.fields}
|
||||||
recipients={envelope.recipients}
|
recipients={envelope.recipients}
|
||||||
@@ -187,9 +196,10 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
|||||||
|
|
||||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewerKonvaLazy
|
<EnvelopePdfViewer
|
||||||
renderer="preview"
|
|
||||||
customPageRenderer={EnvelopeGenericPageRenderer}
|
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||||
|
scrollParentRef="window"
|
||||||
|
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -210,11 +220,12 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
|||||||
documentMeta={mockedDocumentMeta}
|
documentMeta={mockedDocumentMeta}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PDFViewerLazy
|
<PDFViewer
|
||||||
envelopeItem={envelope.envelopeItems[0]}
|
envelopeItem={envelope.envelopeItems[0]}
|
||||||
token={undefined}
|
token={undefined}
|
||||||
version="signed"
|
version="current"
|
||||||
key={envelope.envelopeItems[0].id}
|
key={envelope.envelopeItems[0].id}
|
||||||
|
scrollParentRef="window"
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
|||||||
{template.title}
|
{template.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="text-muted-foreground mb-8 mt-2.5 flex items-center gap-x-2">
|
<div className="mb-8 mt-2.5 flex items-center gap-x-2 text-muted-foreground">
|
||||||
<UsersIcon className="h-4 w-4" />
|
<UsersIcon className="h-4 w-4" />
|
||||||
<p className="text-muted-foreground/80">
|
<p className="text-muted-foreground/80">
|
||||||
<Plural value={template.recipients.length} one="# recipient" other="# recipients" />
|
<Plural value={template.recipients.length} one="# recipient" other="# recipients" />
|
||||||
@@ -246,7 +246,12 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
|||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
user={user}
|
user={user}
|
||||||
>
|
>
|
||||||
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
|
<EnvelopeRenderProvider
|
||||||
|
version="current"
|
||||||
|
envelope={envelope}
|
||||||
|
envelopeItems={envelope.envelopeItems}
|
||||||
|
token={recipient.token}
|
||||||
|
>
|
||||||
<DocumentSigningPageViewV2 />
|
<DocumentSigningPageViewV2 />
|
||||||
</EnvelopeRenderProvider>
|
</EnvelopeRenderProvider>
|
||||||
</DocumentSigningAuthProvider>
|
</DocumentSigningAuthProvider>
|
||||||
|
|||||||
@@ -494,7 +494,12 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
|
|||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
user={user}
|
user={user}
|
||||||
>
|
>
|
||||||
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
|
<EnvelopeRenderProvider
|
||||||
|
version="current"
|
||||||
|
envelope={envelope}
|
||||||
|
envelopeItems={envelope.envelopeItems}
|
||||||
|
token={recipient.token}
|
||||||
|
>
|
||||||
<DocumentSigningPageViewV2 />
|
<DocumentSigningPageViewV2 />
|
||||||
</EnvelopeRenderProvider>
|
</EnvelopeRenderProvider>
|
||||||
</DocumentSigningAuthProvider>
|
</DocumentSigningAuthProvider>
|
||||||
|
|||||||
@@ -320,7 +320,12 @@ const EmbedDirectTemplatePageV2 = ({
|
|||||||
user={user}
|
user={user}
|
||||||
isDirectTemplate={true}
|
isDirectTemplate={true}
|
||||||
>
|
>
|
||||||
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
|
<EnvelopeRenderProvider
|
||||||
|
version="current"
|
||||||
|
envelope={envelope}
|
||||||
|
envelopeItems={envelope.envelopeItems}
|
||||||
|
token={recipient.token}
|
||||||
|
>
|
||||||
<EmbedSignDocumentV2ClientPage
|
<EmbedSignDocumentV2ClientPage
|
||||||
hidePoweredBy={hidePoweredBy}
|
hidePoweredBy={hidePoweredBy}
|
||||||
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
||||||
|
|||||||
@@ -382,7 +382,12 @@ const EmbedSignDocumentPageV2 = ({
|
|||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
user={user}
|
user={user}
|
||||||
>
|
>
|
||||||
<EnvelopeRenderProvider envelope={envelope} token={token}>
|
<EnvelopeRenderProvider
|
||||||
|
version="current"
|
||||||
|
envelope={envelope}
|
||||||
|
envelopeItems={envelope.envelopeItems}
|
||||||
|
token={token}
|
||||||
|
>
|
||||||
<EmbedSignDocumentV2ClientPage
|
<EmbedSignDocumentV2ClientPage
|
||||||
hidePoweredBy={hidePoweredBy}
|
hidePoweredBy={hidePoweredBy}
|
||||||
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
||||||
|
|||||||
@@ -0,0 +1,647 @@
|
|||||||
|
/**
|
||||||
|
* This is an internal test page for the embedding system.
|
||||||
|
*
|
||||||
|
* We use this to test embeds for E2E testing.
|
||||||
|
*
|
||||||
|
* No translations required.
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
|
export const loader = () => {
|
||||||
|
if (process.env.NODE_ENV !== 'development') {
|
||||||
|
throw new Error('This page is only available in development mode.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dummy embed test page.
|
||||||
|
*
|
||||||
|
* Simulates an embedding parent that renders the V2 authoring iframe
|
||||||
|
* with configurable features, externalId, and mode.
|
||||||
|
*
|
||||||
|
* Navigate to /embed/dummy to use.
|
||||||
|
*/
|
||||||
|
export default function EmbedDummyPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [token, setToken] = useState(() => searchParams.get('token') || '');
|
||||||
|
const [externalId, setExternalId] = useState(() => searchParams.get('externalId') || '');
|
||||||
|
const [mode, setMode] = useState<'create' | 'edit'>(
|
||||||
|
() => (searchParams.get('mode') as 'create' | 'edit') || 'create',
|
||||||
|
);
|
||||||
|
const [envelopeId, setEnvelopeId] = useState(() => searchParams.get('envelopeId') || '');
|
||||||
|
const [envelopeType, setEnvelopeType] = useState<'DOCUMENT' | 'TEMPLATE'>(
|
||||||
|
() => (searchParams.get('envelopeType') as 'DOCUMENT' | 'TEMPLATE') || 'DOCUMENT',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-launch if query params are present on mount
|
||||||
|
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
|
||||||
|
const [iframeKey, setIframeKey] = useState(0);
|
||||||
|
const [messages, setMessages] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Feature flags state -- grouped by section
|
||||||
|
const [generalFeatures, setGeneralFeatures] = useState({
|
||||||
|
allowConfigureEnvelopeTitle: true,
|
||||||
|
allowUploadAndRecipientStep: true,
|
||||||
|
allowAddFieldsStep: true,
|
||||||
|
allowPreviewStep: true,
|
||||||
|
minimizeLeftSidebar: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [settingsFeatures, setSettingsFeatures] = useState({
|
||||||
|
allowConfigureSignatureTypes: true,
|
||||||
|
allowConfigureLanguage: true,
|
||||||
|
allowConfigureDateFormat: true,
|
||||||
|
allowConfigureTimezone: true,
|
||||||
|
allowConfigureRedirectUrl: true,
|
||||||
|
allowConfigureDistribution: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [actionsFeatures, setActionsFeatures] = useState({
|
||||||
|
allowAttachments: true,
|
||||||
|
allowDistributing: false,
|
||||||
|
allowDirectLink: false,
|
||||||
|
allowDuplication: false,
|
||||||
|
allowDownloadPDF: false,
|
||||||
|
allowDeletion: false,
|
||||||
|
allowReturnToPreviousPage: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [envelopeItemsFeatures, setEnvelopeItemsFeatures] = useState({
|
||||||
|
allowConfigureTitle: true,
|
||||||
|
allowConfigureOrder: true,
|
||||||
|
allowUpload: true,
|
||||||
|
allowDelete: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [recipientsFeatures, setRecipientsFeatures] = useState({
|
||||||
|
allowAIDetection: true,
|
||||||
|
allowConfigureSigningOrder: true,
|
||||||
|
allowConfigureDictateNextSigner: true,
|
||||||
|
allowApproverRole: true,
|
||||||
|
allowViewerRole: true,
|
||||||
|
allowCCerRole: true,
|
||||||
|
allowAssistantRole: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [fieldsFeatures, setFieldsFeatures] = useState({
|
||||||
|
allowAIDetection: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// CSS theming state
|
||||||
|
const [darkModeDisabled, setDarkModeDisabled] = useState(false);
|
||||||
|
const [rawCss, setRawCss] = useState('');
|
||||||
|
const [cssVars, setCssVars] = useState<Record<string, string>>({
|
||||||
|
background: '',
|
||||||
|
foreground: '',
|
||||||
|
muted: '',
|
||||||
|
mutedForeground: '',
|
||||||
|
popover: '',
|
||||||
|
popoverForeground: '',
|
||||||
|
card: '',
|
||||||
|
cardBorder: '',
|
||||||
|
cardBorderTint: '',
|
||||||
|
cardForeground: '',
|
||||||
|
fieldCard: '',
|
||||||
|
fieldCardBorder: '',
|
||||||
|
fieldCardForeground: '',
|
||||||
|
widget: '',
|
||||||
|
widgetForeground: '',
|
||||||
|
border: '',
|
||||||
|
input: '',
|
||||||
|
primary: '',
|
||||||
|
primaryForeground: '',
|
||||||
|
secondary: '',
|
||||||
|
secondaryForeground: '',
|
||||||
|
accent: '',
|
||||||
|
accentForeground: '',
|
||||||
|
destructive: '',
|
||||||
|
destructiveForeground: '',
|
||||||
|
ring: '',
|
||||||
|
radius: '',
|
||||||
|
warning: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isResolvingToken, setIsResolvingToken] = useState(false);
|
||||||
|
const [tokenError, setTokenError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hasAutoLaunched = useRef(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the token starts with "api_", exchange it for a presign token
|
||||||
|
* via the embedding presign endpoint. Otherwise return as-is.
|
||||||
|
*/
|
||||||
|
const resolveToken = async (inputToken: string): Promise<string> => {
|
||||||
|
if (!inputToken.startsWith('api_')) {
|
||||||
|
return inputToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/v2/embedding/create-presign-token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${inputToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Failed to exchange API token (${response.status}): ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const presignToken = data?.token;
|
||||||
|
|
||||||
|
if (!presignToken || typeof presignToken !== 'string') {
|
||||||
|
throw new Error(`Unexpected response shape: ${JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return presignToken;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
const timestamp = new Date().toISOString().slice(11, 19);
|
||||||
|
setMessages((prev) => [...prev, `[${timestamp}] ${JSON.stringify(event.data, null, 2)}`]);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', handleMessage);
|
||||||
|
return () => window.removeEventListener('message', handleMessage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// Auto-launch on mount if token is present in query params
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasAutoLaunched.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialToken = searchParams.get('token');
|
||||||
|
|
||||||
|
if (initialToken) {
|
||||||
|
hasAutoLaunched.current = true;
|
||||||
|
void launchEmbed(initialToken);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateQueryParams = (params: {
|
||||||
|
token: string;
|
||||||
|
externalId: string;
|
||||||
|
mode: string;
|
||||||
|
envelopeId: string;
|
||||||
|
envelopeType: string;
|
||||||
|
}) => {
|
||||||
|
const newParams = new URLSearchParams();
|
||||||
|
|
||||||
|
if (params.token) {
|
||||||
|
newParams.set('token', params.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.externalId) {
|
||||||
|
newParams.set('externalId', params.externalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.mode && params.mode !== 'create') {
|
||||||
|
newParams.set('mode', params.mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.envelopeId) {
|
||||||
|
newParams.set('envelopeId', params.envelopeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.envelopeType && params.envelopeType !== 'DOCUMENT') {
|
||||||
|
newParams.set('envelopeType', params.envelopeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
const qs = newParams.toString();
|
||||||
|
|
||||||
|
void navigate(qs ? `?${qs}` : '.', { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const launchEmbed = async (overrideToken?: string) => {
|
||||||
|
const inputToken = overrideToken ?? token;
|
||||||
|
|
||||||
|
if (!inputToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTokenError(null);
|
||||||
|
setIsResolvingToken(true);
|
||||||
|
|
||||||
|
let presignToken: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
presignToken = await resolveToken(inputToken);
|
||||||
|
} catch (err) {
|
||||||
|
setTokenError(err instanceof Error ? err.message : String(err));
|
||||||
|
setIsResolvingToken(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsResolvingToken(false);
|
||||||
|
|
||||||
|
// Filter out empty cssVars entries
|
||||||
|
const filteredCssVars: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(cssVars)) {
|
||||||
|
if (value) {
|
||||||
|
filteredCssVars[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashData = {
|
||||||
|
externalId: externalId || undefined,
|
||||||
|
type: mode === 'create' ? envelopeType : undefined,
|
||||||
|
darkModeDisabled: darkModeDisabled || undefined,
|
||||||
|
css: rawCss || undefined,
|
||||||
|
cssVars: Object.keys(filteredCssVars).length > 0 ? filteredCssVars : undefined,
|
||||||
|
features: {
|
||||||
|
general: generalFeatures,
|
||||||
|
settings: settingsFeatures,
|
||||||
|
actions: actionsFeatures,
|
||||||
|
envelopeItems: envelopeItemsFeatures,
|
||||||
|
recipients: recipientsFeatures,
|
||||||
|
fields: fieldsFeatures,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const hash = btoa(encodeURIComponent(JSON.stringify(hashData)));
|
||||||
|
|
||||||
|
const basePath =
|
||||||
|
mode === 'create'
|
||||||
|
? '/embed/v2/authoring/envelope/create'
|
||||||
|
: `/embed/v2/authoring/envelope/edit/${envelopeId}`;
|
||||||
|
|
||||||
|
setIframeSrc(`${basePath}?token=${presignToken}#${hash}`);
|
||||||
|
setIframeKey((prev) => prev + 1);
|
||||||
|
|
||||||
|
updateQueryParams({ token: inputToken, externalId, mode, envelopeId, envelopeType });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void launchEmbed();
|
||||||
|
},
|
||||||
|
[
|
||||||
|
token,
|
||||||
|
externalId,
|
||||||
|
mode,
|
||||||
|
envelopeId,
|
||||||
|
envelopeType,
|
||||||
|
generalFeatures,
|
||||||
|
settingsFeatures,
|
||||||
|
actionsFeatures,
|
||||||
|
envelopeItemsFeatures,
|
||||||
|
recipientsFeatures,
|
||||||
|
fieldsFeatures,
|
||||||
|
darkModeDisabled,
|
||||||
|
rawCss,
|
||||||
|
cssVars,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setToken('');
|
||||||
|
setExternalId('');
|
||||||
|
setMode('create');
|
||||||
|
setEnvelopeId('');
|
||||||
|
setEnvelopeType('DOCUMENT');
|
||||||
|
setIframeSrc(null);
|
||||||
|
setMessages([]);
|
||||||
|
setTokenError(null);
|
||||||
|
setDarkModeDisabled(false);
|
||||||
|
setRawCss('');
|
||||||
|
setCssVars((prev) => {
|
||||||
|
const cleared: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const key of Object.keys(prev)) {
|
||||||
|
cleared[key] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleared;
|
||||||
|
});
|
||||||
|
void navigate('.', { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCheckboxGroup = <T extends Record<string, boolean>>(
|
||||||
|
label: string,
|
||||||
|
state: T,
|
||||||
|
setState: React.Dispatch<React.SetStateAction<T>>,
|
||||||
|
) => (
|
||||||
|
<fieldset
|
||||||
|
style={{ border: '1px solid #ccc', padding: '8px', marginBottom: '8px', borderRadius: '4px' }}
|
||||||
|
>
|
||||||
|
<legend style={{ fontWeight: 'bold', fontSize: '13px' }}>{label}</legend>
|
||||||
|
{Object.entries(state).map(([key, value]) => (
|
||||||
|
<label key={key} style={{ display: 'block', fontSize: '12px', marginBottom: '2px' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value}
|
||||||
|
onChange={(e) => setState((prev) => ({ ...prev, [key]: e.target.checked }))}
|
||||||
|
style={{ marginRight: '4px' }}
|
||||||
|
/>
|
||||||
|
{key}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', height: '100vh', fontFamily: 'monospace' }}>
|
||||||
|
{/* Left panel: controls */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '320px',
|
||||||
|
padding: '12px',
|
||||||
|
borderRight: '1px solid #ccc',
|
||||||
|
overflowY: 'auto',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ margin: '0 0 12px', fontSize: '16px' }}>Embed Test</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div style={{ marginBottom: '8px' }}>
|
||||||
|
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>
|
||||||
|
API or Embedded Token (Required)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
|
||||||
|
placeholder="api_... or presign token"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{tokenError && (
|
||||||
|
<div style={{ color: 'red', fontSize: '11px', marginTop: '4px' }}>{tokenError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '8px' }}>
|
||||||
|
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>
|
||||||
|
External ID (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={externalId}
|
||||||
|
onChange={(e) => setExternalId(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
|
||||||
|
placeholder="your-correlation-id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '8px' }}>
|
||||||
|
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>Mode</label>
|
||||||
|
<select
|
||||||
|
value={mode}
|
||||||
|
onChange={(e) => setMode(e.target.value as 'create' | 'edit')}
|
||||||
|
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
|
||||||
|
>
|
||||||
|
<option value="create">Create</option>
|
||||||
|
<option value="edit">Edit</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'create' && (
|
||||||
|
<div style={{ marginBottom: '8px' }}>
|
||||||
|
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>
|
||||||
|
Envelope Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={envelopeType}
|
||||||
|
onChange={(e) => setEnvelopeType(e.target.value as 'DOCUMENT' | 'TEMPLATE')}
|
||||||
|
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
|
||||||
|
>
|
||||||
|
<option value="DOCUMENT">Document</option>
|
||||||
|
<option value="TEMPLATE">Template</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'edit' && (
|
||||||
|
<div style={{ marginBottom: '8px' }}>
|
||||||
|
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>
|
||||||
|
Envelope ID
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={envelopeId}
|
||||||
|
onChange={(e) => setEnvelopeId(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
|
||||||
|
placeholder="envelope_..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3 style={{ fontSize: '14px', margin: '12px 0 4px' }}>Feature Flags</h3>
|
||||||
|
|
||||||
|
{renderCheckboxGroup('General', generalFeatures, setGeneralFeatures)}
|
||||||
|
{renderCheckboxGroup('Settings', settingsFeatures, setSettingsFeatures)}
|
||||||
|
{renderCheckboxGroup('Actions', actionsFeatures, setActionsFeatures)}
|
||||||
|
{renderCheckboxGroup('Envelope Items', envelopeItemsFeatures, setEnvelopeItemsFeatures)}
|
||||||
|
{renderCheckboxGroup('Recipients', recipientsFeatures, setRecipientsFeatures)}
|
||||||
|
{renderCheckboxGroup('Fields', fieldsFeatures, setFieldsFeatures)}
|
||||||
|
|
||||||
|
<h3 style={{ fontSize: '14px', margin: '12px 0 4px' }}>CSS Theming</h3>
|
||||||
|
|
||||||
|
<label style={{ display: 'block', fontSize: '12px', marginBottom: '8px' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={darkModeDisabled}
|
||||||
|
onChange={(e) => setDarkModeDisabled(e.target.checked)}
|
||||||
|
style={{ marginRight: '4px' }}
|
||||||
|
/>
|
||||||
|
darkModeDisabled
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<fieldset
|
||||||
|
style={{
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
padding: '8px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<legend style={{ fontWeight: 'bold', fontSize: '13px' }}>CSS Variables</legend>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxHeight: '200px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.entries(cssVars).map(([key, value]) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
marginBottom: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label style={{ fontSize: '11px', width: '140px', flexShrink: 0 }}>{key}</label>
|
||||||
|
{key !== 'radius' && (
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={value || '#000000'}
|
||||||
|
onChange={(e) => setCssVars((prev) => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
style={{ width: '24px', height: '20px', padding: 0, border: 'none' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setCssVars((prev) => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
style={{ flex: 1, padding: '2px 4px', fontSize: '11px' }}
|
||||||
|
placeholder={key === 'radius' ? '0.5rem' : '#hex or color'}
|
||||||
|
/>
|
||||||
|
{value && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCssVars((prev) => ({ ...prev, [key]: '' }))}
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '0 4px',
|
||||||
|
lineHeight: '18px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset
|
||||||
|
style={{
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
padding: '8px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<legend style={{ fontWeight: 'bold', fontSize: '13px' }}>Raw CSS</legend>
|
||||||
|
<textarea
|
||||||
|
value={rawCss}
|
||||||
|
onChange={(e) => setRawCss(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '80px',
|
||||||
|
padding: '4px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
resize: 'vertical',
|
||||||
|
}}
|
||||||
|
placeholder=".my-class { color: red; }"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isResolvingToken}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '8px',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
cursor: isResolvingToken ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isResolvingToken ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isResolvingToken ? 'Resolving Token...' : 'Launch Embed'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: '13px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Message log */}
|
||||||
|
<div style={{ marginTop: '16px' }}>
|
||||||
|
<h3 style={{ fontSize: '14px', margin: '0 0 4px' }}>
|
||||||
|
PostMessage Log
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMessages([])}
|
||||||
|
style={{ marginLeft: '8px', fontSize: '10px', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '200px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
padding: '4px',
|
||||||
|
fontSize: '11px',
|
||||||
|
backgroundColor: '#f9f9f9',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<span style={{ color: '#999' }}>Waiting for messages...</span>
|
||||||
|
)}
|
||||||
|
{messages.map((msg, i) => (
|
||||||
|
<div key={i} style={{ borderBottom: '1px solid #eee', padding: '2px 0' }}>
|
||||||
|
{msg}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right panel: iframe */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{iframeSrc ? (
|
||||||
|
<iframe
|
||||||
|
key={iframeKey}
|
||||||
|
src={iframeSrc}
|
||||||
|
style={{ flex: 1, border: 'none', width: '100%', height: '100%' }}
|
||||||
|
title="Embedded Authoring"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#999',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Enter a token and click "Launch Embed" to start
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { useLayoutEffect } from 'react';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { OrganisationMemberRole, OrganisationType, TeamMemberRole } from '@prisma/client';
|
||||||
|
import { Outlet, isRouteErrorResponse, useLoaderData } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
|
||||||
|
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
|
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||||
|
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||||
|
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
||||||
|
import { TrpcProvider } from '@documenso/trpc/react';
|
||||||
|
import type { OrganisationSession } from '@documenso/trpc/server/organisation-router/get-organisation-session.types';
|
||||||
|
|
||||||
|
import { TeamProvider } from '~/providers/team';
|
||||||
|
import { ZBaseEmbedDataSchema } from '~/types/embed-base-schemas';
|
||||||
|
import { injectCss } from '~/utils/css-vars';
|
||||||
|
|
||||||
|
import type { Route } from './+types/_layout';
|
||||||
|
|
||||||
|
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
const token = url.searchParams.get('token');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Response('Invalid token', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new Response('Invalid token', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const organisationClaim = await getOrganisationClaimByTeamId({
|
||||||
|
teamId: result.teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamSettings = await getTeamSettings({
|
||||||
|
userId: result.userId,
|
||||||
|
teamId: result.teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
userId: result.userId,
|
||||||
|
teamId: result.teamId,
|
||||||
|
organisationClaim,
|
||||||
|
preferences: {
|
||||||
|
aiFeaturesEnabled: teamSettings.aiFeaturesEnabled,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AuthoringLayout() {
|
||||||
|
const { token, teamId, organisationClaim, preferences } = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
const allowEmbedAuthoringWhiteLabel = organisationClaim.flags.embedAuthoringWhiteLabel ?? false;
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
try {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
|
||||||
|
const result = ZBaseEmbedDataSchema.safeParse(JSON.parse(decodeURIComponent(atob(hash))));
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { css, cssVars, darkModeDisabled } = result.data;
|
||||||
|
|
||||||
|
if (darkModeDisabled) {
|
||||||
|
document.documentElement.classList.add('dark-mode-disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowEmbedAuthoringWhiteLabel) {
|
||||||
|
injectCss({
|
||||||
|
css,
|
||||||
|
cssVars,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dummy data for providers.
|
||||||
|
*/
|
||||||
|
const team: OrganisationSession['teams'][number] = {
|
||||||
|
id: teamId,
|
||||||
|
name: '',
|
||||||
|
url: '',
|
||||||
|
createdAt: new Date(),
|
||||||
|
avatarImageId: null,
|
||||||
|
organisationId: '',
|
||||||
|
currentTeamRole: TeamMemberRole.ADMIN,
|
||||||
|
preferences: {
|
||||||
|
aiFeaturesEnabled: preferences.aiFeaturesEnabled,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dummy data for providers.
|
||||||
|
*/
|
||||||
|
const organisation: OrganisationSession = {
|
||||||
|
id: '',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
type: OrganisationType.ORGANISATION,
|
||||||
|
name: '',
|
||||||
|
url: '',
|
||||||
|
avatarImageId: null,
|
||||||
|
customerId: null,
|
||||||
|
ownerUserId: -1,
|
||||||
|
organisationClaim,
|
||||||
|
teams: [team],
|
||||||
|
subscription: null,
|
||||||
|
currentOrganisationRole: OrganisationMemberRole.ADMIN,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OrganisationProvider organisation={organisation}>
|
||||||
|
<TeamProvider team={team}>
|
||||||
|
<TrpcProvider
|
||||||
|
headers={{ authorization: `Bearer ${token}`, 'x-team-Id': team.id.toString() }}
|
||||||
|
>
|
||||||
|
<LimitsProvider
|
||||||
|
bypassLimits={true}
|
||||||
|
initialValue={{
|
||||||
|
quota: PAID_PLAN_LIMITS,
|
||||||
|
remaining: PAID_PLAN_LIMITS,
|
||||||
|
maximumEnvelopeItemCount: organisationClaim.envelopeItemCount,
|
||||||
|
}}
|
||||||
|
teamId={team.id}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</LimitsProvider>
|
||||||
|
</TrpcProvider>
|
||||||
|
</TeamProvider>
|
||||||
|
</OrganisationProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
|
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{match(errorCode)
|
||||||
|
.with(404, () => (
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
<Trans>Token Not Found</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<Trans>Ensure that you are using the embedding token, not the API token</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>
|
||||||
|
If you are using staging, ensure that you have set the host prop on the embedding
|
||||||
|
component to the staging domain (https://stg-app.documenso.com)
|
||||||
|
</Trans>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<p>
|
||||||
|
<Trans>An error occurred</Trans>
|
||||||
|
{errorCode}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
import { useLayoutEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import {
|
||||||
|
DocumentStatus,
|
||||||
|
EnvelopeType,
|
||||||
|
ReadStatus,
|
||||||
|
SendStatus,
|
||||||
|
SigningStatus,
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { CheckCircle2Icon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
|
import type { SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
|
||||||
|
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||||
|
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
||||||
|
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
|
||||||
|
import type { TDocumentMetaDateFormat } from '@documenso/lib/types/document-meta';
|
||||||
|
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
|
||||||
|
import {
|
||||||
|
type TEmbedCreateEnvelopeAuthoring,
|
||||||
|
ZEmbedCreateEnvelopeAuthoringSchema,
|
||||||
|
} from '@documenso/lib/types/envelope-editor';
|
||||||
|
import type { TEnvelopeFieldAndMeta } from '@documenso/lib/types/field-meta';
|
||||||
|
import { extractDerivedDocumentMeta } from '@documenso/lib/utils/document';
|
||||||
|
import { buildEmbeddedFeatures } from '@documenso/lib/utils/embed-config';
|
||||||
|
import { buildEmbeddedEditorOptions } from '@documenso/lib/utils/embed-config';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||||
|
import { Spinner } from '@documenso/ui/primitives/spinner';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { EnvelopeEditor } from '~/components/general/envelope-editor/envelope-editor';
|
||||||
|
import { EnvelopeEditorRenderProviderWrapper } from '~/components/general/envelope-editor/envelope-editor-renderer-provider-wrapper';
|
||||||
|
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||||
|
|
||||||
|
import type { Route } from './+types/envelope.create._index';
|
||||||
|
|
||||||
|
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// We know that the token is present because we're checking it in the parent _layout route
|
||||||
|
const token = url.searchParams.get('token') || '';
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Response('Invalid token', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// We also know that the token is valid, but we need the userId + teamId
|
||||||
|
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new Response('Invalid token', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamSettings = await getTeamSettings({
|
||||||
|
userId: result.userId,
|
||||||
|
teamId: result.teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return superLoaderJson({
|
||||||
|
token,
|
||||||
|
tokenUserId: result.userId,
|
||||||
|
tokenTeamId: result.teamId,
|
||||||
|
teamSettings,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EmbeddingAuthoringEnvelopeCreatePage() {
|
||||||
|
const [hasInitialized, setHasInitialized] = useState(false);
|
||||||
|
const [embedAuthoringOptions, setEmbedAuthoringOptions] =
|
||||||
|
useState<TEmbedCreateEnvelopeAuthoring | null>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
try {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
|
||||||
|
if (hash) {
|
||||||
|
const result = ZEmbedCreateEnvelopeAuthoringSchema.safeParse(
|
||||||
|
JSON.parse(decodeURIComponent(atob(hash))),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setEmbedAuthoringOptions({
|
||||||
|
...result.data,
|
||||||
|
features: buildEmbeddedFeatures(result.data.features),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error parsing embedding params:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasInitialized(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!hasInitialized) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!embedAuthoringOptions) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<Trans>Invalid Embedding Parameters</Trans>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <EnvelopeCreatePage embedAuthoringOptions={embedAuthoringOptions} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnvelopeCreatePageProps = {
|
||||||
|
embedAuthoringOptions: TEmbedCreateEnvelopeAuthoring;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps) => {
|
||||||
|
const { token, tokenUserId, tokenTeamId, teamSettings } = useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [isCreatingEnvelope, setIsCreatingEnvelope] = useState(false);
|
||||||
|
const [createdEnvelope, setCreatedEnvelope] = useState<{ id: string } | null>(null);
|
||||||
|
|
||||||
|
const { mutateAsync: createEmbeddingEnvelope } =
|
||||||
|
trpc.embeddingPresign.createEmbeddingEnvelope.useMutation();
|
||||||
|
|
||||||
|
const buildCreateEnvelopeRequest = (
|
||||||
|
envelope: Omit<TEditorEnvelope, 'id'>,
|
||||||
|
): { payload: TCreateEnvelopePayload; files: File[] } => {
|
||||||
|
const sortedItems = [...envelope.envelopeItems].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||||
|
|
||||||
|
const itemIdToIndex = new Map<string, number>();
|
||||||
|
|
||||||
|
sortedItems.forEach((item, index) => {
|
||||||
|
itemIdToIndex.set(String(item.id), index);
|
||||||
|
});
|
||||||
|
|
||||||
|
const files: File[] = [];
|
||||||
|
|
||||||
|
for (const item of sortedItems) {
|
||||||
|
if (!item.data) {
|
||||||
|
throw new Error(`Envelope item "${item.title ?? item.id}" has no PDF data`);
|
||||||
|
}
|
||||||
|
|
||||||
|
files.push(
|
||||||
|
new File(
|
||||||
|
[item.data],
|
||||||
|
item.title?.endsWith('.pdf') ? item.title : `${item.title ?? 'document'}.pdf`,
|
||||||
|
{
|
||||||
|
type: 'application/pdf',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipients = envelope.recipients.map((recipient) => {
|
||||||
|
const recipientFields = envelope.fields.filter((f) => f.recipientId === recipient.id);
|
||||||
|
|
||||||
|
const fields = recipientFields.map((field) => {
|
||||||
|
return {
|
||||||
|
identifier: itemIdToIndex.get(String(field.envelopeItemId)),
|
||||||
|
page: field.page,
|
||||||
|
positionX: Number(field.positionX),
|
||||||
|
positionY: Number(field.positionY),
|
||||||
|
width: Number(field.width),
|
||||||
|
height: Number(field.height),
|
||||||
|
...({
|
||||||
|
type: field.type,
|
||||||
|
fieldMeta: field.fieldMeta ?? undefined,
|
||||||
|
} as TEnvelopeFieldAndMeta),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
email: recipient.email,
|
||||||
|
name: recipient.name,
|
||||||
|
role: recipient.role,
|
||||||
|
signingOrder: recipient.signingOrder ?? undefined,
|
||||||
|
accessAuth: recipient.authOptions?.accessAuth ?? [],
|
||||||
|
actionAuth: recipient.authOptions?.actionAuth ?? [],
|
||||||
|
fields,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload: TCreateEnvelopePayload = {
|
||||||
|
title: envelope.title,
|
||||||
|
type: envelope.type,
|
||||||
|
externalId: envelope.externalId ?? undefined,
|
||||||
|
visibility: envelope.visibility,
|
||||||
|
globalAccessAuth: envelope.authOptions?.globalAccessAuth?.length
|
||||||
|
? envelope.authOptions?.globalAccessAuth
|
||||||
|
: undefined,
|
||||||
|
globalActionAuth: envelope.authOptions?.globalActionAuth?.length
|
||||||
|
? envelope.authOptions?.globalActionAuth
|
||||||
|
: undefined,
|
||||||
|
folderId: envelope.folderId ?? undefined,
|
||||||
|
recipients,
|
||||||
|
meta: {
|
||||||
|
subject: envelope.documentMeta.subject ?? undefined,
|
||||||
|
message: envelope.documentMeta.message ?? undefined,
|
||||||
|
timezone: envelope.documentMeta.timezone ?? undefined,
|
||||||
|
dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined,
|
||||||
|
distributionMethod: envelope.documentMeta.distributionMethod ?? undefined,
|
||||||
|
signingOrder: envelope.documentMeta.signingOrder ?? undefined,
|
||||||
|
allowDictateNextSigner: envelope.documentMeta.allowDictateNextSigner ?? undefined,
|
||||||
|
redirectUrl: envelope.documentMeta.redirectUrl ?? undefined,
|
||||||
|
language: envelope.documentMeta.language as SupportedLanguageCodes,
|
||||||
|
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled ?? undefined,
|
||||||
|
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled ?? undefined,
|
||||||
|
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled ?? undefined,
|
||||||
|
emailId: envelope.documentMeta.emailId ?? undefined,
|
||||||
|
emailReplyTo: envelope.documentMeta.emailReplyTo ?? undefined,
|
||||||
|
emailSettings: envelope.documentMeta.emailSettings ?? undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { payload, files };
|
||||||
|
};
|
||||||
|
|
||||||
|
const createEmbeddedEnvelope = async (envelopeWithoutId: Omit<TEditorEnvelope, 'id'>) => {
|
||||||
|
setIsCreatingEnvelope(true);
|
||||||
|
|
||||||
|
if (isCreatingEnvelope) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { payload, files } = buildCreateEnvelopeRequest(envelopeWithoutId);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('payload', JSON.stringify(payload));
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
formData.append('files', file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await createEmbeddingEnvelope(formData);
|
||||||
|
|
||||||
|
// Send a message to the parent window with the document details
|
||||||
|
if (window.parent !== window) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
type: 'envelope-created',
|
||||||
|
envelopeId: id,
|
||||||
|
externalId: envelopeWithoutId.externalId,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreatedEnvelope({ id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create envelope:', err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: t`Error`,
|
||||||
|
description: t`Failed to create document. Please try again.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreatingEnvelope(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const embeded = useMemo(
|
||||||
|
() => ({
|
||||||
|
presignToken: token,
|
||||||
|
mode: 'create' as const,
|
||||||
|
onCreate: async (envelope: Omit<TEditorEnvelope, 'id'>) => createEmbeddedEnvelope(envelope),
|
||||||
|
customBrandingLogo: Boolean(teamSettings.brandingEnabled && teamSettings.brandingLogo),
|
||||||
|
}),
|
||||||
|
[token],
|
||||||
|
);
|
||||||
|
|
||||||
|
const editorConfig = useMemo(() => {
|
||||||
|
return buildEmbeddedEditorOptions(embedAuthoringOptions.features, embeded);
|
||||||
|
}, [embedAuthoringOptions.features, embeded]);
|
||||||
|
|
||||||
|
const initialEnvelope = useMemo((): TEditorEnvelope => {
|
||||||
|
const defaultDocumentMeta = extractDerivedDocumentMeta(teamSettings, undefined);
|
||||||
|
|
||||||
|
const defaultRecipients = teamSettings.defaultRecipients
|
||||||
|
? ZDefaultRecipientsSchema.parse(teamSettings.defaultRecipients)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const recipients: TEditorEnvelope['recipients'] = defaultRecipients.map((recipient, index) => ({
|
||||||
|
id: -(index + 1),
|
||||||
|
envelopeId: '',
|
||||||
|
email: recipient.email,
|
||||||
|
name: recipient.name,
|
||||||
|
role: recipient.role,
|
||||||
|
token: '',
|
||||||
|
readStatus: ReadStatus.NOT_OPENED,
|
||||||
|
signingStatus: SigningStatus.NOT_SIGNED,
|
||||||
|
sendStatus: SendStatus.NOT_SENT,
|
||||||
|
documentDeletedAt: null,
|
||||||
|
expired: null,
|
||||||
|
signedAt: null,
|
||||||
|
authOptions: {
|
||||||
|
accessAuth: [],
|
||||||
|
actionAuth: [],
|
||||||
|
},
|
||||||
|
signingOrder: index + 1,
|
||||||
|
rejectionReason: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const type = embedAuthoringOptions.type;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: '',
|
||||||
|
secondaryId: '',
|
||||||
|
internalVersion: 2,
|
||||||
|
type,
|
||||||
|
status: DocumentStatus.DRAFT,
|
||||||
|
source: 'DOCUMENT',
|
||||||
|
visibility: teamSettings.documentVisibility,
|
||||||
|
templateType: 'PRIVATE',
|
||||||
|
completedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
|
title: type === EnvelopeType.DOCUMENT ? 'Document Title' : 'Template Title',
|
||||||
|
authOptions: {
|
||||||
|
globalAccessAuth: [],
|
||||||
|
globalActionAuth: [],
|
||||||
|
},
|
||||||
|
publicTitle: '',
|
||||||
|
publicDescription: '',
|
||||||
|
userId: tokenUserId,
|
||||||
|
teamId: tokenTeamId,
|
||||||
|
folderId: null,
|
||||||
|
documentMeta: {
|
||||||
|
id: '',
|
||||||
|
...defaultDocumentMeta,
|
||||||
|
},
|
||||||
|
recipients,
|
||||||
|
fields: [],
|
||||||
|
envelopeItems: [],
|
||||||
|
directLink: null,
|
||||||
|
team: {
|
||||||
|
id: tokenTeamId,
|
||||||
|
url: '',
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: tokenUserId,
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
},
|
||||||
|
externalId: embedAuthoringOptions?.externalId ?? null,
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-screen relative min-h-screen">
|
||||||
|
{isCreatingEnvelope && (
|
||||||
|
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
|
||||||
|
<Spinner />
|
||||||
|
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{initialEnvelope.type === EnvelopeType.DOCUMENT ? (
|
||||||
|
<Trans>Creating Document</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Creating Template</Trans>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{createdEnvelope && (
|
||||||
|
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
|
||||||
|
<div className="mx-auto w-full max-w-md text-center">
|
||||||
|
<CheckCircle2Icon className="mx-auto h-16 w-16 text-primary" />
|
||||||
|
|
||||||
|
<h1 className="mt-6 text-2xl font-bold">
|
||||||
|
{initialEnvelope.type === EnvelopeType.TEMPLATE ? (
|
||||||
|
<Trans>Template Created</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Document Created</Trans>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
{initialEnvelope.type === EnvelopeType.TEMPLATE ? (
|
||||||
|
<Trans>Your template has been created successfully</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Your document has been created successfully</Trans>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EnvelopeEditorProvider initialEnvelope={initialEnvelope} editorConfig={editorConfig}>
|
||||||
|
<EnvelopeEditorRenderProviderWrapper presignedToken={token}>
|
||||||
|
<EnvelopeEditor />
|
||||||
|
</EnvelopeEditorRenderProviderWrapper>
|
||||||
|
</EnvelopeEditorProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
import { useLayoutEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { EnvelopeType } from '@prisma/client';
|
||||||
|
import { CheckCircle2Icon } from 'lucide-react';
|
||||||
|
import { redirect } from 'react-router';
|
||||||
|
|
||||||
|
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
|
import type { SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
|
||||||
|
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||||
|
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||||
|
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
||||||
|
import type { TDocumentMetaDateFormat } from '@documenso/lib/types/document-meta';
|
||||||
|
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
|
||||||
|
import {
|
||||||
|
type TEmbedEditEnvelopeAuthoring,
|
||||||
|
ZEmbedEditEnvelopeAuthoringSchema,
|
||||||
|
} from '@documenso/lib/types/envelope-editor';
|
||||||
|
import type { TEnvelopeFieldAndMeta } from '@documenso/lib/types/field-meta';
|
||||||
|
import { buildEmbeddedEditorOptions } from '@documenso/lib/utils/embed-config';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TUpdateEmbeddingEnvelopePayload } from '@documenso/trpc/server/embedding-router/update-embedding-envelope.types';
|
||||||
|
import { Spinner } from '@documenso/ui/primitives/spinner';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { EnvelopeEditor } from '~/components/general/envelope-editor/envelope-editor';
|
||||||
|
import { EnvelopeEditorRenderProviderWrapper } from '~/components/general/envelope-editor/envelope-editor-renderer-provider-wrapper';
|
||||||
|
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||||
|
|
||||||
|
import type { Route } from './+types/envelope.edit.$id';
|
||||||
|
|
||||||
|
export const loader = async ({ request, params }: Route.LoaderArgs) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
if (!id || !id.startsWith('envelope_')) {
|
||||||
|
throw redirect(`/embed/v2/authoring/error/not-found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We know that the token is present because we're checking it in the parent _layout route
|
||||||
|
const token = url.searchParams.get('token') || '';
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Response('Invalid token', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// We also know that the token is valid, but we need the userId + teamId
|
||||||
|
const result = await verifyEmbeddingPresignToken({ token, scope: `envelopeId:${id}` }).catch(
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('Invalid token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await getTeamSettings({
|
||||||
|
userId: result.userId,
|
||||||
|
teamId: result.teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const envelope = await getEnvelopeById({
|
||||||
|
id: {
|
||||||
|
type: 'envelopeId',
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
type: null,
|
||||||
|
userId: result.userId,
|
||||||
|
teamId: result.teamId,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!envelope) {
|
||||||
|
throw redirect(`/embed/v2/authoring/error/not-found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let brandingLogo: string | undefined = undefined;
|
||||||
|
|
||||||
|
if (settings.brandingEnabled && settings.brandingLogo) {
|
||||||
|
brandingLogo = settings.brandingLogo;
|
||||||
|
}
|
||||||
|
|
||||||
|
return superLoaderJson({
|
||||||
|
token,
|
||||||
|
envelope,
|
||||||
|
brandingLogo,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EmbeddingAuthoringEnvelopeEditPage() {
|
||||||
|
const [hasInitialized, setHasInitialized] = useState(false);
|
||||||
|
const [embedAuthoringOptions, setEmbedAuthoringOptions] =
|
||||||
|
useState<TEmbedEditEnvelopeAuthoring | null>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
try {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
|
||||||
|
if (hash) {
|
||||||
|
const result = ZEmbedEditEnvelopeAuthoringSchema.safeParse(
|
||||||
|
JSON.parse(decodeURIComponent(atob(hash))),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setEmbedAuthoringOptions(result.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error parsing embedding params:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasInitialized(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!hasInitialized) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!embedAuthoringOptions) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<Trans>Invalid Embedding Parameters</Trans>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <EnvelopeEditPage embedAuthoringOptions={embedAuthoringOptions} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnvelopeEditPageProps = {
|
||||||
|
embedAuthoringOptions: TEmbedEditEnvelopeAuthoring;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
|
||||||
|
const { envelope, token, brandingLogo } = useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [isUpdatingEnvelope, setIsUpdatingEnvelope] = useState(false);
|
||||||
|
const [updatedEnvelope, setUpdatedEnvelope] = useState<{ id: string } | null>(null);
|
||||||
|
|
||||||
|
const { mutateAsync: updateEmbeddingEnvelope } =
|
||||||
|
trpc.embeddingPresign.updateEmbeddingEnvelope.useMutation();
|
||||||
|
|
||||||
|
const buildUpdateEnvelopeRequest = (
|
||||||
|
envelope: TEditorEnvelope,
|
||||||
|
): { payload: TUpdateEmbeddingEnvelopePayload; files: File[] } => {
|
||||||
|
const files: File[] = [];
|
||||||
|
|
||||||
|
const envelopeItems = envelope.envelopeItems.map((item) => {
|
||||||
|
// Attach any new envelope item files to the request.
|
||||||
|
if (item.data) {
|
||||||
|
files.push(
|
||||||
|
new File(
|
||||||
|
[item.data],
|
||||||
|
item.title?.endsWith('.pdf') ? item.title : `${item.title ?? 'document'}.pdf`,
|
||||||
|
{
|
||||||
|
type: 'application/pdf',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
order: item.order,
|
||||||
|
index: item.data ? files.length - 1 : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipients = envelope.recipients.map((recipient) => {
|
||||||
|
const recipientFields = envelope.fields.filter((f) => f.recipientId === recipient.id);
|
||||||
|
|
||||||
|
const fields = recipientFields.map((field) => ({
|
||||||
|
id: field.id,
|
||||||
|
envelopeItemId: field.envelopeItemId,
|
||||||
|
page: field.page,
|
||||||
|
positionX: Number(field.positionX),
|
||||||
|
positionY: Number(field.positionY),
|
||||||
|
width: Number(field.width),
|
||||||
|
height: Number(field.height),
|
||||||
|
...({
|
||||||
|
type: field.type,
|
||||||
|
fieldMeta: field.fieldMeta ?? undefined,
|
||||||
|
} as TEnvelopeFieldAndMeta),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
email: recipient.email,
|
||||||
|
name: recipient.name,
|
||||||
|
role: recipient.role,
|
||||||
|
signingOrder: recipient.signingOrder ?? undefined,
|
||||||
|
accessAuth: recipient.authOptions?.accessAuth ?? [],
|
||||||
|
actionAuth: recipient.authOptions?.actionAuth ?? [],
|
||||||
|
fields,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload: TUpdateEmbeddingEnvelopePayload = {
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
data: {
|
||||||
|
title: envelope.title,
|
||||||
|
externalId: envelope.externalId,
|
||||||
|
visibility: envelope.visibility,
|
||||||
|
globalAccessAuth: envelope.authOptions?.globalAccessAuth,
|
||||||
|
globalActionAuth: envelope.authOptions?.globalActionAuth,
|
||||||
|
folderId: envelope.folderId,
|
||||||
|
recipients,
|
||||||
|
envelopeItems,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
subject: envelope.documentMeta.subject ?? undefined,
|
||||||
|
message: envelope.documentMeta.message ?? undefined,
|
||||||
|
timezone: envelope.documentMeta.timezone ?? undefined,
|
||||||
|
distributionMethod: envelope.documentMeta.distributionMethod ?? undefined,
|
||||||
|
signingOrder: envelope.documentMeta.signingOrder ?? undefined,
|
||||||
|
allowDictateNextSigner: envelope.documentMeta.allowDictateNextSigner ?? undefined,
|
||||||
|
redirectUrl: envelope.documentMeta.redirectUrl ?? undefined,
|
||||||
|
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled ?? undefined,
|
||||||
|
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled ?? undefined,
|
||||||
|
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled ?? undefined,
|
||||||
|
emailId: envelope.documentMeta.emailId ?? undefined,
|
||||||
|
emailReplyTo: envelope.documentMeta.emailReplyTo ?? undefined,
|
||||||
|
emailSettings: envelope.documentMeta.emailSettings ?? undefined,
|
||||||
|
dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined,
|
||||||
|
language: envelope.documentMeta.language as SupportedLanguageCodes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { payload, files };
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateEmbeddedEnvelope = async (envelope: TEditorEnvelope) => {
|
||||||
|
setIsUpdatingEnvelope(true);
|
||||||
|
|
||||||
|
if (isUpdatingEnvelope) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { payload, files } = buildUpdateEnvelopeRequest(envelope);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('payload', JSON.stringify(payload));
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
formData.append('files', file);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateEmbeddingEnvelope(formData);
|
||||||
|
|
||||||
|
// Send a message to the parent window with the document details
|
||||||
|
if (window.parent !== window) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
type: 'envelope-updated',
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
externalId: envelope.externalId || null,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to the completion page.
|
||||||
|
setUpdatedEnvelope({ id: envelope.id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update envelope:', err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: t`Error`,
|
||||||
|
description: t`Failed to update envelope. Please try again.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdatingEnvelope(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const embeded = useMemo(
|
||||||
|
() => ({
|
||||||
|
presignToken: token,
|
||||||
|
mode: 'edit' as const,
|
||||||
|
onUpdate: async (envelope: TEditorEnvelope) => updateEmbeddedEnvelope(envelope),
|
||||||
|
brandingLogo,
|
||||||
|
}),
|
||||||
|
[token],
|
||||||
|
);
|
||||||
|
|
||||||
|
const editorConfig = useMemo(() => {
|
||||||
|
return buildEmbeddedEditorOptions(embedAuthoringOptions.features, embeded);
|
||||||
|
}, [embedAuthoringOptions.features, embeded]);
|
||||||
|
|
||||||
|
const initialEnvelope = useMemo(
|
||||||
|
() => ({
|
||||||
|
...envelope,
|
||||||
|
externalId: embedAuthoringOptions?.externalId || envelope.externalId || null,
|
||||||
|
}),
|
||||||
|
[envelope, embedAuthoringOptions?.externalId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-screen relative min-h-screen">
|
||||||
|
{isUpdatingEnvelope && (
|
||||||
|
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
|
||||||
|
<Spinner />
|
||||||
|
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{envelope.type === EnvelopeType.DOCUMENT ? (
|
||||||
|
<Trans>Updating Document</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Updating Template</Trans>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{updatedEnvelope && (
|
||||||
|
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
|
||||||
|
<div className="mx-auto w-full max-w-md text-center">
|
||||||
|
<CheckCircle2Icon className="mx-auto h-16 w-16 text-primary" />
|
||||||
|
|
||||||
|
<h1 className="mt-6 text-2xl font-bold">
|
||||||
|
{envelope.type === EnvelopeType.TEMPLATE ? (
|
||||||
|
<Trans>Template Updated</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Document Updated</Trans>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
{envelope.type === EnvelopeType.TEMPLATE ? (
|
||||||
|
<Trans>Your template has been updated successfully</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Your document has been updated successfully</Trans>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EnvelopeEditorProvider initialEnvelope={initialEnvelope} editorConfig={editorConfig}>
|
||||||
|
<EnvelopeEditorRenderProviderWrapper presignedToken={token}>
|
||||||
|
<EnvelopeEditor />
|
||||||
|
</EnvelopeEditorRenderProviderWrapper>
|
||||||
|
</EnvelopeEditorProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -23,6 +23,10 @@ import {
|
|||||||
ZGetPresignedPostUrlRequestSchema,
|
ZGetPresignedPostUrlRequestSchema,
|
||||||
ZUploadPdfRequestSchema,
|
ZUploadPdfRequestSchema,
|
||||||
} from './files.types';
|
} from './files.types';
|
||||||
|
import getEnvelopeItemImageRoute from './routes/get-envelope-item-image';
|
||||||
|
import getEnvelopeItemImageByTokenRoute from './routes/get-envelope-item-image-by-token';
|
||||||
|
import getEnvelopeItemMetaRoute from './routes/get-envelope-item-meta';
|
||||||
|
import getEnvelopeItemMetaByTokenRoute from './routes/get-envelope-item-meta-by-token';
|
||||||
|
|
||||||
export const filesRoute = new Hono<HonoEnv>()
|
export const filesRoute = new Hono<HonoEnv>()
|
||||||
/**
|
/**
|
||||||
@@ -319,3 +323,11 @@ export const filesRoute = new Hono<HonoEnv>()
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Envelope item meta routes for both tokens and auth based
|
||||||
|
filesRoute.route('/', getEnvelopeItemMetaRoute);
|
||||||
|
filesRoute.route('/', getEnvelopeItemMetaByTokenRoute);
|
||||||
|
|
||||||
|
// Image routes for both tokens and auth based
|
||||||
|
filesRoute.route('/', getEnvelopeItemImageRoute);
|
||||||
|
filesRoute.route('/', getEnvelopeItemImageByTokenRoute);
|
||||||
|
|||||||
@@ -72,3 +72,23 @@ export const ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema = z.object({
|
|||||||
export type TGetEnvelopeItemFileTokenDownloadRequestParams = z.infer<
|
export type TGetEnvelopeItemFileTokenDownloadRequestParams = z.infer<
|
||||||
typeof ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema
|
typeof ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export const ZGetEnvelopeItemMetaSchema = z.object({
|
||||||
|
envelopeItemId: z.string(),
|
||||||
|
documentDataId: z.string(),
|
||||||
|
pages: z
|
||||||
|
.object({
|
||||||
|
originalWidth: z.number(),
|
||||||
|
originalHeight: z.number(),
|
||||||
|
scale: z.number(),
|
||||||
|
scaledWidth: z.number(),
|
||||||
|
scaledHeight: z.number(),
|
||||||
|
})
|
||||||
|
.array(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZGetEnvelopeItemsMetaResponseSchema = z.object({
|
||||||
|
envelopeItems: z.array(ZGetEnvelopeItemMetaSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TGetEnvelopeItemsMetaResponse = z.infer<typeof ZGetEnvelopeItemsMetaResponseSchema>;
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { sValidator } from '@hono/standard-validator';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import type { HonoEnv } from '../../../router';
|
||||||
|
import { handleEnvelopeItemPageRequest } from './get-envelope-item-image';
|
||||||
|
|
||||||
|
const route = new Hono<HonoEnv>();
|
||||||
|
|
||||||
|
const ZGetEnvelopeItemPageTokenParamsSchema = z.object({
|
||||||
|
token: z.string().min(1),
|
||||||
|
envelopeId: z.string().min(1),
|
||||||
|
envelopeItemId: z.string().min(1),
|
||||||
|
documentDataId: z.string().min(1),
|
||||||
|
version: z.enum(['initial', 'current']),
|
||||||
|
pageIndex: z.coerce.number().int().min(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a single PDF page as a JPEG image using a token.
|
||||||
|
*/
|
||||||
|
route.get(
|
||||||
|
'/token/:token/envelope/:envelopeId/envelopeItem/:envelopeItemId/dataId/:documentDataId/:version/:pageIndex/image.jpeg',
|
||||||
|
sValidator('param', ZGetEnvelopeItemPageTokenParamsSchema),
|
||||||
|
async (c) => {
|
||||||
|
const { token, envelopeId, envelopeItemId, documentDataId, version, pageIndex } =
|
||||||
|
c.req.valid('param');
|
||||||
|
|
||||||
|
// Validate envelope access.
|
||||||
|
const envelopeItem = await prisma.envelopeItem.findFirst({
|
||||||
|
where: {
|
||||||
|
id: envelopeItemId,
|
||||||
|
documentDataId,
|
||||||
|
envelope: {
|
||||||
|
id: envelopeId,
|
||||||
|
recipients: {
|
||||||
|
some: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
documentData: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!envelopeItem) {
|
||||||
|
return c.json({ error: 'Not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We can hard cache this since since it's a unique URL for a given recipient.
|
||||||
|
// Might be dicey if the handler returns a cacheable error code.
|
||||||
|
c.header('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
|
|
||||||
|
return await handleEnvelopeItemPageRequest({
|
||||||
|
c,
|
||||||
|
envelopeItem,
|
||||||
|
version,
|
||||||
|
pageIndex,
|
||||||
|
cacheStrategy: 'public',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default route;
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { sValidator } from '@hono/standard-validator';
|
||||||
|
import type { DocumentData, EnvelopeItem } from '@prisma/client';
|
||||||
|
import { type Context, Hono } from 'hono';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { PDF_IMAGE_RENDER_SCALE } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
import { pdfToImage } from '@documenso/lib/server-only/ai/pdf-to-images';
|
||||||
|
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||||
|
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
import type { DocumentDataVersion } from '@documenso/lib/types/document-data';
|
||||||
|
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||||
|
import { UNSAFE_getS3File } from '@documenso/lib/universal/upload/server-actions';
|
||||||
|
import { getEnvelopeItemPageImageS3Key } from '@documenso/lib/utils/envelope-images';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import type { HonoEnv } from '../../../router';
|
||||||
|
|
||||||
|
const route = new Hono<HonoEnv>();
|
||||||
|
|
||||||
|
const ZGetEnvelopeItemPageRequestParamsSchema = z.object({
|
||||||
|
envelopeId: z.string().min(1),
|
||||||
|
envelopeItemId: z.string().min(1),
|
||||||
|
documentDataId: z.string().min(1),
|
||||||
|
version: z.enum(['initial', 'current']),
|
||||||
|
pageIndex: z.coerce.number().int().min(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ZGetEnvelopeItemPageRequestQuerySchema = z.object({
|
||||||
|
presignToken: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a single PDF page as a JPEG image.
|
||||||
|
*/
|
||||||
|
route.get(
|
||||||
|
'/envelope/:envelopeId/envelopeItem/:envelopeItemId/dataId/:documentDataId/:version/:pageIndex/image.jpeg',
|
||||||
|
sValidator('param', ZGetEnvelopeItemPageRequestParamsSchema),
|
||||||
|
sValidator('query', ZGetEnvelopeItemPageRequestQuerySchema),
|
||||||
|
async (c) => {
|
||||||
|
const { envelopeId, envelopeItemId, documentDataId, version, pageIndex } = c.req.valid('param');
|
||||||
|
|
||||||
|
const { presignToken } = c.req.valid('query');
|
||||||
|
|
||||||
|
const session = await getOptionalSession(c);
|
||||||
|
|
||||||
|
let userId = session.user?.id;
|
||||||
|
|
||||||
|
// Check presignToken if provided
|
||||||
|
if (presignToken) {
|
||||||
|
const verifiedToken = await verifyEmbeddingPresignToken({
|
||||||
|
token: presignToken,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
|
||||||
|
userId = verifiedToken?.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return c.json({ error: 'Not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope = await prisma.envelope.findFirst({
|
||||||
|
where: { id: envelopeId },
|
||||||
|
include: {
|
||||||
|
envelopeItems: {
|
||||||
|
where: {
|
||||||
|
id: envelopeItemId,
|
||||||
|
documentDataId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
documentData: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!envelope) {
|
||||||
|
return c.json({ error: 'Not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [envelopeItem] = envelope.envelopeItems;
|
||||||
|
|
||||||
|
if (!envelopeItem?.documentData) {
|
||||||
|
return c.json({ error: 'Not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check team access
|
||||||
|
const team = await getTeamById({
|
||||||
|
userId,
|
||||||
|
teamId: envelope.teamId,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
return c.json({ error: 'Not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await handleEnvelopeItemPageRequest({
|
||||||
|
c,
|
||||||
|
envelopeItem,
|
||||||
|
version,
|
||||||
|
pageIndex,
|
||||||
|
cacheStrategy: 'private',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
type HandleEnvelopeItemPageRequestOptions = {
|
||||||
|
c: Context<HonoEnv>;
|
||||||
|
envelopeItem: EnvelopeItem & {
|
||||||
|
documentData: DocumentData;
|
||||||
|
};
|
||||||
|
pageIndex: number;
|
||||||
|
version: DocumentDataVersion;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of cache strategy to use.
|
||||||
|
*
|
||||||
|
* For access via tokens, we can use a public cache to allow the CDN to cache it.
|
||||||
|
*
|
||||||
|
* For access via session, we must use a private cache.
|
||||||
|
*/
|
||||||
|
cacheStrategy: 'private' | 'public';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleEnvelopeItemPageRequest = async ({
|
||||||
|
c,
|
||||||
|
envelopeItem,
|
||||||
|
pageIndex,
|
||||||
|
version,
|
||||||
|
cacheStrategy,
|
||||||
|
}: HandleEnvelopeItemPageRequestOptions) => {
|
||||||
|
// Determine which PDF data to use based on version requested.
|
||||||
|
const documentDataToUse =
|
||||||
|
version === 'current' ? envelopeItem.documentData.data : envelopeItem.documentData.initialData;
|
||||||
|
|
||||||
|
c.header('Content-Type', 'image/jpeg');
|
||||||
|
c.header('Cache-Control', `${cacheStrategy}, max-age=31536000, immutable`);
|
||||||
|
|
||||||
|
// Return the image if it already exists in S3.
|
||||||
|
if (envelopeItem.documentData.type === 'S3_PATH') {
|
||||||
|
const s3Key = getEnvelopeItemPageImageS3Key(documentDataToUse, pageIndex);
|
||||||
|
|
||||||
|
const image = await UNSAFE_getS3File(s3Key);
|
||||||
|
|
||||||
|
if (image) {
|
||||||
|
return c.body(image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch PDF to render the page on the spot if it doesn't exist in S3.
|
||||||
|
const pdfBytes = await getFileServerSide({
|
||||||
|
type: envelopeItem.documentData.type,
|
||||||
|
data: documentDataToUse,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render page to image.
|
||||||
|
const { image } = await pdfToImage(pdfBytes, {
|
||||||
|
scale: PDF_IMAGE_RENDER_SCALE,
|
||||||
|
pageIndex,
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
return {
|
||||||
|
image: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
return c.json({ error: 'Failed to render page to image' }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.body(image);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default route;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { sValidator } from '@hono/standard-validator';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import type { HonoEnv } from '../../../router';
|
||||||
|
import { handleEnvelopeItemsMetaRequest } from './get-envelope-item-meta';
|
||||||
|
|
||||||
|
const route = new Hono<HonoEnv>();
|
||||||
|
|
||||||
|
const ZGetEnvelopeMetaByTokenParamSchema = z.object({
|
||||||
|
token: z.string().min(1),
|
||||||
|
envelopeId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns metadata for all envelope items including page counts and dimensions using a token.
|
||||||
|
*/
|
||||||
|
route.get(
|
||||||
|
'/token/:token/envelope/:envelopeId/meta',
|
||||||
|
sValidator('param', ZGetEnvelopeMetaByTokenParamSchema),
|
||||||
|
async (c) => {
|
||||||
|
const { token, envelopeId } = c.req.valid('param');
|
||||||
|
|
||||||
|
// Validate token belongs to envelope
|
||||||
|
const recipient = await prisma.recipient.findFirst({
|
||||||
|
where: {
|
||||||
|
token,
|
||||||
|
envelopeId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
envelope: {
|
||||||
|
include: {
|
||||||
|
envelopeItems: {
|
||||||
|
include: { documentData: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
return c.json({ error: 'Not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await handleEnvelopeItemsMetaRequest({
|
||||||
|
c,
|
||||||
|
envelopeItems: recipient.envelope.envelopeItems,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default route;
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { sValidator } from '@hono/standard-validator';
|
||||||
|
import type { DocumentData, EnvelopeItem } from '@prisma/client';
|
||||||
|
import { type Context, Hono } from 'hono';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||||
|
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
import type { TDocumentDataMeta } from '@documenso/lib/types/document-data';
|
||||||
|
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||||
|
import { extractAndStorePdfImages } from '@documenso/lib/universal/upload/put-file.server';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import type { HonoEnv } from '../../../router';
|
||||||
|
import type { TGetEnvelopeItemsMetaResponse } from '../files.types';
|
||||||
|
|
||||||
|
const route = new Hono<HonoEnv>();
|
||||||
|
|
||||||
|
const ZGetEnvelopeMetaParamsSchema = z.object({
|
||||||
|
envelopeId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ZGetEnvelopeMetaQuerySchema = z.object({
|
||||||
|
presignToken: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns metadata for all envelope items including page counts and dimensions.
|
||||||
|
*/
|
||||||
|
route.get(
|
||||||
|
'/envelope/:envelopeId/meta',
|
||||||
|
sValidator('param', ZGetEnvelopeMetaParamsSchema),
|
||||||
|
sValidator('query', ZGetEnvelopeMetaQuerySchema),
|
||||||
|
async (c) => {
|
||||||
|
const { envelopeId } = c.req.valid('param');
|
||||||
|
const { presignToken } = c.req.valid('query');
|
||||||
|
|
||||||
|
const session = await getOptionalSession(c);
|
||||||
|
|
||||||
|
let userId = session.user?.id;
|
||||||
|
|
||||||
|
// Check presignToken if provided
|
||||||
|
if (presignToken) {
|
||||||
|
const verifiedToken = await verifyEmbeddingPresignToken({
|
||||||
|
token: presignToken,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
|
||||||
|
userId = verifiedToken?.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return c.json({ error: 'Not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Access is verified in the getTeamById call after this.
|
||||||
|
const envelope = await prisma.envelope.findFirst({
|
||||||
|
where: {
|
||||||
|
id: envelopeId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
envelopeItems: {
|
||||||
|
include: { documentData: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!envelope) {
|
||||||
|
return c.json({ error: 'Not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access to envelope.
|
||||||
|
const team = await getTeamById({
|
||||||
|
userId,
|
||||||
|
teamId: envelope.teamId,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
return c.json({ error: 'Not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await handleEnvelopeItemsMetaRequest({
|
||||||
|
c,
|
||||||
|
envelopeItems: envelope.envelopeItems,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
type HandleEnvelopeItemsMetaRequestOptions = {
|
||||||
|
c: Context<HonoEnv>;
|
||||||
|
envelopeItems: (EnvelopeItem & {
|
||||||
|
documentData: DocumentData;
|
||||||
|
})[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleEnvelopeItemsMetaRequest = async ({
|
||||||
|
c,
|
||||||
|
envelopeItems,
|
||||||
|
}: HandleEnvelopeItemsMetaRequestOptions) => {
|
||||||
|
const response = await Promise.all(
|
||||||
|
envelopeItems.map(async (item) => {
|
||||||
|
let pageMetadata = item.documentData.metadata;
|
||||||
|
|
||||||
|
// Runtime backfill if pageMetadata is missing.
|
||||||
|
if (!pageMetadata) {
|
||||||
|
const pdfBytes = await getFileServerSide({
|
||||||
|
type: item.documentData.type,
|
||||||
|
data: item.documentData.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdfPageMetadata: TDocumentDataMeta['pages'] = await extractAndStorePdfImages(
|
||||||
|
new Uint8Array(pdfBytes).buffer,
|
||||||
|
item.documentData.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
pageMetadata = {
|
||||||
|
pages: pdfPageMetadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = pageMetadata.pages ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
envelopeItemId: item.id,
|
||||||
|
documentDataId: item.documentData.id,
|
||||||
|
pages: pages.map((page) => ({
|
||||||
|
originalWidth: page.originalWidth,
|
||||||
|
originalHeight: page.originalHeight,
|
||||||
|
scale: page.scale,
|
||||||
|
scaledWidth: page.scaledWidth,
|
||||||
|
scaledHeight: page.scaledHeight,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ envelopeItems: response } satisfies TGetEnvelopeItemsMetaResponse);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default route;
|
||||||
Generated
-84
@@ -27365,24 +27365,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/make-cancellable-promise": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/make-event-props": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/wojtekmaj/make-event-props?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/map-stream": {
|
"node_modules/map-stream": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz",
|
||||||
@@ -27829,23 +27811,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/merge-refs": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/wojtekmaj/merge-refs?sponsor=1"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/merge-stream": {
|
"node_modules/merge-stream": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||||
@@ -31875,44 +31840,6 @@
|
|||||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-pdf": {
|
|
||||||
"version": "10.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.3.0.tgz",
|
|
||||||
"integrity": "sha512-2LQzC9IgNVAX8gM+6F+1t/70a9/5RWThYxc+CWAmT2LW/BRmnj+35x1os5j/nR2oldyf8L+hCAMBmVKU8wrYFA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"clsx": "^2.0.0",
|
|
||||||
"dequal": "^2.0.3",
|
|
||||||
"make-cancellable-promise": "^2.0.0",
|
|
||||||
"make-event-props": "^2.0.0",
|
|
||||||
"merge-refs": "^2.0.0",
|
|
||||||
"pdfjs-dist": "5.4.296",
|
|
||||||
"tiny-invariant": "^1.0.0",
|
|
||||||
"warning": "^4.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/wojtekmaj/react-pdf?sponsor=1"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
|
||||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-pdf/node_modules/clsx": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-promise-suspense": {
|
"node_modules/react-promise-suspense": {
|
||||||
"version": "0.3.4",
|
"version": "0.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz",
|
||||||
@@ -36482,15 +36409,6 @@
|
|||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/warning": {
|
|
||||||
"version": "4.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
|
|
||||||
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"loose-envify": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/wcwidth": {
|
"node_modules/wcwidth": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
|
||||||
@@ -37434,7 +37352,6 @@
|
|||||||
"posthog-js": "^1.297.2",
|
"posthog-js": "^1.297.2",
|
||||||
"posthog-node": "4.18.0",
|
"posthog-node": "4.18.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-pdf": "^10.3.0",
|
|
||||||
"remeda": "^2.32.0",
|
"remeda": "^2.32.0",
|
||||||
"sharp": "0.34.5",
|
"sharp": "0.34.5",
|
||||||
"skia-canvas": "^3.0.8",
|
"skia-canvas": "^3.0.8",
|
||||||
@@ -37592,7 +37509,6 @@
|
|||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-hook-form": "^7.66.1",
|
"react-hook-form": "^7.66.1",
|
||||||
"react-pdf": "^10.3.0",
|
|
||||||
"react-rnd": "^10.5.2",
|
"react-rnd": "^10.5.2",
|
||||||
"remeda": "^2.32.0",
|
"remeda": "^2.32.0",
|
||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
@@ -46,7 +47,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 100,
|
y: 100,
|
||||||
@@ -54,7 +55,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Text' }).click();
|
await page.getByRole('button', { name: 'Text' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 200,
|
y: 200,
|
||||||
@@ -74,7 +75,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 500,
|
y: 500,
|
||||||
@@ -100,7 +101,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 100,
|
y: 100,
|
||||||
@@ -108,7 +109,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Text' }).click();
|
await page.getByRole('button', { name: 'Text' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 200,
|
y: 200,
|
||||||
@@ -128,7 +129,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 500,
|
y: 500,
|
||||||
@@ -162,7 +163,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 100,
|
y: 100,
|
||||||
@@ -170,7 +171,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Text' }).click();
|
await page.getByRole('button', { name: 'Text' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 200,
|
y: 200,
|
||||||
@@ -190,7 +191,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 500,
|
y: 500,
|
||||||
@@ -224,7 +225,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 100,
|
y: 100,
|
||||||
@@ -232,7 +233,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Text' }).click();
|
await page.getByRole('button', { name: 'Text' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 200,
|
y: 200,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Page } from '@playwright/test';
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { EnvelopeType } from '@prisma/client';
|
import { EnvelopeType } from '@prisma/client';
|
||||||
|
|
||||||
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
@@ -28,7 +29,7 @@ export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => {
|
|||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 100,
|
y: 100,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
@@ -33,14 +34,14 @@ test('[DOCUMENT_FLOW]: Simple duplicate recipients test', async ({ page }) => {
|
|||||||
|
|
||||||
// Step 3: Add fields
|
// Step 3: Add fields
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||||
|
|
||||||
await page.getByRole('combobox').first().click();
|
await page.getByRole('combobox').first().click();
|
||||||
|
|
||||||
// Switch to second duplicate and add field
|
// Switch to second duplicate and add field
|
||||||
await page.getByText('Duplicate 2 (duplicate@example.com)').first().click();
|
await page.getByText('Duplicate 2 (duplicate@example.com)').first().click();
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||||
|
|
||||||
// Continue to send
|
// Continue to send
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|||||||
@@ -44,21 +44,21 @@ const completeDocumentFlowWithDuplicateRecipients = async (options: {
|
|||||||
// Step 3: Add fields for each recipient
|
// Step 3: Add fields for each recipient
|
||||||
// Add signature field for first duplicate recipient
|
// Add signature field for first duplicate recipient
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||||
|
|
||||||
await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click();
|
await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click();
|
||||||
|
|
||||||
// Switch to second duplicate recipient and add their field
|
// Switch to second duplicate recipient and add their field
|
||||||
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
|
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||||
|
|
||||||
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
|
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
|
||||||
|
|
||||||
// Switch to unique recipient and add their field
|
// Switch to unique recipient and add their field
|
||||||
await page.getByText('Unique Recipient (unique@example.com)').click();
|
await page.getByText('Unique Recipient (unique@example.com)').click();
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 100 } });
|
||||||
|
|
||||||
// Continue to subject
|
// Continue to subject
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
@@ -122,7 +122,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
|||||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||||
|
|
||||||
// Save the document by going to subject
|
// Save the document by going to subject
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
@@ -149,7 +149,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
|||||||
await page.getByText('Test Recipient Duplicate (test@example.com)').first().click();
|
await page.getByText('Test Recipient Duplicate (test@example.com)').first().click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||||
|
|
||||||
// Complete the flow
|
// Complete the flow
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
@@ -270,24 +270,24 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
|||||||
|
|
||||||
// Add signature for first recipient
|
// Add signature for first recipient
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||||
|
|
||||||
// Add name field for second recipient
|
// Add name field for second recipient
|
||||||
await page.getByRole('combobox').first().click();
|
await page.getByRole('combobox').first().click();
|
||||||
|
|
||||||
await page.getByText('Approver Role (signer@example.com)').first().click();
|
await page.getByText('Approver Role (signer@example.com)').first().click();
|
||||||
await page.getByRole('button', { name: 'Name' }).click();
|
await page.getByRole('button', { name: 'Name' }).click();
|
||||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||||
|
|
||||||
// Add date field for second recipient
|
// Add date field for second recipient
|
||||||
await page.getByRole('button', { name: 'Date' }).click();
|
await page.getByRole('button', { name: 'Date' }).click();
|
||||||
await page.locator('canvas').click({ position: { x: 200, y: 150 } });
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 150 } });
|
||||||
|
|
||||||
// If second recipient is still a SIGNER (role change wasn't available),
|
// If second recipient is still a SIGNER (role change wasn't available),
|
||||||
// add a signature field for them to pass validation
|
// add a signature field for them to pass validation
|
||||||
if (!secondRecipientIsApprover) {
|
if (!secondRecipientIsApprover) {
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({ position: { x: 200, y: 200 } });
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 200 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete the document
|
// Complete the document
|
||||||
@@ -349,7 +349,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
|||||||
|
|
||||||
// Add another field to the second duplicate
|
// Add another field to the second duplicate
|
||||||
await page.getByRole('button', { name: 'Name' }).click();
|
await page.getByRole('button', { name: 'Name' }).click();
|
||||||
await page.locator('canvas').click({ position: { x: 250, y: 150 } });
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 250, y: 150 } });
|
||||||
|
|
||||||
// Save changes
|
// Save changes
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import {
|
import {
|
||||||
seedBlankDocument,
|
seedBlankDocument,
|
||||||
@@ -92,7 +93,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
|
|||||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 100,
|
y: 100,
|
||||||
@@ -100,7 +101,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Email' }).click();
|
await page.getByRole('button', { name: 'Email' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 200,
|
y: 200,
|
||||||
@@ -158,7 +159,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
|||||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 100,
|
y: 100,
|
||||||
@@ -166,7 +167,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Email' }).click();
|
await page.getByRole('button', { name: 'Email' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 200,
|
y: 200,
|
||||||
@@ -177,7 +178,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
|||||||
await page.getByText('User 2 (user2@example.com)').click();
|
await page.getByText('User 2 (user2@example.com)').click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 500,
|
x: 500,
|
||||||
y: 100,
|
y: 100,
|
||||||
@@ -185,7 +186,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Email' }).click();
|
await page.getByRole('button', { name: 'Email' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 500,
|
x: 500,
|
||||||
y: 200,
|
y: 200,
|
||||||
@@ -256,7 +257,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
|||||||
await page.getByRole('option', { name: 'User 1 (user1@example.com)' }).click();
|
await page.getByRole('option', { name: 'User 1 (user1@example.com)' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 100,
|
y: 100,
|
||||||
@@ -264,7 +265,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Email' }).click();
|
await page.getByRole('button', { name: 'Email' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 200,
|
y: 200,
|
||||||
@@ -275,7 +276,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
|||||||
await page.getByRole('option', { name: 'User 3 (user3@example.com)' }).click();
|
await page.getByRole('option', { name: 'User 3 (user3@example.com)' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 500,
|
x: 500,
|
||||||
y: 100,
|
y: 100,
|
||||||
@@ -283,7 +284,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Email' }).click();
|
await page.getByRole('button', { name: 'Email' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 500,
|
x: 500,
|
||||||
y: 200,
|
y: 200,
|
||||||
@@ -576,7 +577,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
|
|||||||
}
|
}
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 100 * i,
|
y: 100 * i,
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { type Page, expect, test } from '@playwright/test';
|
||||||
|
import { FieldType } from '@prisma/client';
|
||||||
|
|
||||||
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type TEnvelopeEditorSurface,
|
||||||
|
addEnvelopeItemPdf,
|
||||||
|
clickAddMyselfButton,
|
||||||
|
clickEnvelopeEditorStep,
|
||||||
|
getEnvelopeEditorSettingsTrigger,
|
||||||
|
getRecipientEmailInputs,
|
||||||
|
openDocumentEnvelopeEditor,
|
||||||
|
openEmbeddedEnvelopeEditor,
|
||||||
|
openTemplateEnvelopeEditor,
|
||||||
|
persistEmbeddedEnvelope,
|
||||||
|
setRecipientEmail,
|
||||||
|
setRecipientName,
|
||||||
|
} from '../fixtures/envelope-editor';
|
||||||
|
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||||
|
|
||||||
|
type TFieldFlowResult = {
|
||||||
|
externalId: string;
|
||||||
|
recipientEmail: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEST_FIELD_VALUES = {
|
||||||
|
embeddedRecipient: {
|
||||||
|
email: 'embedded-field-recipient@documenso.com',
|
||||||
|
name: 'Embedded Field Recipient',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSettingsDialog = async (root: Page) => {
|
||||||
|
await getEnvelopeEditorSettingsTrigger(root).click();
|
||||||
|
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||||
|
await openSettingsDialog(surface.root);
|
||||||
|
await surface.root.locator('input[name="externalId"]').fill(externalId);
|
||||||
|
await surface.root.getByRole('button', { name: 'Update' }).click();
|
||||||
|
|
||||||
|
if (!surface.isEmbedded) {
|
||||||
|
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupRecipientsForFieldPlacement = async (surface: TEnvelopeEditorSurface) => {
|
||||||
|
if (surface.isEmbedded) {
|
||||||
|
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toHaveCount(0);
|
||||||
|
await setRecipientEmail(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.email);
|
||||||
|
await setRecipientName(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.name);
|
||||||
|
|
||||||
|
return TEST_FIELD_VALUES.embeddedRecipient.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toBeVisible();
|
||||||
|
await clickAddMyselfButton(surface.root);
|
||||||
|
await expect(getRecipientEmailInputs(surface.root).first()).toHaveValue(surface.userEmail);
|
||||||
|
|
||||||
|
return surface.userEmail;
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeFieldOnPdf = async (
|
||||||
|
root: Page,
|
||||||
|
fieldName: 'Signature' | 'Text',
|
||||||
|
position: { x: number; y: number },
|
||||||
|
) => {
|
||||||
|
await root.getByRole('button', { name: fieldName, exact: true }).click();
|
||||||
|
|
||||||
|
const canvas = root.locator('.konva-container canvas').first();
|
||||||
|
await expect(canvas).toBeVisible();
|
||||||
|
await canvas.click({ position });
|
||||||
|
};
|
||||||
|
|
||||||
|
const runFieldFlow = async (surface: TEnvelopeEditorSurface): Promise<TFieldFlowResult> => {
|
||||||
|
const externalId = `e2e-fields-${nanoid()}`;
|
||||||
|
|
||||||
|
if (surface.isEmbedded && !surface.envelopeId) {
|
||||||
|
await addEnvelopeItemPdf(surface.root, 'embedded-fields.pdf');
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateExternalId(surface, externalId);
|
||||||
|
const recipientEmail = await setupRecipientsForFieldPlacement(surface);
|
||||||
|
|
||||||
|
await clickEnvelopeEditorStep(surface.root, 'addFields');
|
||||||
|
await expect(surface.root.getByText('Selected Recipient')).toBeVisible();
|
||||||
|
await expect(surface.root.locator('.konva-container canvas').first()).toBeVisible();
|
||||||
|
|
||||||
|
await placeFieldOnPdf(surface.root, 'Signature', { x: 120, y: 140 });
|
||||||
|
await expect(surface.root.getByText('1 Field')).toBeVisible();
|
||||||
|
|
||||||
|
await placeFieldOnPdf(surface.root, 'Text', { x: 220, y: 240 });
|
||||||
|
await expect(surface.root.getByText('2 Fields')).toBeVisible();
|
||||||
|
|
||||||
|
await clickEnvelopeEditorStep(surface.root, 'upload');
|
||||||
|
await expect(surface.root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||||
|
|
||||||
|
await clickEnvelopeEditorStep(surface.root, 'addFields');
|
||||||
|
await expect(surface.root.getByText('Selected Recipient')).toBeVisible();
|
||||||
|
await expect(surface.root.getByText('2 Fields')).toBeVisible();
|
||||||
|
|
||||||
|
return {
|
||||||
|
externalId,
|
||||||
|
recipientEmail,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFieldMetaType = (fieldMeta: unknown) => {
|
||||||
|
if (!isRecord(fieldMeta)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof fieldMeta.type === 'string' ? fieldMeta.type : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === 'object' && value !== null;
|
||||||
|
|
||||||
|
const assertFieldsPersistedInDatabase = async ({
|
||||||
|
surface,
|
||||||
|
externalId,
|
||||||
|
recipientEmail,
|
||||||
|
}: {
|
||||||
|
surface: TEnvelopeEditorSurface;
|
||||||
|
externalId: string;
|
||||||
|
recipientEmail: string;
|
||||||
|
}) => {
|
||||||
|
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
externalId,
|
||||||
|
userId: surface.userId,
|
||||||
|
teamId: surface.teamId,
|
||||||
|
type: surface.envelopeType,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
fields: true,
|
||||||
|
recipients: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipient = envelope.recipients.find(
|
||||||
|
(currentRecipient) => currentRecipient.email === recipientEmail,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(recipient).toBeDefined();
|
||||||
|
|
||||||
|
const fieldTypes = envelope.fields.map((field) => field.type).sort();
|
||||||
|
const expectedFieldTypes = [FieldType.SIGNATURE, FieldType.TEXT].sort();
|
||||||
|
|
||||||
|
expect(envelope.fields).toHaveLength(2);
|
||||||
|
expect(fieldTypes).toEqual(expectedFieldTypes);
|
||||||
|
expect(new Set(envelope.fields.map((field) => field.envelopeItemId)).size).toBe(1);
|
||||||
|
expect(envelope.fields.every((field) => field.recipientId === recipient?.id)).toBe(true);
|
||||||
|
|
||||||
|
const signatureField = envelope.fields.find((field) => field.type === FieldType.SIGNATURE);
|
||||||
|
const textField = envelope.fields.find((field) => field.type === FieldType.TEXT);
|
||||||
|
|
||||||
|
expect(getFieldMetaType(signatureField?.fieldMeta)).toBe('signature');
|
||||||
|
expect(getFieldMetaType(textField?.fieldMeta)).toBe('text');
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('Envelope Editor V2 - Fields', () => {
|
||||||
|
test('documents/<id>: add and persist signature/text fields', async ({ page }) => {
|
||||||
|
const surface = await openDocumentEnvelopeEditor(page);
|
||||||
|
const result = await runFieldFlow(surface);
|
||||||
|
|
||||||
|
await assertFieldsPersistedInDatabase({
|
||||||
|
surface,
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('templates/<id>: add and persist signature/text fields', async ({ page }) => {
|
||||||
|
const surface = await openTemplateEnvelopeEditor(page);
|
||||||
|
const result = await runFieldFlow(surface);
|
||||||
|
|
||||||
|
await assertFieldsPersistedInDatabase({
|
||||||
|
surface,
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/embed/v2/authoring/envelope/create DOCUMENT: add and persist signature/text fields', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||||
|
envelopeType: 'DOCUMENT',
|
||||||
|
tokenNamePrefix: 'e2e-embed-fields',
|
||||||
|
});
|
||||||
|
const result = await runFieldFlow(surface);
|
||||||
|
|
||||||
|
await persistEmbeddedEnvelope(surface);
|
||||||
|
|
||||||
|
await assertFieldsPersistedInDatabase({
|
||||||
|
surface,
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/embed/v2/authoring/envelope/edit/<id> TEMPLATE: add and persist signature/text fields', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||||
|
envelopeType: 'TEMPLATE',
|
||||||
|
mode: 'edit',
|
||||||
|
tokenNamePrefix: 'e2e-embed-fields',
|
||||||
|
});
|
||||||
|
const result = await runFieldFlow(surface);
|
||||||
|
|
||||||
|
await persistEmbeddedEnvelope(surface);
|
||||||
|
|
||||||
|
await assertFieldsPersistedInDatabase({
|
||||||
|
surface,
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { type Page, expect, test } from '@playwright/test';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type TEnvelopeEditorSurface,
|
||||||
|
getEnvelopeItemDragHandles,
|
||||||
|
getEnvelopeItemDropzoneInput,
|
||||||
|
getEnvelopeItemRemoveButtons,
|
||||||
|
getEnvelopeItemTitleInputs,
|
||||||
|
openDocumentEnvelopeEditor,
|
||||||
|
openEmbeddedEnvelopeEditor,
|
||||||
|
openTemplateEnvelopeEditor,
|
||||||
|
} from '../fixtures/envelope-editor';
|
||||||
|
|
||||||
|
test.use({
|
||||||
|
storageState: {
|
||||||
|
cookies: [],
|
||||||
|
origins: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type TestFilePayload = {
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
buffer: Buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
|
||||||
|
|
||||||
|
const createPdfPayload = (name: string): TestFilePayload => ({
|
||||||
|
name,
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: examplePdfBuffer,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getCurrentTitles = async (root: Page) => {
|
||||||
|
const titleInputs = getEnvelopeItemTitleInputs(root);
|
||||||
|
const count = await titleInputs.count();
|
||||||
|
|
||||||
|
return await Promise.all(
|
||||||
|
Array.from({ length: count }, async (_, index) => await titleInputs.nth(index).inputValue()),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFiles = async (root: Page, files: TestFilePayload[]) => {
|
||||||
|
const input = getEnvelopeItemDropzoneInput(root);
|
||||||
|
|
||||||
|
await input.setInputFiles(files);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dragEnvelopeItemByHandle = async ({
|
||||||
|
root,
|
||||||
|
sourceIndex,
|
||||||
|
targetIndex,
|
||||||
|
}: {
|
||||||
|
root: Page;
|
||||||
|
sourceIndex: number;
|
||||||
|
targetIndex: number;
|
||||||
|
}) => {
|
||||||
|
const sourceHandle = getEnvelopeItemDragHandles(root).nth(sourceIndex);
|
||||||
|
const targetHandle = getEnvelopeItemDragHandles(root).nth(targetIndex);
|
||||||
|
|
||||||
|
await expect(sourceHandle).toBeVisible();
|
||||||
|
await expect(targetHandle).toBeVisible();
|
||||||
|
|
||||||
|
const sourceBox = await sourceHandle.boundingBox();
|
||||||
|
const targetBox = await targetHandle.boundingBox();
|
||||||
|
|
||||||
|
if (!sourceBox || !targetBox) {
|
||||||
|
throw new Error('Could not resolve drag handle bounding boxes');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceX = sourceBox.x + sourceBox.width / 2;
|
||||||
|
const sourceY = sourceBox.y + sourceBox.height / 2;
|
||||||
|
const targetX = targetBox.x + targetBox.width / 2;
|
||||||
|
const targetY = targetBox.y + targetBox.height / 2;
|
||||||
|
|
||||||
|
await root.mouse.move(sourceX, sourceY);
|
||||||
|
await root.mouse.down();
|
||||||
|
await root.mouse.move(targetX, targetY, { steps: 20 });
|
||||||
|
await root.mouse.up();
|
||||||
|
};
|
||||||
|
|
||||||
|
const runEnvelopeItemCrudFlow = async ({
|
||||||
|
root,
|
||||||
|
isEmbedded,
|
||||||
|
initialCount,
|
||||||
|
filesToUpload,
|
||||||
|
}: TEnvelopeEditorSurface & {
|
||||||
|
initialCount: number;
|
||||||
|
filesToUpload: TestFilePayload[];
|
||||||
|
}) => {
|
||||||
|
await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||||
|
|
||||||
|
await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(initialCount);
|
||||||
|
|
||||||
|
await uploadFiles(root, filesToUpload);
|
||||||
|
|
||||||
|
const expectedCountAfterUpload = initialCount + filesToUpload.length;
|
||||||
|
|
||||||
|
await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(expectedCountAfterUpload);
|
||||||
|
|
||||||
|
await getEnvelopeItemTitleInputs(root).nth(0).fill('Envelope Item A');
|
||||||
|
await getEnvelopeItemTitleInputs(root).nth(1).fill('Envelope Item B');
|
||||||
|
|
||||||
|
await expect(getEnvelopeItemTitleInputs(root).nth(0)).toHaveValue('Envelope Item A');
|
||||||
|
await expect(getEnvelopeItemTitleInputs(root).nth(1)).toHaveValue('Envelope Item B');
|
||||||
|
|
||||||
|
await dragEnvelopeItemByHandle({
|
||||||
|
root,
|
||||||
|
sourceIndex: 0,
|
||||||
|
targetIndex: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await getCurrentTitles(root))
|
||||||
|
.toEqual(['Envelope Item B', 'Envelope Item A']);
|
||||||
|
|
||||||
|
await getEnvelopeItemRemoveButtons(root).first().click();
|
||||||
|
|
||||||
|
if (!isEmbedded) {
|
||||||
|
await root.getByRole('button', { name: 'Delete' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(expectedCountAfterUpload - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('Envelope Editor V2 - Envelope item CRUD', () => {
|
||||||
|
test('documents/<id>: add, remove, reorder and retitle items', async ({ page }) => {
|
||||||
|
const surface = await openDocumentEnvelopeEditor(page);
|
||||||
|
|
||||||
|
await runEnvelopeItemCrudFlow({
|
||||||
|
...surface,
|
||||||
|
initialCount: 1,
|
||||||
|
filesToUpload: [createPdfPayload('document-item-added.pdf')],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('templates/<id>: add, remove, reorder and retitle items', async ({ page }) => {
|
||||||
|
const surface = await openTemplateEnvelopeEditor(page);
|
||||||
|
|
||||||
|
await runEnvelopeItemCrudFlow({
|
||||||
|
...surface,
|
||||||
|
initialCount: 1,
|
||||||
|
filesToUpload: [createPdfPayload('template-item-added.pdf')],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/embed/v2/authoring/envelope/create DOCUMENT: add, remove, reorder and retitle items', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||||
|
envelopeType: 'DOCUMENT',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runEnvelopeItemCrudFlow({
|
||||||
|
...surface,
|
||||||
|
initialCount: 0,
|
||||||
|
filesToUpload: [
|
||||||
|
createPdfPayload('embedded-document-item-a.pdf'),
|
||||||
|
createPdfPayload('embedded-document-item-b.pdf'),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/embed/v2/authoring/envelope/edit/<id> TEMPLATE: add, remove, reorder and retitle items', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||||
|
envelopeType: 'TEMPLATE',
|
||||||
|
mode: 'edit',
|
||||||
|
tokenNamePrefix: 'e2e-embed-items',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runEnvelopeItemCrudFlow({
|
||||||
|
...surface,
|
||||||
|
initialCount: 1,
|
||||||
|
filesToUpload: [createPdfPayload('embedded-template-item-updated.pdf')],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { type Page, expect, test } from '@playwright/test';
|
||||||
|
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
||||||
|
|
||||||
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type TEnvelopeEditorSurface,
|
||||||
|
addEnvelopeItemPdf,
|
||||||
|
assertRecipientRole,
|
||||||
|
clickAddMyselfButton,
|
||||||
|
clickAddSignerButton,
|
||||||
|
clickEnvelopeEditorStep,
|
||||||
|
getEnvelopeEditorSettingsTrigger,
|
||||||
|
getRecipientEmailInputs,
|
||||||
|
getRecipientNameInputs,
|
||||||
|
getRecipientRemoveButtons,
|
||||||
|
getSigningOrderInputs,
|
||||||
|
openDocumentEnvelopeEditor,
|
||||||
|
openEmbeddedEnvelopeEditor,
|
||||||
|
openTemplateEnvelopeEditor,
|
||||||
|
persistEmbeddedEnvelope,
|
||||||
|
setRecipientEmail,
|
||||||
|
setRecipientName,
|
||||||
|
setRecipientRole,
|
||||||
|
setSigningOrderValue,
|
||||||
|
toggleAllowDictateSigners,
|
||||||
|
toggleSigningOrder,
|
||||||
|
} from '../fixtures/envelope-editor';
|
||||||
|
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||||
|
|
||||||
|
type RecipientFlowResult = {
|
||||||
|
externalId: string;
|
||||||
|
expectedRecipientsBySigningOrder: Array<{
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: RecipientRole;
|
||||||
|
signingOrder: number;
|
||||||
|
}>;
|
||||||
|
removedRecipientEmail: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEST_RECIPIENT_VALUES = {
|
||||||
|
secondRecipient: {
|
||||||
|
email: 'recipient-two@example.com',
|
||||||
|
name: 'Recipient Two',
|
||||||
|
},
|
||||||
|
thirdRecipient: {
|
||||||
|
email: 'recipient-three@example.com',
|
||||||
|
name: 'Recipient Three',
|
||||||
|
},
|
||||||
|
embeddedPrimaryRecipient: {
|
||||||
|
email: 'embedded-primary@example.com',
|
||||||
|
name: 'Embedded Primary',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSettingsDialog = async (root: Page) => {
|
||||||
|
await getEnvelopeEditorSettingsTrigger(root).click();
|
||||||
|
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||||
|
await openSettingsDialog(surface.root);
|
||||||
|
await surface.root.locator('input[name="externalId"]').fill(externalId);
|
||||||
|
await surface.root.getByRole('button', { name: 'Update' }).click();
|
||||||
|
|
||||||
|
if (!surface.isEmbedded) {
|
||||||
|
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateToAddFieldsAndBack = async (root: Page) => {
|
||||||
|
await clickEnvelopeEditorStep(root, 'addFields');
|
||||||
|
await expect(root.getByText('Selected Recipient')).toBeVisible();
|
||||||
|
|
||||||
|
await clickEnvelopeEditorStep(root, 'upload');
|
||||||
|
await expect(root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||||
|
};
|
||||||
|
|
||||||
|
const runRecipientFlow = async (surface: TEnvelopeEditorSurface): Promise<RecipientFlowResult> => {
|
||||||
|
const externalId = `e2e-recipients-${nanoid()}`;
|
||||||
|
|
||||||
|
await updateExternalId(surface, externalId);
|
||||||
|
|
||||||
|
let primaryRecipient = TEST_RECIPIENT_VALUES.embeddedPrimaryRecipient;
|
||||||
|
|
||||||
|
if (surface.isEmbedded) {
|
||||||
|
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toHaveCount(0);
|
||||||
|
await setRecipientEmail(surface.root, 0, primaryRecipient.email);
|
||||||
|
await setRecipientName(surface.root, 0, primaryRecipient.name);
|
||||||
|
} else {
|
||||||
|
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toBeVisible();
|
||||||
|
await clickAddMyselfButton(surface.root);
|
||||||
|
|
||||||
|
primaryRecipient = {
|
||||||
|
email: surface.userEmail,
|
||||||
|
name: surface.userName,
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(surface.userEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
await clickAddSignerButton(surface.root);
|
||||||
|
await clickAddSignerButton(surface.root);
|
||||||
|
|
||||||
|
await setRecipientEmail(surface.root, 1, TEST_RECIPIENT_VALUES.secondRecipient.email);
|
||||||
|
await setRecipientName(surface.root, 1, TEST_RECIPIENT_VALUES.secondRecipient.name);
|
||||||
|
|
||||||
|
await setRecipientEmail(surface.root, 2, TEST_RECIPIENT_VALUES.thirdRecipient.email);
|
||||||
|
await setRecipientName(surface.root, 2, TEST_RECIPIENT_VALUES.thirdRecipient.name);
|
||||||
|
|
||||||
|
await setRecipientRole(surface.root, 1, 'Needs to approve');
|
||||||
|
await setRecipientRole(surface.root, 2, 'Receives copy');
|
||||||
|
|
||||||
|
await getRecipientRemoveButtons(surface.root).nth(2).click();
|
||||||
|
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
|
||||||
|
|
||||||
|
await toggleSigningOrder(surface.root, true);
|
||||||
|
await expect(getSigningOrderInputs(surface.root)).toHaveCount(2);
|
||||||
|
await setSigningOrderValue(surface.root, 0, 2);
|
||||||
|
|
||||||
|
await toggleAllowDictateSigners(surface.root, true);
|
||||||
|
|
||||||
|
await navigateToAddFieldsAndBack(surface.root);
|
||||||
|
|
||||||
|
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
|
||||||
|
await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(
|
||||||
|
TEST_RECIPIENT_VALUES.secondRecipient.email,
|
||||||
|
);
|
||||||
|
await expect(getRecipientEmailInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.email);
|
||||||
|
|
||||||
|
await expect(getRecipientNameInputs(surface.root).nth(0)).toHaveValue(
|
||||||
|
TEST_RECIPIENT_VALUES.secondRecipient.name,
|
||||||
|
);
|
||||||
|
await expect(getRecipientNameInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.name);
|
||||||
|
|
||||||
|
await assertRecipientRole(surface.root, 0, 'Needs to approve');
|
||||||
|
await assertRecipientRole(surface.root, 1, 'Needs to sign');
|
||||||
|
|
||||||
|
await expect(surface.root.locator('#signingOrder')).toHaveAttribute('aria-checked', 'true');
|
||||||
|
await expect(surface.root.locator('#allowDictateNextSigner')).toHaveAttribute(
|
||||||
|
'aria-checked',
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
await expect(getSigningOrderInputs(surface.root).nth(0)).toHaveValue('1');
|
||||||
|
await expect(getSigningOrderInputs(surface.root).nth(1)).toHaveValue('2');
|
||||||
|
|
||||||
|
return {
|
||||||
|
externalId,
|
||||||
|
removedRecipientEmail: TEST_RECIPIENT_VALUES.thirdRecipient.email,
|
||||||
|
expectedRecipientsBySigningOrder: [
|
||||||
|
{
|
||||||
|
email: TEST_RECIPIENT_VALUES.secondRecipient.email,
|
||||||
|
name: TEST_RECIPIENT_VALUES.secondRecipient.name,
|
||||||
|
role: RecipientRole.APPROVER,
|
||||||
|
signingOrder: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: primaryRecipient.email,
|
||||||
|
name: primaryRecipient.name,
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
signingOrder: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertRecipientsPersistedInDatabase = async ({
|
||||||
|
surface,
|
||||||
|
externalId,
|
||||||
|
expectedRecipientsBySigningOrder,
|
||||||
|
removedRecipientEmail,
|
||||||
|
}: {
|
||||||
|
surface: TEnvelopeEditorSurface;
|
||||||
|
externalId: string;
|
||||||
|
expectedRecipientsBySigningOrder: RecipientFlowResult['expectedRecipientsBySigningOrder'];
|
||||||
|
removedRecipientEmail: string;
|
||||||
|
}) => {
|
||||||
|
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
externalId,
|
||||||
|
userId: surface.userId,
|
||||||
|
teamId: surface.teamId,
|
||||||
|
type: surface.envelopeType,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
documentMeta: true,
|
||||||
|
recipients: {
|
||||||
|
orderBy: {
|
||||||
|
signingOrder: 'asc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(envelope.recipients).toHaveLength(expectedRecipientsBySigningOrder.length);
|
||||||
|
expect(envelope.documentMeta.signingOrder).toBe(DocumentSigningOrder.SEQUENTIAL);
|
||||||
|
expect(envelope.documentMeta.allowDictateNextSigner).toBe(true);
|
||||||
|
|
||||||
|
expectedRecipientsBySigningOrder.forEach((expectedRecipient, index) => {
|
||||||
|
const recipient = envelope.recipients[index];
|
||||||
|
|
||||||
|
expect(recipient.email).toBe(expectedRecipient.email);
|
||||||
|
expect(recipient.name).toBe(expectedRecipient.name);
|
||||||
|
expect(recipient.role).toBe(expectedRecipient.role);
|
||||||
|
expect(recipient.signingOrder).toBe(expectedRecipient.signingOrder);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(envelope.recipients.some((recipient) => recipient.email === removedRecipientEmail)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('Envelope Editor V2 - Recipients', () => {
|
||||||
|
test('documents/<id>: add myself, CRUD, roles, signing order and dictate signers', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const surface = await openDocumentEnvelopeEditor(page);
|
||||||
|
const result = await runRecipientFlow(surface);
|
||||||
|
|
||||||
|
await assertRecipientsPersistedInDatabase({
|
||||||
|
surface,
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('templates/<id>: add myself, CRUD, roles, signing order and dictate signers', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const surface = await openTemplateEnvelopeEditor(page);
|
||||||
|
const result = await runRecipientFlow(surface);
|
||||||
|
|
||||||
|
await assertRecipientsPersistedInDatabase({
|
||||||
|
surface,
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/embed/v2/authoring/envelope/create DOCUMENT: recipients settings persist after create', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||||
|
envelopeType: 'DOCUMENT',
|
||||||
|
tokenNamePrefix: 'e2e-embed-recipients',
|
||||||
|
});
|
||||||
|
|
||||||
|
await addEnvelopeItemPdf(surface.root, 'embedded-document-recipients.pdf');
|
||||||
|
|
||||||
|
const result = await runRecipientFlow(surface);
|
||||||
|
await persistEmbeddedEnvelope(surface);
|
||||||
|
|
||||||
|
await assertRecipientsPersistedInDatabase({
|
||||||
|
surface,
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/embed/v2/authoring/envelope/edit/<id> TEMPLATE: recipients settings persist after update', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||||
|
envelopeType: 'TEMPLATE',
|
||||||
|
mode: 'edit',
|
||||||
|
tokenNamePrefix: 'e2e-embed-recipients',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runRecipientFlow(surface);
|
||||||
|
await persistEmbeddedEnvelope(surface);
|
||||||
|
|
||||||
|
await assertRecipientsPersistedInDatabase({
|
||||||
|
surface,
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import { type Page, expect, test } from '@playwright/test';
|
||||||
|
import { DocumentDistributionMethod, DocumentVisibility } from '@prisma/client';
|
||||||
|
|
||||||
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type TEnvelopeEditorSurface,
|
||||||
|
getEnvelopeEditorSettingsTrigger,
|
||||||
|
openDocumentEnvelopeEditor,
|
||||||
|
openEmbeddedEnvelopeEditor,
|
||||||
|
openTemplateEnvelopeEditor,
|
||||||
|
persistEmbeddedEnvelope,
|
||||||
|
} from '../fixtures/envelope-editor';
|
||||||
|
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||||
|
|
||||||
|
type SettingsFlowData = {
|
||||||
|
externalId: string;
|
||||||
|
isEmbedded: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEST_SETTINGS_VALUES = {
|
||||||
|
replyTo: 'e2e-settings@example.com',
|
||||||
|
redirectUrl: 'https://example.com/e2e-settings-complete',
|
||||||
|
subject: 'E2E settings subject',
|
||||||
|
message: 'E2E settings message',
|
||||||
|
language: 'French',
|
||||||
|
dateFormat: 'DD/MM/YYYY',
|
||||||
|
timezone: 'Europe/London',
|
||||||
|
distributionMethod: 'None',
|
||||||
|
accessAuth: 'Require account',
|
||||||
|
actionAuth: 'Require password',
|
||||||
|
visibility: 'Managers and above',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DB_EXPECTED_VALUES = {
|
||||||
|
language: 'fr',
|
||||||
|
dateFormat: 'dd/MM/yyyy',
|
||||||
|
timezone: 'Europe/London',
|
||||||
|
distributionMethod: DocumentDistributionMethod.NONE,
|
||||||
|
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
|
||||||
|
globalAccessAuth: ['ACCOUNT'],
|
||||||
|
globalActionAuth: ['PASSWORD'],
|
||||||
|
emailSettings: {
|
||||||
|
recipientSigned: false,
|
||||||
|
recipientSigningRequest: false,
|
||||||
|
recipientRemoved: false,
|
||||||
|
documentPending: false,
|
||||||
|
documentCompleted: false,
|
||||||
|
documentDeleted: false,
|
||||||
|
ownerDocumentCompleted: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSettingsDialog = async (root: Page) => {
|
||||||
|
await getEnvelopeEditorSettingsTrigger(root).click();
|
||||||
|
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clickSettingsDialogHeader = async (root: Page) => {
|
||||||
|
await root.locator('[data-testid="envelope-editor-settings-dialog-header"]').click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getComboboxByLabel = (root: Page, label: string) =>
|
||||||
|
root
|
||||||
|
.locator(`label:has-text("${label}")`)
|
||||||
|
.locator('xpath=..')
|
||||||
|
.locator('[role="combobox"]')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const selectMultiSelectOption = async (
|
||||||
|
root: Page,
|
||||||
|
dataTestId: 'documentAccessSelectValue' | 'documentActionSelectValue',
|
||||||
|
optionLabel: string,
|
||||||
|
) => {
|
||||||
|
const select = root.locator(`[data-testid="${dataTestId}"]`);
|
||||||
|
|
||||||
|
await select.click();
|
||||||
|
await root.locator('[cmdk-item]').filter({ hasText: optionLabel }).first().click();
|
||||||
|
await clickSettingsDialogHeader(root);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runSettingsFlow = async (
|
||||||
|
{ root }: TEnvelopeEditorSurface,
|
||||||
|
{ externalId, isEmbedded }: SettingsFlowData,
|
||||||
|
) => {
|
||||||
|
await openSettingsDialog(root);
|
||||||
|
|
||||||
|
await getComboboxByLabel(root, 'Language').click();
|
||||||
|
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.language }).click();
|
||||||
|
await clickSettingsDialogHeader(root);
|
||||||
|
|
||||||
|
const signatureTypesCombobox = getComboboxByLabel(root, 'Allowed Signature Types');
|
||||||
|
|
||||||
|
await signatureTypesCombobox.click();
|
||||||
|
await root.getByRole('option', { name: 'Upload' }).click();
|
||||||
|
await clickSettingsDialogHeader(root);
|
||||||
|
|
||||||
|
await getComboboxByLabel(root, 'Date Format').click();
|
||||||
|
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.dateFormat, exact: true }).click();
|
||||||
|
await clickSettingsDialogHeader(root);
|
||||||
|
|
||||||
|
await getComboboxByLabel(root, 'Time Zone').click();
|
||||||
|
await root.locator('[cmdk-input]').last().fill(TEST_SETTINGS_VALUES.timezone);
|
||||||
|
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.timezone }).click();
|
||||||
|
await clickSettingsDialogHeader(root);
|
||||||
|
|
||||||
|
await root.locator('input[name="externalId"]').fill(externalId);
|
||||||
|
await root.locator('input[name="meta.redirectUrl"]').fill(TEST_SETTINGS_VALUES.redirectUrl);
|
||||||
|
|
||||||
|
await root.locator('[data-testid="documentDistributionMethodSelectValue"]').click();
|
||||||
|
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.distributionMethod }).click();
|
||||||
|
await clickSettingsDialogHeader(root);
|
||||||
|
|
||||||
|
await root.getByRole('button', { name: 'Email' }).click();
|
||||||
|
await root.locator('#recipientSigned').click();
|
||||||
|
await root.locator('#recipientSigningRequest').click();
|
||||||
|
await root.locator('#recipientRemoved').click();
|
||||||
|
await root.locator('#documentPending').click();
|
||||||
|
await root.locator('#documentCompleted').click();
|
||||||
|
await root.locator('#documentDeleted').click();
|
||||||
|
await root.locator('#ownerDocumentCompleted').click();
|
||||||
|
await root.locator('input[name="meta.emailReplyTo"]').fill(TEST_SETTINGS_VALUES.replyTo);
|
||||||
|
await root.locator('input[name="meta.subject"]').fill(TEST_SETTINGS_VALUES.subject);
|
||||||
|
await root.locator('textarea[name="meta.message"]').fill(TEST_SETTINGS_VALUES.message);
|
||||||
|
|
||||||
|
await root.getByRole('button', { name: 'Security' }).click();
|
||||||
|
await selectMultiSelectOption(root, 'documentAccessSelectValue', TEST_SETTINGS_VALUES.accessAuth);
|
||||||
|
|
||||||
|
const actionAuthSelect = root.locator('[data-testid="documentActionSelectValue"]');
|
||||||
|
const hasActionAuthSelect = (await actionAuthSelect.count()) > 0;
|
||||||
|
|
||||||
|
if (hasActionAuthSelect) {
|
||||||
|
await selectMultiSelectOption(
|
||||||
|
root,
|
||||||
|
'documentActionSelectValue',
|
||||||
|
TEST_SETTINGS_VALUES.actionAuth,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await root.locator('[data-testid="documentVisibilitySelectValue"]').click();
|
||||||
|
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.visibility }).click();
|
||||||
|
await clickSettingsDialogHeader(root);
|
||||||
|
|
||||||
|
await root.getByRole('button', { name: 'Update' }).click();
|
||||||
|
|
||||||
|
if (!isEmbedded) {
|
||||||
|
await expectToastTextToBeVisible(root, 'Envelope updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
await openSettingsDialog(root);
|
||||||
|
|
||||||
|
await expect(root.locator('input[name="externalId"]')).toHaveValue(externalId);
|
||||||
|
await expect(root.locator('input[name="meta.redirectUrl"]')).toHaveValue(
|
||||||
|
TEST_SETTINGS_VALUES.redirectUrl,
|
||||||
|
);
|
||||||
|
await expect(getComboboxByLabel(root, 'Language')).toContainText(TEST_SETTINGS_VALUES.language);
|
||||||
|
await expect(getComboboxByLabel(root, 'Allowed Signature Types')).not.toContainText('Upload');
|
||||||
|
await expect(getComboboxByLabel(root, 'Date Format')).toContainText(
|
||||||
|
TEST_SETTINGS_VALUES.dateFormat,
|
||||||
|
);
|
||||||
|
await expect(getComboboxByLabel(root, 'Time Zone')).toContainText(TEST_SETTINGS_VALUES.timezone);
|
||||||
|
await expect(root.locator('[data-testid="documentDistributionMethodSelectValue"]')).toContainText(
|
||||||
|
TEST_SETTINGS_VALUES.distributionMethod,
|
||||||
|
);
|
||||||
|
|
||||||
|
await root.getByRole('button', { name: 'Email' }).click();
|
||||||
|
await expect(root.locator('#recipientSigned')).toHaveAttribute('aria-checked', 'false');
|
||||||
|
await expect(root.locator('#recipientSigningRequest')).toHaveAttribute('aria-checked', 'false');
|
||||||
|
await expect(root.locator('#recipientRemoved')).toHaveAttribute('aria-checked', 'false');
|
||||||
|
await expect(root.locator('#documentPending')).toHaveAttribute('aria-checked', 'false');
|
||||||
|
await expect(root.locator('#documentCompleted')).toHaveAttribute('aria-checked', 'false');
|
||||||
|
await expect(root.locator('#documentDeleted')).toHaveAttribute('aria-checked', 'false');
|
||||||
|
await expect(root.locator('#ownerDocumentCompleted')).toHaveAttribute('aria-checked', 'false');
|
||||||
|
await expect(root.locator('input[name="meta.emailReplyTo"]')).toHaveValue(
|
||||||
|
TEST_SETTINGS_VALUES.replyTo,
|
||||||
|
);
|
||||||
|
await expect(root.locator('input[name="meta.subject"]')).toHaveValue(
|
||||||
|
TEST_SETTINGS_VALUES.subject,
|
||||||
|
);
|
||||||
|
await expect(root.locator('textarea[name="meta.message"]')).toHaveValue(
|
||||||
|
TEST_SETTINGS_VALUES.message,
|
||||||
|
);
|
||||||
|
|
||||||
|
await root.getByRole('button', { name: 'Security' }).click();
|
||||||
|
await expect(root.locator('[data-testid="documentAccessSelectValue"]')).toContainText(
|
||||||
|
TEST_SETTINGS_VALUES.accessAuth,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasActionAuthSelect) {
|
||||||
|
await expect(root.locator('[data-testid="documentActionSelectValue"]')).toContainText(
|
||||||
|
TEST_SETTINGS_VALUES.actionAuth,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(root.locator('[data-testid="documentVisibilitySelectValue"]')).toContainText(
|
||||||
|
TEST_SETTINGS_VALUES.visibility,
|
||||||
|
);
|
||||||
|
|
||||||
|
await root.getByRole('button', { name: 'Update' }).click();
|
||||||
|
|
||||||
|
if (!isEmbedded) {
|
||||||
|
await expectToastTextToBeVisible(root, 'Envelope updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasActionAuthSelect,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertEnvelopeSettingsPersistedInDatabase = async ({
|
||||||
|
externalId,
|
||||||
|
surface,
|
||||||
|
hasActionAuthSelect,
|
||||||
|
}: {
|
||||||
|
externalId: string;
|
||||||
|
surface: TEnvelopeEditorSurface;
|
||||||
|
hasActionAuthSelect: boolean;
|
||||||
|
}) => {
|
||||||
|
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
externalId,
|
||||||
|
userId: surface.userId,
|
||||||
|
teamId: surface.teamId,
|
||||||
|
type: surface.envelopeType,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
documentMeta: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(envelope.externalId).toBe(externalId);
|
||||||
|
expect(envelope.visibility).toBe(DB_EXPECTED_VALUES.visibility);
|
||||||
|
expect(envelope.documentMeta.language).toBe(DB_EXPECTED_VALUES.language);
|
||||||
|
expect(envelope.documentMeta.dateFormat).toBe(DB_EXPECTED_VALUES.dateFormat);
|
||||||
|
expect(envelope.documentMeta.timezone).toBe(DB_EXPECTED_VALUES.timezone);
|
||||||
|
expect(envelope.documentMeta.distributionMethod).toBe(DB_EXPECTED_VALUES.distributionMethod);
|
||||||
|
expect(envelope.documentMeta.redirectUrl).toBe(TEST_SETTINGS_VALUES.redirectUrl);
|
||||||
|
expect(envelope.documentMeta.emailReplyTo).toBe(TEST_SETTINGS_VALUES.replyTo);
|
||||||
|
expect(envelope.documentMeta.subject).toBe(TEST_SETTINGS_VALUES.subject);
|
||||||
|
expect(envelope.documentMeta.message).toBe(TEST_SETTINGS_VALUES.message);
|
||||||
|
expect(envelope.documentMeta.drawSignatureEnabled).toBe(true);
|
||||||
|
expect(envelope.documentMeta.typedSignatureEnabled).toBe(true);
|
||||||
|
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(false);
|
||||||
|
expect(envelope.documentMeta.emailSettings).toMatchObject(DB_EXPECTED_VALUES.emailSettings);
|
||||||
|
|
||||||
|
const authOptions = parseAuthOptions(envelope.authOptions);
|
||||||
|
|
||||||
|
expect(authOptions.globalAccessAuth ?? []).toEqual(DB_EXPECTED_VALUES.globalAccessAuth);
|
||||||
|
|
||||||
|
if (hasActionAuthSelect) {
|
||||||
|
expect(authOptions.globalActionAuth ?? []).toEqual(DB_EXPECTED_VALUES.globalActionAuth);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseAuthOptions = (
|
||||||
|
authOptions: unknown,
|
||||||
|
): { globalAccessAuth: string[]; globalActionAuth: string[] } => {
|
||||||
|
if (!isRecord(authOptions)) {
|
||||||
|
return {
|
||||||
|
globalAccessAuth: [],
|
||||||
|
globalActionAuth: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
globalAccessAuth: Array.isArray(authOptions.globalAccessAuth)
|
||||||
|
? authOptions.globalAccessAuth.filter((entry): entry is string => typeof entry === 'string')
|
||||||
|
: [],
|
||||||
|
globalActionAuth: Array.isArray(authOptions.globalActionAuth)
|
||||||
|
? authOptions.globalActionAuth.filter((entry): entry is string => typeof entry === 'string')
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === 'object' && value !== null;
|
||||||
|
|
||||||
|
test.describe('Envelope Editor V2 - Envelope settings dialog', () => {
|
||||||
|
test('documents/<id>: update and persist settings', async ({ page }) => {
|
||||||
|
const surface = await openDocumentEnvelopeEditor(page);
|
||||||
|
const externalId = `e2e-settings-${nanoid()}`;
|
||||||
|
|
||||||
|
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
|
||||||
|
externalId,
|
||||||
|
isEmbedded: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await assertEnvelopeSettingsPersistedInDatabase({
|
||||||
|
externalId,
|
||||||
|
surface,
|
||||||
|
hasActionAuthSelect,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('templates/<id>: update and persist settings', async ({ page }) => {
|
||||||
|
const surface = await openTemplateEnvelopeEditor(page);
|
||||||
|
const externalId = `e2e-settings-${nanoid()}`;
|
||||||
|
|
||||||
|
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
|
||||||
|
externalId,
|
||||||
|
isEmbedded: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await assertEnvelopeSettingsPersistedInDatabase({
|
||||||
|
externalId,
|
||||||
|
surface,
|
||||||
|
hasActionAuthSelect,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/embed/v2/authoring/envelope/create DOCUMENT: update and persist settings', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||||
|
envelopeType: 'DOCUMENT',
|
||||||
|
tokenNamePrefix: 'e2e-embed-settings',
|
||||||
|
});
|
||||||
|
const externalId = `e2e-settings-${nanoid()}`;
|
||||||
|
|
||||||
|
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
|
||||||
|
externalId,
|
||||||
|
isEmbedded: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await persistEmbeddedEnvelope(surface);
|
||||||
|
|
||||||
|
await assertEnvelopeSettingsPersistedInDatabase({
|
||||||
|
externalId,
|
||||||
|
surface,
|
||||||
|
hasActionAuthSelect,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/embed/v2/authoring/envelope/edit/<id> TEMPLATE: update and persist settings', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||||
|
envelopeType: 'TEMPLATE',
|
||||||
|
mode: 'edit',
|
||||||
|
tokenNamePrefix: 'e2e-embed-settings',
|
||||||
|
});
|
||||||
|
const externalId = `e2e-settings-${nanoid()}`;
|
||||||
|
|
||||||
|
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
|
||||||
|
externalId,
|
||||||
|
isEmbedded: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await persistEmbeddedEnvelope(surface);
|
||||||
|
|
||||||
|
await assertEnvelopeSettingsPersistedInDatabase({
|
||||||
|
externalId,
|
||||||
|
surface,
|
||||||
|
hasActionAuthSelect,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import { createCanvas } from '@napi-rs/canvas';
|
|
||||||
import type { TestInfo } from '@playwright/test';
|
import type { TestInfo } from '@playwright/test';
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
|
||||||
import pixelMatch from 'pixelmatch';
|
import pixelMatch from 'pixelmatch';
|
||||||
import { PNG } from 'pngjs';
|
import { PNG } from 'pngjs';
|
||||||
|
|
||||||
|
import { pdfToImages } from '@documenso/lib/server-only/ai/pdf-to-images';
|
||||||
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
|
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed';
|
import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed';
|
||||||
@@ -378,39 +377,14 @@ test.skip('download envelope images', async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function renderPdfToImage(pdfBytes: Uint8Array) {
|
async function renderPdfToImage(pdfBytes: Uint8Array) {
|
||||||
const loadingTask = pdfjsLib.getDocument({ data: pdfBytes });
|
|
||||||
const pdf = await loadingTask.promise;
|
|
||||||
|
|
||||||
// Increase for higher resolution
|
// Increase for higher resolution
|
||||||
const scale = 4;
|
const scale = 4;
|
||||||
|
|
||||||
return await Promise.all(
|
return (await pdfToImages(pdfBytes, { scale, imageFormat: 'png' })).map((image) => ({
|
||||||
Array.from({ length: pdf.numPages }, async (_, index) => {
|
image: image.image,
|
||||||
const page = await pdf.getPage(index + 1);
|
width: Math.floor(image.scaledWidth),
|
||||||
|
height: Math.floor(image.scaledHeight),
|
||||||
const viewport = page.getViewport({ scale });
|
}));
|
||||||
|
|
||||||
const canvas = createCanvas(viewport.width, viewport.height);
|
|
||||||
const canvasContext = canvas.getContext('2d');
|
|
||||||
canvasContext.imageSmoothingEnabled = false;
|
|
||||||
|
|
||||||
await page.render({
|
|
||||||
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
|
|
||||||
canvas,
|
|
||||||
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
|
|
||||||
canvasContext,
|
|
||||||
viewport,
|
|
||||||
}).promise;
|
|
||||||
|
|
||||||
return {
|
|
||||||
image: await canvas.encode('png'),
|
|
||||||
|
|
||||||
// Rounded down because the certificate page somehow gives dimensions with decimals
|
|
||||||
width: Math.floor(viewport.width),
|
|
||||||
height: Math.floor(viewport.height),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CompareSignedPdfWithImagesOptions = {
|
type CompareSignedPdfWithImagesOptions = {
|
||||||
|
|||||||
@@ -0,0 +1,429 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||||
|
import { DEFAULT_EMBEDDED_EDITOR_CONFIG } from '@documenso/lib/types/envelope-editor';
|
||||||
|
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { apiSignin } from './authentication';
|
||||||
|
|
||||||
|
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
|
||||||
|
|
||||||
|
export type TEnvelopeEditorSurface = {
|
||||||
|
root: Page;
|
||||||
|
isEmbedded: boolean;
|
||||||
|
envelopeId?: string;
|
||||||
|
envelopeType: TEnvelopeEditorType;
|
||||||
|
userId: number;
|
||||||
|
userEmail: string;
|
||||||
|
userName: string;
|
||||||
|
teamId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TEnvelopeEditorType = 'DOCUMENT' | 'TEMPLATE';
|
||||||
|
|
||||||
|
type TEmbeddedHashCommonOptions = {
|
||||||
|
externalId?: string;
|
||||||
|
features?: typeof DEFAULT_EMBEDDED_EDITOR_CONFIG;
|
||||||
|
css?: string;
|
||||||
|
cssVars?: Record<string, string>;
|
||||||
|
darkModeDisabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const encodeEmbeddedOptions = (options: Record<string, unknown>) => {
|
||||||
|
const encodedPayload = encodeURIComponent(JSON.stringify(options));
|
||||||
|
|
||||||
|
if (typeof btoa === 'function') {
|
||||||
|
return btoa(encodedPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(encodedPayload, 'utf8').toString('base64');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createEmbeddedEnvelopeCreateHash = ({
|
||||||
|
envelopeType,
|
||||||
|
externalId,
|
||||||
|
features = DEFAULT_EMBEDDED_EDITOR_CONFIG,
|
||||||
|
css,
|
||||||
|
cssVars,
|
||||||
|
darkModeDisabled,
|
||||||
|
}: { envelopeType: TEnvelopeEditorType } & TEmbeddedHashCommonOptions) => {
|
||||||
|
return encodeEmbeddedOptions({
|
||||||
|
externalId,
|
||||||
|
type: envelopeType,
|
||||||
|
features,
|
||||||
|
css,
|
||||||
|
cssVars,
|
||||||
|
darkModeDisabled,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createEmbeddedEnvelopeEditHash = ({
|
||||||
|
externalId,
|
||||||
|
features = DEFAULT_EMBEDDED_EDITOR_CONFIG,
|
||||||
|
css,
|
||||||
|
cssVars,
|
||||||
|
darkModeDisabled,
|
||||||
|
}: TEmbeddedHashCommonOptions) => {
|
||||||
|
return encodeEmbeddedOptions({
|
||||||
|
externalId,
|
||||||
|
features,
|
||||||
|
css,
|
||||||
|
cssVars,
|
||||||
|
darkModeDisabled,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const openDocumentEnvelopeEditor = async (page: Page): Promise<TEnvelopeEditorSurface> => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const document = await seedBlankDocument(user, team.id, {
|
||||||
|
internalVersion: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
root: page,
|
||||||
|
isEmbedded: false,
|
||||||
|
envelopeId: document.id,
|
||||||
|
envelopeType: 'DOCUMENT',
|
||||||
|
userId: user.id,
|
||||||
|
userEmail: user.email,
|
||||||
|
userName: user.name ?? '',
|
||||||
|
teamId: team.id,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const openTemplateEnvelopeEditor = async (page: Page): Promise<TEnvelopeEditorSurface> => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const template = await seedBlankTemplate(user, team.id, {
|
||||||
|
createTemplateOptions: {
|
||||||
|
title: `E2E Template ${Date.now()}`,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
internalVersion: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/t/${team.url}/templates/${template.id}/edit?step=uploadAndRecipients`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
root: page,
|
||||||
|
isEmbedded: false,
|
||||||
|
envelopeId: template.id,
|
||||||
|
envelopeType: 'TEMPLATE',
|
||||||
|
userId: user.id,
|
||||||
|
userEmail: user.email,
|
||||||
|
userName: user.name ?? '',
|
||||||
|
teamId: team.id,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenEmbeddedEnvelopeEditorOptions = {
|
||||||
|
envelopeType: TEnvelopeEditorType;
|
||||||
|
mode?: 'create' | 'edit';
|
||||||
|
tokenNamePrefix?: string;
|
||||||
|
externalId?: string;
|
||||||
|
features?: typeof DEFAULT_EMBEDDED_EDITOR_CONFIG;
|
||||||
|
css?: string;
|
||||||
|
cssVars?: Record<string, string>;
|
||||||
|
darkModeDisabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const openEmbeddedEnvelopeEditor = async (
|
||||||
|
page: Page,
|
||||||
|
{
|
||||||
|
envelopeType,
|
||||||
|
mode = 'create',
|
||||||
|
tokenNamePrefix = 'e2e-embed',
|
||||||
|
externalId,
|
||||||
|
features,
|
||||||
|
css,
|
||||||
|
cssVars,
|
||||||
|
darkModeDisabled,
|
||||||
|
}: OpenEmbeddedEnvelopeEditorOptions,
|
||||||
|
): Promise<TEnvelopeEditorSurface> => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const envelopeToEdit =
|
||||||
|
mode === 'edit'
|
||||||
|
? envelopeType === 'DOCUMENT'
|
||||||
|
? await seedBlankDocument(user, team.id, {
|
||||||
|
internalVersion: 2,
|
||||||
|
})
|
||||||
|
: await seedBlankTemplate(user, team.id, {
|
||||||
|
createTemplateOptions: {
|
||||||
|
title: `E2E Template ${Date.now()}`,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
internalVersion: 2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const { token } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
tokenName: `${tokenNamePrefix}-${envelopeType.toLowerCase()}`,
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const embeddedToken = await resolveEmbeddingToken(
|
||||||
|
page,
|
||||||
|
token,
|
||||||
|
envelopeToEdit ? `envelopeId:${envelopeToEdit.id}` : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (envelopeToEdit) {
|
||||||
|
const hash = createEmbeddedEnvelopeEditHash({
|
||||||
|
externalId,
|
||||||
|
features: features ?? DEFAULT_EMBEDDED_EDITOR_CONFIG,
|
||||||
|
css,
|
||||||
|
cssVars,
|
||||||
|
darkModeDisabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(
|
||||||
|
`/embed/v2/authoring/envelope/edit/${envelopeToEdit.id}?token=${encodeURIComponent(embeddedToken)}#${hash}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const hash = createEmbeddedEnvelopeCreateHash({
|
||||||
|
envelopeType,
|
||||||
|
externalId,
|
||||||
|
features,
|
||||||
|
css,
|
||||||
|
cssVars,
|
||||||
|
darkModeDisabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(
|
||||||
|
`/embed/v2/authoring/envelope/create?token=${encodeURIComponent(embeddedToken)}#${hash}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||||
|
|
||||||
|
return {
|
||||||
|
root: page,
|
||||||
|
isEmbedded: true,
|
||||||
|
envelopeId: envelopeToEdit?.id,
|
||||||
|
envelopeType,
|
||||||
|
userId: user.id,
|
||||||
|
userEmail: user.email,
|
||||||
|
userName: user.name ?? '',
|
||||||
|
teamId: team.id,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEnvelopeEditorSettingsTrigger = (root: Page) =>
|
||||||
|
root.locator('button[title="Settings"]');
|
||||||
|
|
||||||
|
export const getEnvelopeItemTitleInputs = (root: Page) =>
|
||||||
|
root.locator('[data-testid^="envelope-item-title-input-"]');
|
||||||
|
|
||||||
|
export const getEnvelopeItemDragHandles = (root: Page) =>
|
||||||
|
root.locator('[data-testid^="envelope-item-drag-handle-"]');
|
||||||
|
|
||||||
|
export const getEnvelopeItemRemoveButtons = (root: Page) =>
|
||||||
|
root.locator('[data-testid^="envelope-item-remove-button-"]');
|
||||||
|
|
||||||
|
export const getEnvelopeItemDropzoneInput = (root: Page) =>
|
||||||
|
root.locator('[data-testid="envelope-item-dropzone"] input[type="file"]');
|
||||||
|
|
||||||
|
export const addEnvelopeItemPdf = async (root: Page, fileName = 'embedded-envelope-item.pdf') => {
|
||||||
|
await getEnvelopeItemDropzoneInput(root).setInputFiles({
|
||||||
|
name: fileName,
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: examplePdfBuffer,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRecipientEmailInputs = (root: Page) =>
|
||||||
|
root.locator('[data-testid="signer-email-input"]');
|
||||||
|
|
||||||
|
export const getRecipientNameInputs = (root: Page) =>
|
||||||
|
root.locator('input[placeholder^="Recipient "]');
|
||||||
|
|
||||||
|
export const getRecipientRows = (root: Page) =>
|
||||||
|
root.locator('[data-testid="signer-email-input"]').locator('xpath=ancestor::fieldset[1]');
|
||||||
|
|
||||||
|
export const getRecipientRemoveButtons = (root: Page) =>
|
||||||
|
root.locator('[data-testid="remove-signer-button"]');
|
||||||
|
|
||||||
|
export const getSigningOrderInputs = (root: Page) =>
|
||||||
|
root.locator('[data-testid="signing-order-input"]');
|
||||||
|
|
||||||
|
export const clickEnvelopeEditorStep = async (
|
||||||
|
root: Page,
|
||||||
|
stepId: 'upload' | 'addFields' | 'preview',
|
||||||
|
) => {
|
||||||
|
await root.waitForTimeout(200);
|
||||||
|
await root.locator(`[data-testid="envelope-editor-step-${stepId}"]`).first().click();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clickAddMyselfButton = async (root: Page) => {
|
||||||
|
await root.getByRole('button', { name: 'Add Myself' }).click();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clickAddSignerButton = async (root: Page) => {
|
||||||
|
await root.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setRecipientEmail = async (root: Page, index: number, email: string) => {
|
||||||
|
await getRecipientEmailInputs(root).nth(index).fill(email);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setRecipientName = async (root: Page, index: number, name: string) => {
|
||||||
|
await getRecipientNameInputs(root).nth(index).fill(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setRecipientRole = async (
|
||||||
|
root: Page,
|
||||||
|
index: number,
|
||||||
|
roleLabel:
|
||||||
|
| 'Needs to sign'
|
||||||
|
| 'Needs to approve'
|
||||||
|
| 'Needs to view'
|
||||||
|
| 'Receives copy'
|
||||||
|
| 'Can prepare',
|
||||||
|
) => {
|
||||||
|
const row = getRecipientRows(root).nth(index);
|
||||||
|
|
||||||
|
await row.locator('button[role="combobox"]').first().click();
|
||||||
|
await root.getByRole('option', { name: roleLabel }).click();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assertRecipientRole = async (
|
||||||
|
root: Page,
|
||||||
|
index: number,
|
||||||
|
roleLabel:
|
||||||
|
| 'Needs to sign'
|
||||||
|
| 'Needs to approve'
|
||||||
|
| 'Needs to view'
|
||||||
|
| 'Receives copy'
|
||||||
|
| 'Can prepare',
|
||||||
|
) => {
|
||||||
|
const row = getRecipientRows(root).nth(index);
|
||||||
|
const roleValueByLabel: Record<typeof roleLabel, string> = {
|
||||||
|
'Needs to sign': 'SIGNER',
|
||||||
|
'Needs to approve': 'APPROVER',
|
||||||
|
'Needs to view': 'VIEWER',
|
||||||
|
'Receives copy': 'CC',
|
||||||
|
'Can prepare': 'ASSISTANT',
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(row.locator('button[role="combobox"]').first()).toHaveAttribute(
|
||||||
|
'title',
|
||||||
|
roleValueByLabel[roleLabel],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleSigningOrder = async (root: Page, enabled: boolean) => {
|
||||||
|
const checkbox = root.locator('#signingOrder');
|
||||||
|
const currentState = await checkbox.getAttribute('aria-checked');
|
||||||
|
const isEnabled = currentState === 'true';
|
||||||
|
|
||||||
|
if (isEnabled !== enabled) {
|
||||||
|
await checkbox.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleAllowDictateSigners = async (root: Page, enabled: boolean) => {
|
||||||
|
const checkbox = root.locator('#allowDictateNextSigner');
|
||||||
|
const currentState = await checkbox.getAttribute('aria-checked');
|
||||||
|
const isEnabled = currentState === 'true';
|
||||||
|
|
||||||
|
if (isEnabled !== enabled) {
|
||||||
|
await checkbox.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setSigningOrderValue = async (root: Page, index: number, value: number) => {
|
||||||
|
const input = getSigningOrderInputs(root).nth(index);
|
||||||
|
await input.fill(value.toString());
|
||||||
|
await input.blur();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const persistEmbeddedEnvelope = async (surface: TEnvelopeEditorSurface) => {
|
||||||
|
if (!surface.isEmbedded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUpdateFlow =
|
||||||
|
(await surface.root.getByRole('button', { name: 'Update Document' }).count()) > 0 ||
|
||||||
|
(await surface.root.getByRole('button', { name: 'Update Template' }).count()) > 0;
|
||||||
|
|
||||||
|
const actionButtonName = isUpdateFlow
|
||||||
|
? surface.envelopeType === 'DOCUMENT'
|
||||||
|
? 'Update Document'
|
||||||
|
: 'Update Template'
|
||||||
|
: surface.envelopeType === 'DOCUMENT'
|
||||||
|
? 'Create Document'
|
||||||
|
: 'Create Template';
|
||||||
|
|
||||||
|
await surface.root.getByRole('button', { name: actionButtonName }).click();
|
||||||
|
|
||||||
|
const completionHeading = isUpdateFlow
|
||||||
|
? surface.envelopeType === 'DOCUMENT'
|
||||||
|
? 'Document Updated'
|
||||||
|
: 'Template Updated'
|
||||||
|
: surface.envelopeType === 'DOCUMENT'
|
||||||
|
? 'Document Created'
|
||||||
|
: 'Template Created';
|
||||||
|
|
||||||
|
await expect(surface.root.getByRole('heading', { name: completionHeading })).toBeVisible();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveEmbeddingToken = async (
|
||||||
|
page: Page,
|
||||||
|
inputToken: string,
|
||||||
|
scope?: string,
|
||||||
|
): Promise<string> => {
|
||||||
|
if (!inputToken.startsWith('api_')) {
|
||||||
|
return inputToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await page
|
||||||
|
.context()
|
||||||
|
.request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2/embedding/create-presign-token`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${inputToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: scope ? { scope } : {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok()) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Failed to exchange API token (${response.status()}): ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
|
||||||
|
if (typeof data !== 'object' || data === null || !('token' in data)) {
|
||||||
|
throw new Error(`Unexpected response shape: ${JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = data.token;
|
||||||
|
|
||||||
|
if (typeof token !== 'string' || token.length === 0) {
|
||||||
|
throw new Error(`Unexpected response shape: ${JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
};
|
||||||
@@ -42,21 +42,21 @@ const completeTemplateFlowWithDuplicateRecipients = async (options: {
|
|||||||
// Step 3: Add fields for each recipient instance
|
// Step 3: Add fields for each recipient instance
|
||||||
// Add signature field for first instance
|
// Add signature field for first instance
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||||
|
|
||||||
// Switch to second instance and add their field
|
// Switch to second instance and add their field
|
||||||
await page.getByRole('combobox').first().click();
|
await page.getByRole('combobox').first().click();
|
||||||
await page.getByText('Second Instance').first().click();
|
await page.getByText('Second Instance').first().click();
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||||
|
|
||||||
// Switch to different recipient and add their fields
|
// Switch to different recipient and add their fields
|
||||||
await page.getByRole('combobox').first().click();
|
await page.getByRole('combobox').first().click();
|
||||||
await page.getByText('Different Recipient').first().click();
|
await page.getByText('Different Recipient').first().click();
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 100 } });
|
||||||
await page.getByRole('button', { name: 'Name' }).click();
|
await page.getByRole('button', { name: 'Name' }).click();
|
||||||
await page.locator('canvas').click({ position: { x: 300, y: 150 } });
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 150 } });
|
||||||
|
|
||||||
// Save template
|
// Save template
|
||||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||||
@@ -209,17 +209,17 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
|
|||||||
|
|
||||||
// Add fields for each recipient
|
// Add fields for each recipient
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||||
|
|
||||||
await page.getByRole('combobox').first().click();
|
await page.getByRole('combobox').first().click();
|
||||||
await page.getByText('Duplicate Recipient 2').first().click();
|
await page.getByText('Duplicate Recipient 2').first().click();
|
||||||
await page.getByRole('button', { name: 'Date' }).click();
|
await page.getByRole('button', { name: 'Date' }).click();
|
||||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||||
|
|
||||||
await page.getByRole('combobox').first().click();
|
await page.getByRole('combobox').first().click();
|
||||||
await page.getByText('Different Recipient').first().click();
|
await page.getByText('Different Recipient').first().click();
|
||||||
await page.getByRole('button', { name: 'Name' }).click();
|
await page.getByRole('button', { name: 'Name' }).click();
|
||||||
await page.locator('canvas').click({ position: { x: 100, y: 200 } });
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 200 } });
|
||||||
|
|
||||||
// Save template
|
// Save template
|
||||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||||
@@ -272,7 +272,7 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
|
|||||||
await page.getByRole('combobox').first().click();
|
await page.getByRole('combobox').first().click();
|
||||||
await page.getByRole('option', { name: 'First Instance' }).first().click();
|
await page.getByRole('option', { name: 'First Instance' }).first().click();
|
||||||
await page.getByRole('button', { name: 'Name' }).click();
|
await page.getByRole('button', { name: 'Name' }).click();
|
||||||
await page.locator('canvas').click({ position: { x: 100, y: 300 } });
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 300 } });
|
||||||
|
|
||||||
await page.waitForTimeout(2500);
|
await page.waitForTimeout(2500);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||||
@@ -47,7 +48,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 100,
|
y: 100,
|
||||||
@@ -55,7 +56,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Text' }).click();
|
await page.getByRole('button', { name: 'Text' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 200,
|
y: 200,
|
||||||
@@ -75,7 +76,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 500,
|
y: 500,
|
||||||
@@ -110,7 +111,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 100,
|
y: 100,
|
||||||
@@ -118,7 +119,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Text' }).click();
|
await page.getByRole('button', { name: 'Text' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 200,
|
y: 200,
|
||||||
@@ -138,7 +139,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 500,
|
y: 500,
|
||||||
@@ -179,7 +180,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 100,
|
y: 100,
|
||||||
@@ -187,7 +188,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Text' }).click();
|
await page.getByRole('button', { name: 'Text' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 200,
|
y: 200,
|
||||||
@@ -207,7 +208,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 500,
|
y: 500,
|
||||||
@@ -250,7 +251,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 100,
|
y: 100,
|
||||||
@@ -258,7 +259,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Text' }).click();
|
await page.getByRole('button', { name: 'Text' }).click();
|
||||||
await page.locator('canvas').click({
|
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||||
position: {
|
position: {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 200,
|
y: 200,
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ export const useLimits = () => {
|
|||||||
|
|
||||||
export type LimitsProviderProps = {
|
export type LimitsProviderProps = {
|
||||||
initialValue?: TLimitsResponseSchema;
|
initialValue?: TLimitsResponseSchema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bypass limits for embed authoring. This is just client side bypass since
|
||||||
|
* all embeds should be paid plans.
|
||||||
|
*/
|
||||||
|
bypassLimits?: boolean;
|
||||||
teamId: number;
|
teamId: number;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
};
|
};
|
||||||
@@ -32,12 +38,17 @@ export const LimitsProvider = ({
|
|||||||
remaining: FREE_PLAN_LIMITS,
|
remaining: FREE_PLAN_LIMITS,
|
||||||
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
||||||
},
|
},
|
||||||
|
bypassLimits,
|
||||||
teamId,
|
teamId,
|
||||||
children,
|
children,
|
||||||
}: LimitsProviderProps) => {
|
}: LimitsProviderProps) => {
|
||||||
const [limits, setLimits] = useState(() => initialValue);
|
const [limits, setLimits] = useState(() => initialValue);
|
||||||
|
|
||||||
const refreshLimits = useCallback(async () => {
|
const refreshLimits = useCallback(async () => {
|
||||||
|
if (bypassLimits) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const newLimits = await getLimits({ teamId });
|
const newLimits = await getLimits({ teamId });
|
||||||
|
|
||||||
setLimits((oldLimits) => {
|
setLimits((oldLimits) => {
|
||||||
@@ -54,6 +65,10 @@ export const LimitsProvider = ({
|
|||||||
}, [refreshLimits]);
|
}, [refreshLimits]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (bypassLimits) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const onFocus = () => {
|
const onFocus = () => {
|
||||||
void refreshLimits();
|
void refreshLimits();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const getBoundingClientRect = (element: HTMLElement) => {
|
export const getBoundingClientRect = (element: HTMLElement | Element) => {
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
|
|
||||||
const { width, height } = rect;
|
const { width, height } = rect;
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ export const useDocumentElement = () => {
|
|||||||
const target = event.target;
|
const target = event.target;
|
||||||
|
|
||||||
const $page =
|
const $page =
|
||||||
target.closest<HTMLElement>(pageSelector) ?? target.querySelector<HTMLElement>(pageSelector);
|
target.closest<HTMLElement>(pageSelector) ??
|
||||||
|
document
|
||||||
|
.elementsFromPoint(event.clientX, event.clientY)
|
||||||
|
.find((el) => el.matches(pageSelector));
|
||||||
|
|
||||||
if (!$page) {
|
if (!$page) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -6,11 +6,10 @@ import { FieldType } from '@prisma/client';
|
|||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
|
||||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
|
|
||||||
import type { TEnvelope } from '../../types/envelope';
|
|
||||||
|
|
||||||
export const ZLocalFieldSchema = z.object({
|
export const ZLocalFieldSchema = z.object({
|
||||||
// This is the actual ID of the field if created.
|
// This is the actual ID of the field if created.
|
||||||
id: z.number().optional(),
|
id: z.number().optional(),
|
||||||
@@ -37,7 +36,7 @@ const ZEditorFieldsFormSchema = z.object({
|
|||||||
export type TEditorFieldsFormSchema = z.infer<typeof ZEditorFieldsFormSchema>;
|
export type TEditorFieldsFormSchema = z.infer<typeof ZEditorFieldsFormSchema>;
|
||||||
|
|
||||||
type EditorFieldsProps = {
|
type EditorFieldsProps = {
|
||||||
envelope: TEnvelope;
|
envelope: TEditorEnvelope;
|
||||||
handleFieldsUpdate: (fields: TLocalField[]) => unknown;
|
handleFieldsUpdate: (fields: TLocalField[]) => unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,9 @@ import {
|
|||||||
ZRecipientActionAuthTypesSchema,
|
ZRecipientActionAuthTypesSchema,
|
||||||
ZRecipientAuthOptionsSchema,
|
ZRecipientAuthOptionsSchema,
|
||||||
} from '@documenso/lib/types/document-auth';
|
} from '@documenso/lib/types/document-auth';
|
||||||
|
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
|
||||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||||
|
|
||||||
import type { TEnvelope } from '../../types/envelope';
|
|
||||||
|
|
||||||
const LocalRecipientSchema = z.object({
|
const LocalRecipientSchema = z.object({
|
||||||
formId: z.string().min(1),
|
formId: z.string().min(1),
|
||||||
id: z.number().optional(),
|
id: z.number().optional(),
|
||||||
@@ -36,12 +35,12 @@ export const ZEditorRecipientsFormSchema = z.object({
|
|||||||
export type TEditorRecipientsFormSchema = z.infer<typeof ZEditorRecipientsFormSchema>;
|
export type TEditorRecipientsFormSchema = z.infer<typeof ZEditorRecipientsFormSchema>;
|
||||||
|
|
||||||
type EditorRecipientsProps = {
|
type EditorRecipientsProps = {
|
||||||
envelope: TEnvelope;
|
envelope: TEditorEnvelope;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResetFormOptions = {
|
type ResetFormOptions = {
|
||||||
recipients?: Recipient[];
|
recipients?: Recipient[];
|
||||||
documentMeta?: TEnvelope['documentMeta'];
|
documentMeta?: TEditorEnvelope['documentMeta'];
|
||||||
};
|
};
|
||||||
|
|
||||||
type UseEditorRecipientsResponse = {
|
type UseEditorRecipientsResponse = {
|
||||||
|
|||||||
@@ -1,46 +1,45 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import Konva from 'konva';
|
import Konva from 'konva';
|
||||||
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
|
|
||||||
import { usePageContext } from 'react-pdf';
|
import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
|
||||||
|
import { EAGER_LOAD_PAGE_COUNT, type PageRenderData } from '../providers/envelope-render-provider';
|
||||||
|
|
||||||
type RenderFunction = (props: { stage: Konva.Stage; pageLayer: Konva.Layer }) => void;
|
type RenderFunction = (props: { stage: Konva.Stage; pageLayer: Konva.Layer }) => void;
|
||||||
|
|
||||||
export function usePageRenderer(renderFunction: RenderFunction) {
|
export function usePageRenderer(renderFunction: RenderFunction, pageData: PageRenderData) {
|
||||||
const pageContext = usePageContext();
|
const { pageWidth, pageHeight, scale, imageUrl, pageNumber } = pageData;
|
||||||
|
|
||||||
if (!pageContext) {
|
|
||||||
throw new Error('Unable to find Page context.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { page, rotate, scale } = pageContext;
|
|
||||||
|
|
||||||
if (!page) {
|
|
||||||
throw new Error('Attempted to render page canvas, but no page was specified.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const canvasElement = useRef<HTMLCanvasElement>(null);
|
|
||||||
const konvaContainer = useRef<HTMLDivElement>(null);
|
const konvaContainer = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const stage = useRef<Konva.Stage | null>(null);
|
const stage = useRef<Konva.Stage | null>(null);
|
||||||
const pageLayer = useRef<Konva.Layer | null>(null);
|
const pageLayer = useRef<Konva.Layer | null>(null);
|
||||||
|
|
||||||
const [renderError, setRenderError] = useState<boolean>(false);
|
const [renderStatus, setRenderStatus] = useState<'loading' | 'loaded' | 'error'>('loading');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The raw viewport with no scaling. Basically the actual PDF size.
|
* The raw viewport with no scaling. Basically the actual PDF size.
|
||||||
*/
|
*/
|
||||||
const unscaledViewport = useMemo(
|
const unscaledViewport = useMemo(
|
||||||
() => page.getViewport({ scale: 1, rotation: rotate }),
|
() => ({
|
||||||
[page, rotate, scale],
|
scale: 1,
|
||||||
|
width: pageWidth,
|
||||||
|
height: pageHeight,
|
||||||
|
}),
|
||||||
|
[pageWidth, pageHeight],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The viewport scaled according to page width.
|
* The viewport scaled according to page width.
|
||||||
*/
|
*/
|
||||||
const scaledViewport = useMemo(
|
const scaledViewport = useMemo(
|
||||||
() => page.getViewport({ scale, rotation: rotate }),
|
() => ({
|
||||||
[page, rotate, scale],
|
scale,
|
||||||
|
width: pageWidth * scale,
|
||||||
|
height: pageHeight * scale,
|
||||||
|
}),
|
||||||
|
[pageWidth, pageHeight, scale],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,88 +47,77 @@ export function usePageRenderer(renderFunction: RenderFunction) {
|
|||||||
* in a higher resolution.
|
* in a higher resolution.
|
||||||
*/
|
*/
|
||||||
const renderViewport = useMemo(
|
const renderViewport = useMemo(
|
||||||
() => page.getViewport({ scale: scale * window.devicePixelRatio, rotation: rotate }),
|
() => ({
|
||||||
[page, rotate, scale],
|
scale: scale * window.devicePixelRatio,
|
||||||
|
width: pageWidth * scale * window.devicePixelRatio,
|
||||||
|
height: pageHeight * scale * window.devicePixelRatio,
|
||||||
|
}),
|
||||||
|
[pageWidth, pageHeight, scale],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the PDF and create the scaled Konva stage.
|
* The props for the image element which will render the page.
|
||||||
*/
|
*/
|
||||||
useEffect(
|
const imageProps = useMemo(
|
||||||
function drawPageOnCanvas() {
|
(): React.ImgHTMLAttributes<HTMLImageElement> & Record<string, unknown> & { alt: '' } => ({
|
||||||
if (!page) {
|
className: PDF_VIEWER_PAGE_CLASSNAME,
|
||||||
return;
|
width: `${Math.floor(scaledViewport.width)}px`,
|
||||||
}
|
height: `${Math.floor(scaledViewport.height)}px`,
|
||||||
|
alt: '',
|
||||||
const { current: canvas } = canvasElement;
|
onLoad: () => setRenderStatus('loaded'),
|
||||||
const { current: kContainer } = konvaContainer;
|
// Purposely not using lazy here since we can use the virtual list overscan to let us prerender images.
|
||||||
|
loading: pageNumber < EAGER_LOAD_PAGE_COUNT ? 'eager' : undefined,
|
||||||
if (!canvas || !kContainer) {
|
src: imageUrl,
|
||||||
return;
|
'data-page-number': pageNumber,
|
||||||
}
|
}),
|
||||||
|
[renderViewport, scaledViewport],
|
||||||
canvas.width = renderViewport.width;
|
|
||||||
canvas.height = renderViewport.height;
|
|
||||||
|
|
||||||
canvas.style.width = `${Math.floor(scaledViewport.width)}px`;
|
|
||||||
canvas.style.height = `${Math.floor(scaledViewport.height)}px`;
|
|
||||||
|
|
||||||
const renderContext: RenderParameters = {
|
|
||||||
canvas,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
|
||||||
viewport: renderViewport,
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancellable = page.render(renderContext);
|
|
||||||
const runningTask = cancellable;
|
|
||||||
|
|
||||||
cancellable.promise.catch(() => {
|
|
||||||
// Intentionally empty
|
|
||||||
});
|
|
||||||
|
|
||||||
void cancellable.promise.then(() => {
|
|
||||||
stage.current = new Konva.Stage({
|
|
||||||
container: kContainer,
|
|
||||||
width: scaledViewport.width,
|
|
||||||
height: scaledViewport.height,
|
|
||||||
scale: {
|
|
||||||
x: scale,
|
|
||||||
y: scale,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create the main layer for interactive elements.
|
|
||||||
pageLayer.current = new Konva.Layer();
|
|
||||||
|
|
||||||
stage.current.add(pageLayer.current);
|
|
||||||
|
|
||||||
renderFunction({
|
|
||||||
stage: stage.current,
|
|
||||||
pageLayer: pageLayer.current,
|
|
||||||
});
|
|
||||||
|
|
||||||
void document.fonts.ready.then(function () {
|
|
||||||
pageLayer.current?.batchDraw();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
runningTask.cancel();
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[page, scaledViewport],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { current: container } = konvaContainer;
|
||||||
|
|
||||||
|
if (renderStatus !== 'loaded' || !container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stage.current = new Konva.Stage({
|
||||||
|
container,
|
||||||
|
width: scaledViewport.width,
|
||||||
|
height: scaledViewport.height,
|
||||||
|
scale: {
|
||||||
|
x: scale,
|
||||||
|
y: scale,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the main layer for interactive elements.
|
||||||
|
pageLayer.current = new Konva.Layer();
|
||||||
|
|
||||||
|
stage.current.add(pageLayer.current);
|
||||||
|
|
||||||
|
renderFunction({
|
||||||
|
stage: stage.current,
|
||||||
|
pageLayer: pageLayer.current,
|
||||||
|
});
|
||||||
|
|
||||||
|
void document.fonts.ready.then(function () {
|
||||||
|
pageLayer.current?.batchDraw();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stage.current?.destroy();
|
||||||
|
stage.current = null;
|
||||||
|
};
|
||||||
|
}, [renderStatus, imageProps]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canvasElement,
|
|
||||||
konvaContainer,
|
konvaContainer,
|
||||||
|
imageProps,
|
||||||
stage,
|
stage,
|
||||||
pageLayer,
|
pageLayer,
|
||||||
unscaledViewport,
|
unscaledViewport,
|
||||||
scaledViewport,
|
scaledViewport,
|
||||||
pageContext,
|
renderStatus,
|
||||||
renderError,
|
setRenderStatus,
|
||||||
setRenderError,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
|
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { EnvelopeType } from '@prisma/client';
|
import { EnvelopeType, Prisma, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
|
import {
|
||||||
|
DEFAULT_EDITOR_CONFIG,
|
||||||
|
type EnvelopeEditorConfig,
|
||||||
|
type TEditorEnvelope,
|
||||||
|
} from '@documenso/lib/types/envelope-editor';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TSetEnvelopeFieldsResponse } from '@documenso/trpc/server/envelope-router/set-envelope-fields.types';
|
||||||
import type { TSetEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
|
import type { TSetEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
|
||||||
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
|
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
|
||||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||||
@@ -11,7 +19,6 @@ import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import type { TDocumentEmailSettings } from '../../types/document-email';
|
import type { TDocumentEmailSettings } from '../../types/document-email';
|
||||||
import type { TEnvelope } from '../../types/envelope';
|
|
||||||
import { formatDocumentsPath, formatTemplatesPath } from '../../utils/teams';
|
import { formatDocumentsPath, formatTemplatesPath } from '../../utils/teams';
|
||||||
import { useEditorFields } from '../hooks/use-editor-fields';
|
import { useEditorFields } from '../hooks/use-editor-fields';
|
||||||
import type { TLocalField } from '../hooks/use-editor-fields';
|
import type { TLocalField } from '../hooks/use-editor-fields';
|
||||||
@@ -38,14 +45,20 @@ export const useDebounceFunction = <Args extends unknown[]>(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
|
||||||
|
|
||||||
type UpdateEnvelopePayload = Pick<TUpdateEnvelopeRequest, 'data' | 'meta'>;
|
type UpdateEnvelopePayload = Pick<TUpdateEnvelopeRequest, 'data' | 'meta'>;
|
||||||
|
|
||||||
type EnvelopeEditorProviderValue = {
|
type EnvelopeEditorProviderValue = {
|
||||||
envelope: TEnvelope;
|
editorConfig: EnvelopeEditorConfig;
|
||||||
|
|
||||||
|
envelope: TEditorEnvelope;
|
||||||
|
|
||||||
|
isEmbedded: boolean;
|
||||||
isDocument: boolean;
|
isDocument: boolean;
|
||||||
isTemplate: boolean;
|
isTemplate: boolean;
|
||||||
setLocalEnvelope: (localEnvelope: Partial<TEnvelope>) => void;
|
|
||||||
|
|
||||||
|
setLocalEnvelope: (localEnvelope: Partial<TEditorEnvelope>) => void;
|
||||||
updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
|
updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
|
||||||
updateEnvelopeAsync: (envelopeUpdates: UpdateEnvelopePayload) => Promise<void>;
|
updateEnvelopeAsync: (envelopeUpdates: UpdateEnvelopePayload) => Promise<void>;
|
||||||
setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void;
|
setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void;
|
||||||
@@ -57,7 +70,7 @@ type EnvelopeEditorProviderValue = {
|
|||||||
editorRecipients: ReturnType<typeof useEditorRecipients>;
|
editorRecipients: ReturnType<typeof useEditorRecipients>;
|
||||||
|
|
||||||
isAutosaving: boolean;
|
isAutosaving: boolean;
|
||||||
flushAutosave: () => Promise<void>;
|
flushAutosave: () => Promise<TEditorEnvelope>;
|
||||||
autosaveError: boolean;
|
autosaveError: boolean;
|
||||||
|
|
||||||
relativePath: {
|
relativePath: {
|
||||||
@@ -68,12 +81,14 @@ type EnvelopeEditorProviderValue = {
|
|||||||
templateRootPath: string;
|
templateRootPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
navigateToStep: (step: EnvelopeEditorStep) => Promise<void>;
|
||||||
syncEnvelope: () => Promise<void>;
|
syncEnvelope: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface EnvelopeEditorProviderProps {
|
interface EnvelopeEditorProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
initialEnvelope: TEnvelope;
|
editorConfig?: EnvelopeEditorConfig;
|
||||||
|
initialEnvelope: TEditorEnvelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EnvelopeEditorContext = createContext<EnvelopeEditorProviderValue | null>(null);
|
const EnvelopeEditorContext = createContext<EnvelopeEditorProviderValue | null>(null);
|
||||||
@@ -90,14 +105,29 @@ export const useCurrentEnvelopeEditor = () => {
|
|||||||
|
|
||||||
export const EnvelopeEditorProvider = ({
|
export const EnvelopeEditorProvider = ({
|
||||||
children,
|
children,
|
||||||
|
editorConfig = DEFAULT_EDITOR_CONFIG,
|
||||||
initialEnvelope,
|
initialEnvelope,
|
||||||
}: EnvelopeEditorProviderProps) => {
|
}: EnvelopeEditorProviderProps) => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [envelope, setEnvelope] = useState(initialEnvelope);
|
const [_searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const [envelope, _setEnvelope] = useState(initialEnvelope);
|
||||||
const [autosaveError, setAutosaveError] = useState<boolean>(false);
|
const [autosaveError, setAutosaveError] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const envelopeRef = useRef(initialEnvelope);
|
||||||
|
|
||||||
|
const setEnvelope: typeof _setEnvelope = (action) => {
|
||||||
|
_setEnvelope((prev) => {
|
||||||
|
const next = typeof action === 'function' ? action(prev) : action;
|
||||||
|
envelopeRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEmbedded = editorConfig.embeded !== undefined;
|
||||||
|
|
||||||
const editorFields = useEditorFields({
|
const editorFields = useEditorFields({
|
||||||
envelope,
|
envelope,
|
||||||
handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
|
handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
|
||||||
@@ -107,61 +137,35 @@ export const EnvelopeEditorProvider = ({
|
|||||||
envelope,
|
envelope,
|
||||||
});
|
});
|
||||||
|
|
||||||
const envelopeUpdateMutationQuery = trpc.envelope.update.useMutation({
|
const setRecipientsMutation = trpc.envelope.recipient.set.useMutation();
|
||||||
onSuccess: (response, input) => {
|
const setFieldsMutation = trpc.envelope.field.set.useMutation();
|
||||||
setEnvelope({
|
const updateEnvelopeMutation = trpc.envelope.update.useMutation();
|
||||||
...envelope,
|
|
||||||
...response,
|
|
||||||
documentMeta: {
|
|
||||||
...envelope.documentMeta,
|
|
||||||
...input.meta,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
emailSettings: (input.meta?.emailSettings ||
|
|
||||||
null) as unknown as TDocumentEmailSettings | null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
setAutosaveError(false);
|
/**
|
||||||
},
|
* Handles debouncing the recipients updates to the server.
|
||||||
onError: (err) => {
|
*
|
||||||
console.error(err);
|
* Will set the local envelope recipients and fields after the update is complete.
|
||||||
|
*/
|
||||||
|
const {
|
||||||
|
triggerSave: setRecipientsDebounced,
|
||||||
|
flush: flushSetRecipients,
|
||||||
|
isPending: isRecipientsMutationPending,
|
||||||
|
} = useEnvelopeAutosave(async (localRecipients: TSetEnvelopeRecipientsRequest['recipients']) => {
|
||||||
|
try {
|
||||||
|
let recipients: TEditorEnvelope['recipients'] = [];
|
||||||
|
|
||||||
setAutosaveError(true);
|
if (!isEmbedded) {
|
||||||
|
const response = await setRecipientsMutation.mutateAsync({
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
envelopeType: envelope.type,
|
||||||
|
recipients: localRecipients,
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
recipients = response.data;
|
||||||
title: t`Save failed`,
|
} else {
|
||||||
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
|
recipients = mapLocalRecipientsToRecipients({ envelope, localRecipients });
|
||||||
variant: 'destructive',
|
}
|
||||||
duration: 7500,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const envelopeFieldSetMutationQuery = trpc.envelope.field.set.useMutation({
|
|
||||||
onSuccess: ({ data: fields }) => {
|
|
||||||
setEnvelope((prev) => ({
|
|
||||||
...prev,
|
|
||||||
fields,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setAutosaveError(false);
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
console.error(err);
|
|
||||||
|
|
||||||
setAutosaveError(true);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t`Save failed`,
|
|
||||||
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 7500,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
|
|
||||||
onSuccess: ({ data: recipients }) => {
|
|
||||||
setEnvelope((prev) => ({
|
setEnvelope((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
recipients,
|
recipients,
|
||||||
@@ -178,8 +182,7 @@ export const EnvelopeEditorProvider = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
setAutosaveError(false);
|
setAutosaveError(false);
|
||||||
},
|
} catch (err) {
|
||||||
onError: (err) => {
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
setAutosaveError(true);
|
setAutosaveError(true);
|
||||||
@@ -190,58 +193,137 @@ export const EnvelopeEditorProvider = ({
|
|||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 7500,
|
duration: 7500,
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
triggerSave: setRecipientsDebounced,
|
|
||||||
flush: setRecipientsAsync,
|
|
||||||
isPending: isRecipientsMutationPending,
|
|
||||||
} = useEnvelopeAutosave(async (recipients: TSetEnvelopeRecipientsRequest['recipients']) => {
|
|
||||||
await envelopeRecipientSetMutationQuery.mutateAsync({
|
|
||||||
envelopeId: envelope.id,
|
|
||||||
envelopeType: envelope.type,
|
|
||||||
recipients,
|
|
||||||
});
|
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
|
const setRecipientsAsync = async (
|
||||||
|
localRecipients: TSetEnvelopeRecipientsRequest['recipients'],
|
||||||
|
) => {
|
||||||
|
setRecipientsDebounced(localRecipients);
|
||||||
|
await flushSetRecipients();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles debouncing the fields updates to the server.
|
||||||
|
*
|
||||||
|
* Will set the local envelope fields after the update is complete.
|
||||||
|
*/
|
||||||
const {
|
const {
|
||||||
triggerSave: setFieldsDebounced,
|
triggerSave: setFieldsDebounced,
|
||||||
flush: setFieldsAsync,
|
flush: flushSetFields,
|
||||||
isPending: isFieldsMutationPending,
|
isPending: isFieldsMutationPending,
|
||||||
} = useEnvelopeAutosave(async (localFields: TLocalField[]) => {
|
} = useEnvelopeAutosave(async (localFields: TLocalField[]) => {
|
||||||
const envelopeFields = await envelopeFieldSetMutationQuery.mutateAsync({
|
try {
|
||||||
envelopeId: envelope.id,
|
let fields: TSetEnvelopeFieldsResponse['data'] = [];
|
||||||
envelopeType: envelope.type,
|
|
||||||
fields: localFields,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Insert the IDs into the local fields.
|
if (!isEmbedded) {
|
||||||
envelopeFields.data.forEach((field) => {
|
const response = await setFieldsMutation.mutateAsync({
|
||||||
const localField = localFields.find((localField) => localField.formId === field.formId);
|
envelopeId: envelope.id,
|
||||||
|
envelopeType: envelope.type,
|
||||||
|
fields: localFields,
|
||||||
|
});
|
||||||
|
|
||||||
if (localField && !localField.id) {
|
fields = response.data;
|
||||||
localField.id = field.id;
|
} else {
|
||||||
|
fields = mapLocalFieldsToFields({ envelope, localFields });
|
||||||
editorFields.setFieldId(localField.formId, field.id);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
setEnvelope((prev) => ({
|
||||||
|
...prev,
|
||||||
|
fields,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setAutosaveError(false);
|
||||||
|
|
||||||
|
// Insert the IDs into the local fields.
|
||||||
|
fields.forEach((field) => {
|
||||||
|
const localField = localFields.find((localField) => localField.formId === field.formId);
|
||||||
|
|
||||||
|
if (localField && !localField.id) {
|
||||||
|
localField.id = field.id;
|
||||||
|
|
||||||
|
editorFields.setFieldId(localField.formId, field.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
setAutosaveError(true);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Save failed`,
|
||||||
|
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 7500,
|
||||||
|
});
|
||||||
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
|
const setFieldsAsync = async (localFields: TLocalField[]) => {
|
||||||
|
setFieldsDebounced(localFields);
|
||||||
|
await flushSetFields();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles debouncing the envelope updates to the server.
|
||||||
|
*
|
||||||
|
* Will set the local envelope after the update is complete.
|
||||||
|
*/
|
||||||
const {
|
const {
|
||||||
triggerSave: setEnvelopeDebounced,
|
triggerSave: updateEnvelopeDebounced,
|
||||||
flush: setEnvelopeAsync,
|
flush: flushUpdateEnvelope,
|
||||||
isPending: isEnvelopeMutationPending,
|
isPending: isEnvelopeMutationPending,
|
||||||
} = useEnvelopeAutosave(async (envelopeUpdates: UpdateEnvelopePayload) => {
|
} = useEnvelopeAutosave(async ({ data, meta }: UpdateEnvelopePayload) => {
|
||||||
await envelopeUpdateMutationQuery.mutateAsync({
|
try {
|
||||||
envelopeId: envelope.id,
|
const response = !isEmbedded
|
||||||
data: envelopeUpdates.data,
|
? await updateEnvelopeMutation.mutateAsync({
|
||||||
meta: envelopeUpdates.meta,
|
envelopeId: envelope.id,
|
||||||
});
|
data,
|
||||||
|
meta,
|
||||||
|
})
|
||||||
|
: {};
|
||||||
|
|
||||||
|
setEnvelope((prev) => ({
|
||||||
|
...prev,
|
||||||
|
...data,
|
||||||
|
authOptions: {
|
||||||
|
globalAccessAuth: data?.globalAccessAuth || [],
|
||||||
|
globalActionAuth: data?.globalActionAuth || [],
|
||||||
|
},
|
||||||
|
...response,
|
||||||
|
documentMeta: {
|
||||||
|
...prev.documentMeta,
|
||||||
|
...meta,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
emailSettings: (meta?.emailSettings || null) as unknown as TDocumentEmailSettings | null,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
setAutosaveError(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
setAutosaveError(true);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Save failed`,
|
||||||
|
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 7500,
|
||||||
|
});
|
||||||
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
|
const updateEnvelopeAsync = async (envelopeUpdates: UpdateEnvelopePayload) => {
|
||||||
|
updateEnvelopeDebounced(envelopeUpdates);
|
||||||
|
await flushUpdateEnvelope();
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the local envelope and debounces the update to the server.
|
* Updates the local envelope and debounces the update to the server.
|
||||||
|
*
|
||||||
|
* Use this when you want to update the local envelope immediately while debouncing
|
||||||
|
* the actual update to the server.
|
||||||
*/
|
*/
|
||||||
const updateEnvelope = (envelopeUpdates: UpdateEnvelopePayload) => {
|
const updateEnvelope = (envelopeUpdates: UpdateEnvelopePayload) => {
|
||||||
setEnvelope((prev) => ({
|
setEnvelope((prev) => ({
|
||||||
@@ -253,14 +335,7 @@ export const EnvelopeEditorProvider = ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setEnvelopeDebounced(envelopeUpdates);
|
updateEnvelopeDebounced(envelopeUpdates);
|
||||||
};
|
|
||||||
|
|
||||||
const updateEnvelopeAsync = async (envelopeUpdates: UpdateEnvelopePayload) => {
|
|
||||||
await envelopeUpdateMutationQuery.mutateAsync({
|
|
||||||
envelopeId: envelope.id,
|
|
||||||
...envelopeUpdates,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRecipientColorKey = useCallback(
|
const getRecipientColorKey = useCallback(
|
||||||
@@ -276,12 +351,13 @@ export const EnvelopeEditorProvider = ({
|
|||||||
[envelope.recipients],
|
[envelope.recipients],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery(
|
const { refetch: reloadEnvelope } = trpc.envelope.get.useQuery(
|
||||||
{
|
{
|
||||||
envelopeId: envelope.id,
|
envelopeId: envelope.id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
initialData: envelope,
|
enabled: !isEmbedded,
|
||||||
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -293,6 +369,11 @@ export const EnvelopeEditorProvider = ({
|
|||||||
const syncEnvelope = async () => {
|
const syncEnvelope = async () => {
|
||||||
await flushAutosave();
|
await flushAutosave();
|
||||||
|
|
||||||
|
// Bypass syncing for embedded mode.
|
||||||
|
if (isEmbedded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const fetchedEnvelopeData = await reloadEnvelope();
|
const fetchedEnvelopeData = await reloadEnvelope();
|
||||||
|
|
||||||
if (fetchedEnvelopeData.data) {
|
if (fetchedEnvelopeData.data) {
|
||||||
@@ -302,55 +383,89 @@ export const EnvelopeEditorProvider = ({
|
|||||||
recipients: fetchedEnvelopeData.data.recipients,
|
recipients: fetchedEnvelopeData.data.recipients,
|
||||||
documentMeta: fetchedEnvelopeData.data.documentMeta,
|
documentMeta: fetchedEnvelopeData.data.documentMeta,
|
||||||
});
|
});
|
||||||
|
|
||||||
editorFields.resetForm(fetchedEnvelopeData.data.fields);
|
editorFields.resetForm(fetchedEnvelopeData.data.fields);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setLocalEnvelope = (localEnvelope: Partial<TEnvelope>) => {
|
const setLocalEnvelope = (localEnvelope: Partial<TEditorEnvelope>) => {
|
||||||
setEnvelope((prev) => ({ ...prev, ...localEnvelope }));
|
setEnvelope((prev) => ({ ...prev, ...localEnvelope }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAutosaving = useMemo(() => {
|
const isAutosaving = useMemo(() => {
|
||||||
return (
|
return isFieldsMutationPending || isRecipientsMutationPending || isEnvelopeMutationPending;
|
||||||
envelopeFieldSetMutationQuery.isPending ||
|
}, [isFieldsMutationPending, isRecipientsMutationPending, isEnvelopeMutationPending]);
|
||||||
envelopeRecipientSetMutationQuery.isPending ||
|
|
||||||
envelopeUpdateMutationQuery.isPending ||
|
|
||||||
isFieldsMutationPending ||
|
|
||||||
isRecipientsMutationPending ||
|
|
||||||
isEnvelopeMutationPending
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
envelopeFieldSetMutationQuery.isPending,
|
|
||||||
envelopeRecipientSetMutationQuery.isPending,
|
|
||||||
envelopeUpdateMutationQuery.isPending,
|
|
||||||
isFieldsMutationPending,
|
|
||||||
isRecipientsMutationPending,
|
|
||||||
isEnvelopeMutationPending,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const relativePath = useMemo(() => {
|
const relativePath = useMemo(() => {
|
||||||
const documentRootPath = formatDocumentsPath(envelope.team.url);
|
let documentRootPath = formatDocumentsPath(envelope.team.url);
|
||||||
const templateRootPath = formatTemplatesPath(envelope.team.url);
|
let templateRootPath = formatTemplatesPath(envelope.team.url);
|
||||||
|
|
||||||
const basePath = envelope.type === EnvelopeType.DOCUMENT ? documentRootPath : templateRootPath;
|
const basePath = envelope.type === EnvelopeType.DOCUMENT ? documentRootPath : templateRootPath;
|
||||||
|
let envelopePath = `${basePath}/${envelope.id}`;
|
||||||
|
let editorPath = `${basePath}/${envelope.id}/edit`;
|
||||||
|
|
||||||
|
if (editorConfig.embeded) {
|
||||||
|
let embeddedEditorPath =
|
||||||
|
editorConfig.embeded.mode === 'edit'
|
||||||
|
? `/embed/v2/authoring/envelope/edit/${envelope.id}`
|
||||||
|
: `/embed/v2/authoring/envelope/create`;
|
||||||
|
|
||||||
|
embeddedEditorPath += `?token=${editorConfig.embeded.presignToken}`;
|
||||||
|
|
||||||
|
// Todo: Embeds - This should be thought about more.
|
||||||
|
envelopePath = embeddedEditorPath;
|
||||||
|
editorPath = embeddedEditorPath;
|
||||||
|
documentRootPath = embeddedEditorPath;
|
||||||
|
templateRootPath = embeddedEditorPath;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
basePath,
|
basePath,
|
||||||
envelopePath: `${basePath}/${envelope.id}`,
|
envelopePath,
|
||||||
editorPath: `${basePath}/${envelope.id}/edit`,
|
editorPath,
|
||||||
documentRootPath,
|
documentRootPath,
|
||||||
templateRootPath,
|
templateRootPath,
|
||||||
};
|
};
|
||||||
}, [envelope.type, envelope.id]);
|
}, [envelope.type, envelope.id]);
|
||||||
|
|
||||||
const flushAutosave = async (): Promise<void> => {
|
const navigateToStep = async (step: EnvelopeEditorStep) => {
|
||||||
await Promise.all([setFieldsAsync(), setRecipientsAsync(), setEnvelopeAsync()]);
|
setSearchParams((prev) => {
|
||||||
|
const newParams = new URLSearchParams(prev);
|
||||||
|
|
||||||
|
if (step === 'upload') {
|
||||||
|
newParams.delete('step');
|
||||||
|
} else {
|
||||||
|
newParams.set('step', step);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newParams;
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushAutosave();
|
||||||
|
|
||||||
|
resetForms();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForms = () => {
|
||||||
|
editorRecipients.resetForm({
|
||||||
|
recipients: envelopeRef.current.recipients,
|
||||||
|
documentMeta: envelopeRef.current.documentMeta,
|
||||||
|
});
|
||||||
|
|
||||||
|
editorFields.resetForm(envelopeRef.current.fields);
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushAutosave = async (): Promise<TEditorEnvelope> => {
|
||||||
|
await Promise.all([flushSetFields(), flushSetRecipients(), flushUpdateEnvelope()]);
|
||||||
|
return envelopeRef.current;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EnvelopeEditorContext.Provider
|
<EnvelopeEditorContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
editorConfig,
|
||||||
envelope,
|
envelope,
|
||||||
|
isEmbedded,
|
||||||
isDocument: envelope.type === EnvelopeType.DOCUMENT,
|
isDocument: envelope.type === EnvelopeType.DOCUMENT,
|
||||||
isTemplate: envelope.type === EnvelopeType.TEMPLATE,
|
isTemplate: envelope.type === EnvelopeType.TEMPLATE,
|
||||||
setLocalEnvelope,
|
setLocalEnvelope,
|
||||||
@@ -366,9 +481,107 @@ export const EnvelopeEditorProvider = ({
|
|||||||
isAutosaving,
|
isAutosaving,
|
||||||
relativePath,
|
relativePath,
|
||||||
syncEnvelope,
|
syncEnvelope,
|
||||||
|
navigateToStep,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</EnvelopeEditorContext.Provider>
|
</EnvelopeEditorContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MapLocalRecipientsToRecipientsOptions = {
|
||||||
|
envelope: TEditorEnvelope;
|
||||||
|
localRecipients: TSetEnvelopeRecipientsRequest['recipients'];
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapLocalRecipientsToRecipients = ({
|
||||||
|
envelope,
|
||||||
|
localRecipients,
|
||||||
|
}: MapLocalRecipientsToRecipientsOptions): TEditorEnvelope['recipients'] => {
|
||||||
|
let smallestRecipientId = localRecipients.reduce((min, recipient) => {
|
||||||
|
if (recipient.id && recipient.id < min) {
|
||||||
|
return recipient.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return min;
|
||||||
|
}, -1);
|
||||||
|
|
||||||
|
return localRecipients.map((recipient) => {
|
||||||
|
const foundRecipient = envelope.recipients.find((recipient) => recipient.id === recipient.id);
|
||||||
|
|
||||||
|
let recipientId = recipient.id;
|
||||||
|
|
||||||
|
if (recipientId === undefined) {
|
||||||
|
recipientId = smallestRecipientId;
|
||||||
|
smallestRecipientId--;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: recipientId,
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
email: recipient.email,
|
||||||
|
name: recipient.name,
|
||||||
|
token: foundRecipient?.token || '',
|
||||||
|
documentDeletedAt: foundRecipient?.documentDeletedAt || null,
|
||||||
|
expired: foundRecipient?.expired || null,
|
||||||
|
signedAt: foundRecipient?.signedAt || null,
|
||||||
|
authOptions:
|
||||||
|
recipient.actionAuth.length > 0
|
||||||
|
? { actionAuth: recipient.actionAuth, accessAuth: [] }
|
||||||
|
: null,
|
||||||
|
signingOrder: recipient.signingOrder ?? null,
|
||||||
|
rejectionReason: foundRecipient?.rejectionReason || null,
|
||||||
|
role: recipient.role,
|
||||||
|
readStatus: foundRecipient?.readStatus || ReadStatus.NOT_OPENED,
|
||||||
|
signingStatus: foundRecipient?.signingStatus || SigningStatus.NOT_SIGNED,
|
||||||
|
sendStatus: foundRecipient?.sendStatus || SendStatus.NOT_SENT,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
type MapLocalFieldsToFieldsOptions = {
|
||||||
|
localFields: TLocalField[];
|
||||||
|
envelope: TEditorEnvelope;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapLocalFieldsToFields = ({
|
||||||
|
envelope,
|
||||||
|
localFields,
|
||||||
|
}: MapLocalFieldsToFieldsOptions): TSetEnvelopeFieldsResponse['data'] => {
|
||||||
|
let smallestFieldId = localFields.reduce((min, field) => {
|
||||||
|
if (field.id && field.id < min) {
|
||||||
|
return field.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return min;
|
||||||
|
}, -1);
|
||||||
|
|
||||||
|
return localFields.map((field) => {
|
||||||
|
const foundField = envelope.fields.find((envelopeField) => envelopeField.id === field.id);
|
||||||
|
|
||||||
|
let fieldId = field.id;
|
||||||
|
|
||||||
|
if (fieldId === undefined) {
|
||||||
|
fieldId = smallestFieldId;
|
||||||
|
smallestFieldId--;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
formId: field.formId,
|
||||||
|
id: fieldId,
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
envelopeItemId: field.envelopeItemId,
|
||||||
|
type: field.type,
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
positionX: new Prisma.Decimal(field.positionX),
|
||||||
|
positionY: new Prisma.Decimal(field.positionY),
|
||||||
|
width: new Prisma.Decimal(field.width),
|
||||||
|
height: new Prisma.Decimal(field.height),
|
||||||
|
secondaryId: foundField?.secondaryId || '',
|
||||||
|
inserted: foundField?.inserted || false,
|
||||||
|
customText: foundField?.customText || '',
|
||||||
|
fieldMeta: field.fieldMeta || null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,23 +1,48 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import type { Field, Recipient } from '@prisma/client';
|
import type { Field, Recipient } from '@prisma/client';
|
||||||
|
|
||||||
|
import { PDF_IMAGE_RENDER_SCALE } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
|
||||||
|
import type { TGetEnvelopeItemsMetaResponse } from '@documenso/remix/server/api/files/files.types';
|
||||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||||
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
|
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
|
||||||
|
|
||||||
|
import type { DocumentDataVersion } from '../../types/document-data';
|
||||||
import type { TEnvelope } from '../../types/envelope';
|
import type { TEnvelope } from '../../types/envelope';
|
||||||
import type { FieldRenderMode } from '../../universal/field-renderer/render-field';
|
import type { FieldRenderMode } from '../../universal/field-renderer/render-field';
|
||||||
import { getEnvelopeItemPdfUrl } from '../../utils/envelope-download';
|
import { getEnvelopeItemMetaUrl, getEnvelopeItemPageImageUrl } from '../../utils/envelope-images';
|
||||||
|
|
||||||
type FileData =
|
/**
|
||||||
| {
|
* Number of pages to load eagerly on initial render.
|
||||||
status: 'loading' | 'error';
|
* Pages beyond this threshold will be loaded lazily when they enter the viewport.
|
||||||
}
|
*/
|
||||||
| {
|
export const EAGER_LOAD_PAGE_COUNT = 5;
|
||||||
file: Uint8Array;
|
|
||||||
status: 'loaded';
|
export type PageRenderData = BasePageRenderData & {
|
||||||
};
|
scale: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BasePageRenderData = {
|
||||||
|
envelopeItemId: string;
|
||||||
|
documentDataId: string;
|
||||||
|
pageIndex: number;
|
||||||
|
pageNumber: number;
|
||||||
|
pageWidth: number;
|
||||||
|
pageHeight: number;
|
||||||
|
imageUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImageLoadingState = 'loading' | 'loaded' | 'error';
|
||||||
|
|
||||||
type EnvelopeRenderOverrideSettings = {
|
type EnvelopeRenderOverrideSettings = {
|
||||||
mode?: FieldRenderMode;
|
mode?: FieldRenderMode;
|
||||||
@@ -25,11 +50,19 @@ type EnvelopeRenderOverrideSettings = {
|
|||||||
showRecipientSigningStatus?: boolean;
|
showRecipientSigningStatus?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EnvelopeRenderItem = TEnvelope['envelopeItems'][number];
|
type EnvelopeRenderItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
order: number;
|
||||||
|
envelopeId: string;
|
||||||
|
data?: Uint8Array | null;
|
||||||
|
};
|
||||||
|
|
||||||
type EnvelopeRenderProviderValue = {
|
type EnvelopeRenderProviderValue = {
|
||||||
getPdfBuffer: (envelopeItemId: string) => FileData | null;
|
version: DocumentDataVersion;
|
||||||
envelopeItems: EnvelopeRenderItem[];
|
envelopeItems: EnvelopeRenderItem[];
|
||||||
|
envelopeItemsMeta: Record<string, BasePageRenderData[]>;
|
||||||
|
envelopeItemsMetaLoadingState: ImageLoadingState;
|
||||||
envelopeStatus: TEnvelope['status'];
|
envelopeStatus: TEnvelope['status'];
|
||||||
envelopeType: TEnvelope['type'];
|
envelopeType: TEnvelope['type'];
|
||||||
currentEnvelopeItem: EnvelopeRenderItem | null;
|
currentEnvelopeItem: EnvelopeRenderItem | null;
|
||||||
@@ -46,7 +79,17 @@ type EnvelopeRenderProviderValue = {
|
|||||||
interface EnvelopeRenderProviderProps {
|
interface EnvelopeRenderProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
||||||
envelope: Pick<TEnvelope, 'envelopeItems' | 'status' | 'type'>;
|
/**
|
||||||
|
* The envelope item version to render.
|
||||||
|
*/
|
||||||
|
version: DocumentDataVersion;
|
||||||
|
|
||||||
|
envelope: Pick<TEnvelope, 'id' | 'status' | 'type'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The envelope items to render.
|
||||||
|
*/
|
||||||
|
envelopeItems: EnvelopeRenderItem[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional fields which are passed down to renderers for custom rendering needs.
|
* Optional fields which are passed down to renderers for custom rendering needs.
|
||||||
@@ -70,6 +113,13 @@ interface EnvelopeRenderProviderProps {
|
|||||||
*/
|
*/
|
||||||
token: string | undefined;
|
token: string | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The presign token to access the envelope.
|
||||||
|
*
|
||||||
|
* If not provided, it will be assumed that the current user can access the document.
|
||||||
|
*/
|
||||||
|
presignToken?: string | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom override settings for generic page renderers.
|
* Custom override settings for generic page renderers.
|
||||||
*/
|
*/
|
||||||
@@ -89,81 +139,175 @@ export const useCurrentEnvelopeRender = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages fetching and storing PDF files to render on the client.
|
* Manages fetching the data required to render an envelope and it's items.
|
||||||
*/
|
*/
|
||||||
export const EnvelopeRenderProvider = ({
|
export const EnvelopeRenderProvider = ({
|
||||||
children,
|
children,
|
||||||
envelope,
|
envelope,
|
||||||
|
envelopeItems: envelopeItemsFromProps,
|
||||||
fields,
|
fields,
|
||||||
token,
|
token,
|
||||||
|
presignToken,
|
||||||
recipients = [],
|
recipients = [],
|
||||||
|
version,
|
||||||
overrideSettings,
|
overrideSettings,
|
||||||
}: EnvelopeRenderProviderProps) => {
|
}: EnvelopeRenderProviderProps) => {
|
||||||
// Indexed by documentDataId.
|
// Indexed by envelope item ID.
|
||||||
const [files, setFiles] = useState<Record<string, FileData>>({});
|
const [envelopeItemsMeta, setEnvelopeItemsMeta] = useState<Record<string, BasePageRenderData[]>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
const [currentItem, setCurrentItem] = useState<EnvelopeRenderItem | null>(null);
|
const [envelopeItemsMetaLoadingState, setEnvelopeItemsMetaLoadingState] =
|
||||||
|
useState<ImageLoadingState>('loading');
|
||||||
|
|
||||||
const [renderError, setRenderError] = useState<boolean>(false);
|
const [renderError, setRenderError] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Track the timestamp of the most recent fetch to prevent race conditions
|
||||||
|
const fetchStartedAtRef = useRef<number>(0);
|
||||||
|
|
||||||
const envelopeItems = useMemo(
|
const envelopeItems = useMemo(
|
||||||
() => envelope.envelopeItems.sort((a, b) => a.order - b.order),
|
() => envelopeItemsFromProps.sort((a, b) => a.order - b.order),
|
||||||
[envelope.envelopeItems],
|
[envelopeItemsFromProps],
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadEnvelopeItemPdfFile = async (envelopeItem: EnvelopeRenderItem) => {
|
const [currentItem, setCurrentItem] = useState<EnvelopeRenderItem | null>(
|
||||||
if (files[envelopeItem.id]?.status === 'loading') {
|
envelopeItems[0] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch metadata and preload initial images when the envelope or token changes.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchEnvelopeRenderData();
|
||||||
|
}, [envelope.id, envelopeItems.length, token, version]);
|
||||||
|
|
||||||
|
const fetchEnvelopeRenderData = async () => {
|
||||||
|
if (envelopeItems.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!files[envelopeItem.id]) {
|
// Render certain envelope items locally, such as embedded.
|
||||||
setFiles((prev) => ({
|
// No envelope ID means it's in embedded create mode.
|
||||||
...prev,
|
if (
|
||||||
[envelopeItem.id]: {
|
!envelope.id ||
|
||||||
status: 'loading',
|
envelopeItems.some(
|
||||||
},
|
(item) => item.id.startsWith(PRESIGNED_ENVELOPE_ITEM_ID_PREFIX) && item.data,
|
||||||
}));
|
)
|
||||||
|
) {
|
||||||
|
await handleLocalFileFetch();
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record when this fetch started to detect stale responses
|
||||||
|
const fetchStartedAt = Date.now();
|
||||||
|
fetchStartedAtRef.current = fetchStartedAt;
|
||||||
|
|
||||||
|
setEnvelopeItemsMetaLoadingState('loading');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const downloadUrl = getEnvelopeItemPdfUrl({
|
// Fetch metadata for all envelope items
|
||||||
type: 'view',
|
const metaUrl = getEnvelopeItemMetaUrl({
|
||||||
envelopeItem: envelopeItem,
|
envelopeId: envelope.id,
|
||||||
token,
|
token,
|
||||||
|
presignToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
|
const response = await fetch(metaUrl);
|
||||||
|
|
||||||
const file = await blob.arrayBuffer();
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch envelope meta: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
setFiles((prev) => ({
|
const data: TGetEnvelopeItemsMetaResponse = await response.json();
|
||||||
...prev,
|
|
||||||
[envelopeItem.id]: {
|
// Check again after parsing JSON in case a newer fetch started
|
||||||
file: new Uint8Array(file),
|
if (fetchStartedAtRef.current !== fetchStartedAt) {
|
||||||
status: 'loaded',
|
return;
|
||||||
},
|
}
|
||||||
}));
|
|
||||||
|
// Build a map of envelope items by ID
|
||||||
|
const metaMap: Record<string, BasePageRenderData[]> = {};
|
||||||
|
|
||||||
|
for (const item of data.envelopeItems) {
|
||||||
|
metaMap[item.envelopeItemId] = item.pages.map((page, pageIndex) => {
|
||||||
|
const imageUrl = getEnvelopeItemPageImageUrl({
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
envelopeItemId: item.envelopeItemId,
|
||||||
|
documentDataId: item.documentDataId,
|
||||||
|
pageIndex,
|
||||||
|
token,
|
||||||
|
presignToken,
|
||||||
|
version,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
envelopeItemId: item.envelopeItemId,
|
||||||
|
documentDataId: item.documentDataId,
|
||||||
|
pageIndex,
|
||||||
|
pageNumber: pageIndex + 1,
|
||||||
|
pageWidth: page.originalWidth,
|
||||||
|
pageHeight: page.originalHeight,
|
||||||
|
imageUrl,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnvelopeItemsMeta(metaMap);
|
||||||
|
|
||||||
|
setEnvelopeItemsMetaLoadingState('loaded');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
// Only set error state if this is still the most recent fetch
|
||||||
|
if (fetchStartedAtRef.current === fetchStartedAt) {
|
||||||
setFiles((prev) => ({
|
console.error('Failed to load envelope data:', error);
|
||||||
...prev,
|
setEnvelopeItemsMetaLoadingState('error');
|
||||||
[envelopeItem.id]: {
|
}
|
||||||
status: 'error',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPdfBuffer = useCallback(
|
const handleLocalFileFetch = async () => {
|
||||||
(envelopeItemId: string) => {
|
setEnvelopeItemsMetaLoadingState('loading');
|
||||||
return files[envelopeItemId] || null;
|
|
||||||
},
|
try {
|
||||||
[files],
|
// Build a map of envelope items by ID
|
||||||
);
|
const metaMap: Record<string, BasePageRenderData[]> = {};
|
||||||
|
|
||||||
|
// Dynamically import "pdfToImagesClientSide" function to prevent bundling.
|
||||||
|
const { pdfToImagesClientSide } = await import(
|
||||||
|
'@documenso/lib/server-only/ai/pdf-to-images.client'
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const item of envelopeItems) {
|
||||||
|
if (item.data) {
|
||||||
|
// Clone the buffer so PDF.js can transfer it to its worker without detaching the one in state
|
||||||
|
const pdfBytes = new Uint8Array(structuredClone(item.data));
|
||||||
|
|
||||||
|
const pdfImages = await pdfToImagesClientSide(pdfBytes, {
|
||||||
|
scale: PDF_IMAGE_RENDER_SCALE,
|
||||||
|
});
|
||||||
|
|
||||||
|
metaMap[item.id] = pdfImages.map((image) => ({
|
||||||
|
envelopeItemId: item.id,
|
||||||
|
documentDataId: item.id,
|
||||||
|
pageIndex: image.pageIndex,
|
||||||
|
pageNumber: image.pageIndex + 1,
|
||||||
|
pageWidth: image.width,
|
||||||
|
pageHeight: image.height,
|
||||||
|
imageUrl: image.image,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnvelopeItemsMeta(metaMap);
|
||||||
|
setEnvelopeItemsMetaLoadingState('loaded');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load envelope data:', error);
|
||||||
|
setEnvelopeItemsMetaLoadingState('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const setCurrentEnvelopeItem = (envelopeItemId: string) => {
|
const setCurrentEnvelopeItem = (envelopeItemId: string) => {
|
||||||
const foundItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
|
const foundItem = envelopeItems.find((item) => item.id === envelopeItemId);
|
||||||
|
|
||||||
setCurrentItem(foundItem ?? null);
|
setCurrentItem(foundItem ?? null);
|
||||||
};
|
};
|
||||||
@@ -179,15 +323,6 @@ export const EnvelopeRenderProvider = ({
|
|||||||
}
|
}
|
||||||
}, [currentItem, envelopeItems]);
|
}, [currentItem, envelopeItems]);
|
||||||
|
|
||||||
// Look for any missing pdf files and load them.
|
|
||||||
useEffect(() => {
|
|
||||||
const missingFiles = envelope.envelopeItems.filter((item) => !files[item.id]);
|
|
||||||
|
|
||||||
for (const item of missingFiles) {
|
|
||||||
void loadEnvelopeItemPdfFile(item);
|
|
||||||
}
|
|
||||||
}, [envelope.envelopeItems]);
|
|
||||||
|
|
||||||
const recipientIds = useMemo(
|
const recipientIds = useMemo(
|
||||||
() => recipients.map((recipient) => recipient.id).sort(),
|
() => recipients.map((recipient) => recipient.id).sort(),
|
||||||
[recipients],
|
[recipients],
|
||||||
@@ -207,7 +342,9 @@ export const EnvelopeRenderProvider = ({
|
|||||||
return (
|
return (
|
||||||
<EnvelopeRenderContext.Provider
|
<EnvelopeRenderContext.Provider
|
||||||
value={{
|
value={{
|
||||||
getPdfBuffer,
|
version,
|
||||||
|
envelopeItemsMeta,
|
||||||
|
envelopeItemsMetaLoadingState,
|
||||||
envelopeItems,
|
envelopeItems,
|
||||||
envelopeStatus: envelope.status,
|
envelopeStatus: envelope.status,
|
||||||
envelopeType: envelope.type,
|
envelopeType: envelope.type,
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
|
||||||
|
// This is separate from the pdf-viewer.ts constant file due to parsing issues during testing.
|
||||||
|
export const PDF_VIEWER_ERROR_MESSAGES = {
|
||||||
|
editor: {
|
||||||
|
title: msg`Configuration Error`,
|
||||||
|
description: msg`There was an issue rendering some fields, please review the fields and try again.`,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
title: msg`Configuration Error`,
|
||||||
|
description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`,
|
||||||
|
},
|
||||||
|
signing: {
|
||||||
|
title: msg`Configuration Error`,
|
||||||
|
description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`,
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
title: msg`Configuration Error`,
|
||||||
|
description: msg`Something went wrong while rendering the document, please try again or contact our support.`,
|
||||||
|
},
|
||||||
|
} satisfies Record<string, { title: MessageDescriptor; description: MessageDescriptor }>;
|
||||||
@@ -1,2 +1,8 @@
|
|||||||
export const PDF_VIEWER_CONTAINER_SELECTOR = '.react-pdf__Document';
|
// Keep these two constants in sync.
|
||||||
export const PDF_VIEWER_PAGE_SELECTOR = '.react-pdf__Page';
|
export const PDF_VIEWER_PAGE_SELECTOR = '.react-pdf__Page';
|
||||||
|
export const PDF_VIEWER_PAGE_CLASSNAME = 'react-pdf__Page z-0';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changing this will require large testing.
|
||||||
|
*/
|
||||||
|
export const PDF_IMAGE_RENDER_SCALE = 2;
|
||||||
|
|||||||
@@ -242,18 +242,13 @@ export const run = async ({
|
|||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) {
|
for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) {
|
||||||
const newData = await tx.documentData.findFirstOrThrow({
|
await tx.envelopeItem.update({
|
||||||
where: {
|
where: {
|
||||||
id: newDocumentDataId,
|
envelopeId: envelope.id,
|
||||||
},
|
documentDataId: oldDocumentDataId,
|
||||||
});
|
|
||||||
|
|
||||||
await tx.documentData.update({
|
|
||||||
where: {
|
|
||||||
id: oldDocumentDataId,
|
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
data: newData.data,
|
documentDataId: newDocumentDataId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,6 @@
|
|||||||
"posthog-js": "^1.297.2",
|
"posthog-js": "^1.297.2",
|
||||||
"posthog-node": "4.18.0",
|
"posthog-node": "4.18.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-pdf": "^10.3.0",
|
|
||||||
"remeda": "^2.32.0",
|
"remeda": "^2.32.0",
|
||||||
"sharp": "0.34.5",
|
"sharp": "0.34.5",
|
||||||
"skia-canvas": "^3.0.8",
|
"skia-canvas": "^3.0.8",
|
||||||
|
|||||||
@@ -162,8 +162,8 @@ export const detectFieldsFromPdf = async ({
|
|||||||
// Mask existing fields on the image
|
// Mask existing fields on the image
|
||||||
const maskedImage = await maskFieldsOnImage({
|
const maskedImage = await maskFieldsOnImage({
|
||||||
image: page.image,
|
image: page.image,
|
||||||
width: page.width,
|
width: page.scaledWidth,
|
||||||
height: page.height,
|
height: page.scaledHeight,
|
||||||
fields: fieldsOnPage,
|
fields: fieldsOnPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import pMap from 'p-map';
|
||||||
|
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||||
|
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||||
|
'pdfjs-dist/legacy/build/pdf.worker.min.mjs',
|
||||||
|
import.meta.url,
|
||||||
|
).toString();
|
||||||
|
|
||||||
|
export type PdfToImagesOptions = {
|
||||||
|
scale?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PdfImageResult = {
|
||||||
|
pageIndex: number;
|
||||||
|
image: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pdfToImagesClientSide = async (
|
||||||
|
pdfBytes: Uint8Array,
|
||||||
|
options: PdfToImagesOptions = {},
|
||||||
|
): Promise<PdfImageResult[]> => {
|
||||||
|
const { scale = 2 } = options;
|
||||||
|
|
||||||
|
const task = pdfjsLib.getDocument({
|
||||||
|
data: pdfBytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdf = await task.promise;
|
||||||
|
|
||||||
|
const images = await pMap(
|
||||||
|
Array.from({ length: pdf.numPages }),
|
||||||
|
async (_, pageIndex) => {
|
||||||
|
const pageNumber = pageIndex + 1;
|
||||||
|
const page = await pdf.getPage(pageNumber);
|
||||||
|
|
||||||
|
const viewport = page.getViewport({ scale });
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.floor(viewport.width);
|
||||||
|
canvas.height = Math.floor(viewport.height);
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('Failed to get 2D canvas context');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.render({
|
||||||
|
canvasContext: context,
|
||||||
|
viewport,
|
||||||
|
canvas,
|
||||||
|
}).promise;
|
||||||
|
|
||||||
|
const imageBase64 = canvas.toDataURL('image/jpeg');
|
||||||
|
|
||||||
|
page.cleanup();
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageIndex,
|
||||||
|
image: imageBase64,
|
||||||
|
width: canvas.width,
|
||||||
|
height: canvas.height,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ concurrency: 50 },
|
||||||
|
);
|
||||||
|
|
||||||
|
await pdf.destroy();
|
||||||
|
await task.destroy();
|
||||||
|
|
||||||
|
return images;
|
||||||
|
};
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import pMap from 'p-map';
|
import pMap from 'p-map';
|
||||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||||
|
import type { PDFDocumentProxy } from 'pdfjs-dist/types/src/display/api';
|
||||||
|
import type { ExportFormat } from 'skia-canvas';
|
||||||
import { Canvas, Image, Path2D } from 'skia-canvas';
|
import { Canvas, Image, Path2D } from 'skia-canvas';
|
||||||
|
|
||||||
|
import { PDF_IMAGE_RENDER_SCALE } from '../../constants/pdf-viewer';
|
||||||
|
|
||||||
// @ts-expect-error napi-rs/canvas satisfies the requirements
|
// @ts-expect-error napi-rs/canvas satisfies the requirements
|
||||||
globalThis.Path2D = Path2D;
|
globalThis.Path2D = Path2D;
|
||||||
// @ts-expect-error napi-rs/canvas satisfies the requirements
|
// @ts-expect-error napi-rs/canvas satisfies the requirements
|
||||||
@@ -42,10 +46,17 @@ class SkiaCanvasFactory {
|
|||||||
|
|
||||||
export type PdfToImagesOptions = {
|
export type PdfToImagesOptions = {
|
||||||
scale?: number;
|
scale?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The format of the images to return.
|
||||||
|
*
|
||||||
|
* Defaults to 'jpeg'.
|
||||||
|
*/
|
||||||
|
imageFormat?: ExportFormat;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOptions = {}) => {
|
export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOptions = {}) => {
|
||||||
const { scale = 2 } = options;
|
const { scale = PDF_IMAGE_RENDER_SCALE, imageFormat = 'jpeg' } = options;
|
||||||
|
|
||||||
const task = await pdfjsLib.getDocument({
|
const task = await pdfjsLib.getDocument({
|
||||||
data: pdfBytes,
|
data: pdfBytes,
|
||||||
@@ -56,37 +67,7 @@ export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOpti
|
|||||||
|
|
||||||
const images = await pMap(
|
const images = await pMap(
|
||||||
Array.from({ length: pdf.numPages }),
|
Array.from({ length: pdf.numPages }),
|
||||||
async (_, index) => {
|
async (_, pageIndex) => getPdfPageAsImage({ pdf, pageIndex, scale, imageFormat }),
|
||||||
const pageNumber = index + 1;
|
|
||||||
const page = await pdf.getPage(pageNumber);
|
|
||||||
|
|
||||||
const viewport = page.getViewport({ scale });
|
|
||||||
|
|
||||||
const canvas = new Canvas(viewport.width, viewport.height);
|
|
||||||
canvas.gpu = false;
|
|
||||||
|
|
||||||
const canvasContext = canvas.getContext('2d');
|
|
||||||
|
|
||||||
await page.render({
|
|
||||||
// @ts-expect-error napi-rs/canvas satifies the requirements
|
|
||||||
canvas,
|
|
||||||
// @ts-expect-error napi-rs/canvas satifies the requirements
|
|
||||||
canvasContext,
|
|
||||||
viewport,
|
|
||||||
}).promise;
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
pageNumber,
|
|
||||||
image: await canvas.toBuffer('jpeg'),
|
|
||||||
width: Math.floor(viewport.width),
|
|
||||||
height: Math.floor(viewport.height),
|
|
||||||
mimeType: 'image/jpeg',
|
|
||||||
};
|
|
||||||
|
|
||||||
void page.cleanup();
|
|
||||||
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
{ concurrency: 10 },
|
{ concurrency: 10 },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -95,3 +76,84 @@ export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOpti
|
|||||||
|
|
||||||
return images;
|
return images;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PdfToImageOptions = {
|
||||||
|
scale?: number;
|
||||||
|
pageIndex: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The format of the image to return.
|
||||||
|
* Defaults to 'jpeg'.
|
||||||
|
*/
|
||||||
|
imageFormat?: ExportFormat;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pdfToImage = async (pdfBytes: Uint8Array, options: PdfToImageOptions) => {
|
||||||
|
const { scale = PDF_IMAGE_RENDER_SCALE, pageIndex, imageFormat = 'jpeg' } = options;
|
||||||
|
|
||||||
|
if (pageIndex !== undefined && pageIndex < 0) {
|
||||||
|
throw new Error('Page index must be greater than or equal to 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await pdfjsLib.getDocument({
|
||||||
|
data: pdfBytes,
|
||||||
|
CanvasFactory: SkiaCanvasFactory,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdf = await task.promise;
|
||||||
|
|
||||||
|
const image = await getPdfPageAsImage({ pdf, pageIndex, scale, imageFormat });
|
||||||
|
|
||||||
|
void pdf.destroy().catch((e) => console.error(e));
|
||||||
|
void task.destroy().catch((e) => console.error(e));
|
||||||
|
|
||||||
|
return image;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetPdfPageAsImageOptions = {
|
||||||
|
pdf: PDFDocumentProxy;
|
||||||
|
pageIndex: number;
|
||||||
|
scale: number;
|
||||||
|
imageFormat: ExportFormat;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPdfPageAsImage = async ({
|
||||||
|
pdf,
|
||||||
|
pageIndex,
|
||||||
|
scale,
|
||||||
|
imageFormat,
|
||||||
|
}: GetPdfPageAsImageOptions) => {
|
||||||
|
const page = await pdf.getPage(pageIndex + 1);
|
||||||
|
|
||||||
|
const viewport = page.getViewport({ scale });
|
||||||
|
|
||||||
|
const canvas = new Canvas(viewport.width, viewport.height);
|
||||||
|
canvas.gpu = false;
|
||||||
|
|
||||||
|
const canvasContext = canvas.getContext('2d');
|
||||||
|
|
||||||
|
await page.render({
|
||||||
|
// @ts-expect-error napi-rs/canvas satifies the requirements
|
||||||
|
canvas,
|
||||||
|
// @ts-expect-error napi-rs/canvas satifies the requirements
|
||||||
|
canvasContext,
|
||||||
|
viewport,
|
||||||
|
}).promise;
|
||||||
|
|
||||||
|
const originalViewport = page.getViewport({ scale: 1 });
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
pageIndex,
|
||||||
|
pageNumber: pageIndex + 1,
|
||||||
|
image: await canvas.toBuffer(imageFormat),
|
||||||
|
originalWidth: originalViewport.width,
|
||||||
|
originalHeight: originalViewport.height,
|
||||||
|
scale,
|
||||||
|
scaledWidth: Math.floor(viewport.width),
|
||||||
|
scaledHeight: Math.floor(viewport.height),
|
||||||
|
};
|
||||||
|
|
||||||
|
void page.cleanup();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ export const sendDocument = async ({
|
|||||||
|
|
||||||
const injectFormValuesIntoDocument = async (
|
const injectFormValuesIntoDocument = async (
|
||||||
envelope: Envelope,
|
envelope: Envelope,
|
||||||
envelopeItem: Pick<EnvelopeItem, 'id'> & { documentData: DocumentData },
|
envelopeItem: Pick<EnvelopeItem, 'id'> & { documentData: Omit<DocumentData, 'metadata'> },
|
||||||
) => {
|
) => {
|
||||||
const file = await getFileServerSide(envelopeItem.documentData);
|
const file = await getFileServerSide(envelopeItem.documentData);
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ export const verifyEmbeddingPresignToken = async ({
|
|||||||
where: {
|
where: {
|
||||||
id: tokenId,
|
id: tokenId,
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!apiToken) {
|
if (!apiToken) {
|
||||||
@@ -69,7 +78,7 @@ export const verifyEmbeddingPresignToken = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// This should never happen but we need to narrow types
|
// This should never happen but we need to narrow types
|
||||||
if (!apiToken.userId) {
|
if (!apiToken.userId || !apiToken.user) {
|
||||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
message: 'Invalid presign token: API token does not have a user attached',
|
message: 'Invalid presign token: API token does not have a user attached',
|
||||||
});
|
});
|
||||||
@@ -119,5 +128,10 @@ export const verifyEmbeddingPresignToken = async ({
|
|||||||
return {
|
return {
|
||||||
...apiToken,
|
...apiToken,
|
||||||
userId,
|
userId,
|
||||||
|
user: {
|
||||||
|
id: apiToken.user.id,
|
||||||
|
name: apiToken.user.name,
|
||||||
|
email: apiToken.user.email,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -97,6 +97,14 @@ export type CreateEnvelopeOptions = {
|
|||||||
data: string;
|
data: string;
|
||||||
type?: TEnvelopeAttachmentType;
|
type?: TEnvelopeAttachmentType;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to bypass adding default recipients.
|
||||||
|
*
|
||||||
|
* Defaults to false.
|
||||||
|
*/
|
||||||
|
bypassDefaultRecipients?: boolean;
|
||||||
|
|
||||||
meta?: Partial<Omit<DocumentMeta, 'id'>>;
|
meta?: Partial<Omit<DocumentMeta, 'id'>>;
|
||||||
requestMetadata: ApiRequestMetadata;
|
requestMetadata: ApiRequestMetadata;
|
||||||
};
|
};
|
||||||
@@ -110,6 +118,7 @@ export const createEnvelope = async ({
|
|||||||
meta,
|
meta,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
internalVersion,
|
internalVersion,
|
||||||
|
bypassDefaultRecipients = false,
|
||||||
}: CreateEnvelopeOptions) => {
|
}: CreateEnvelopeOptions) => {
|
||||||
const {
|
const {
|
||||||
type,
|
type,
|
||||||
@@ -355,9 +364,10 @@ export const createEnvelope = async ({
|
|||||||
|
|
||||||
const firstEnvelopeItem = envelope.envelopeItems[0];
|
const firstEnvelopeItem = envelope.envelopeItems[0];
|
||||||
|
|
||||||
const defaultRecipients = settings.defaultRecipients
|
const defaultRecipients =
|
||||||
? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
|
settings.defaultRecipients && !bypassDefaultRecipients
|
||||||
: [];
|
? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
|
||||||
|
: [];
|
||||||
|
|
||||||
const mappedDefaultRecipients: CreateEnvelopeRecipientOptions[] = defaultRecipients.map(
|
const mappedDefaultRecipients: CreateEnvelopeRecipientOptions[] = defaultRecipients.map(
|
||||||
(recipient) => ({
|
(recipient) => ({
|
||||||
|
|||||||
@@ -21,14 +21,7 @@ export type SetTemplateRecipientsOptions = {
|
|||||||
userId: number;
|
userId: number;
|
||||||
teamId: number;
|
teamId: number;
|
||||||
id: EnvelopeIdOptions;
|
id: EnvelopeIdOptions;
|
||||||
recipients: {
|
recipients: RecipientData[];
|
||||||
id?: number;
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
role: RecipientRole;
|
|
||||||
signingOrder?: number | null;
|
|
||||||
actionAuth?: TRecipientActionAuthTypes[];
|
|
||||||
}[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setTemplateRecipients = async ({
|
export const setTemplateRecipients = async ({
|
||||||
@@ -183,7 +176,10 @@ export const setTemplateRecipients = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return upsertedRecipient;
|
return {
|
||||||
|
...upsertedRecipient,
|
||||||
|
clientId: recipient.clientId,
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -199,7 +195,7 @@ export const setTemplateRecipients = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Filter out recipients that have been removed or have been updated.
|
// Filter out recipients that have been removed or have been updated.
|
||||||
const filteredRecipients: Recipient[] = existingRecipients.filter((recipient) => {
|
const filteredRecipients: RecipientDataWithClientId[] = existingRecipients.filter((recipient) => {
|
||||||
const isRemoved = removedRecipients.find(
|
const isRemoved = removedRecipients.find(
|
||||||
(removedRecipient) => removedRecipient.id === recipient.id,
|
(removedRecipient) => removedRecipient.id === recipient.id,
|
||||||
);
|
);
|
||||||
@@ -218,3 +214,17 @@ export const setTemplateRecipients = async ({
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RecipientData = {
|
||||||
|
id?: number;
|
||||||
|
clientId?: string | null;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: RecipientRole;
|
||||||
|
signingOrder?: number | null;
|
||||||
|
actionAuth?: TRecipientActionAuthTypes[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type RecipientDataWithClientId = Recipient & {
|
||||||
|
clientId?: string | null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZDocumentDataMetaSchema = z.object({
|
||||||
|
// Could store other things such as PDF size, etc here.
|
||||||
|
pages: z
|
||||||
|
.object({
|
||||||
|
originalWidth: z.number().describe('Original PDF page width'),
|
||||||
|
originalHeight: z.number().describe('Original PDF page height'),
|
||||||
|
scale: z.number().describe('The scale applied to the width/height of the PDF page'),
|
||||||
|
scaledWidth: z.number().describe('Scaled PDF page image width'),
|
||||||
|
scaledHeight: z.number().describe('Scaled PDF page image height'),
|
||||||
|
})
|
||||||
|
.array(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TDocumentDataMeta = z.infer<typeof ZDocumentDataMetaSchema>;
|
||||||
|
|
||||||
|
export type DocumentDataVersion = 'initial' | 'current';
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { EnvelopeType } from '@prisma/client';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ZEnvelopeFieldSchema } from '@documenso/lib/types/field';
|
||||||
|
import { ZEnvelopeRecipientLiteSchema } from '@documenso/lib/types/recipient';
|
||||||
|
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
|
||||||
|
import { EnvelopeItemSchema } from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
|
||||||
|
import { EnvelopeSchema } from '@documenso/prisma/generated/zod/modelSchema/EnvelopeSchema';
|
||||||
|
import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
|
||||||
|
import { TemplateDirectLinkSchema } from '@documenso/prisma/generated/zod/modelSchema/TemplateDirectLinkSchema';
|
||||||
|
import { ZBaseEmbedDataSchema } from '@documenso/remix/app/types/embed-base-schemas';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DO NOT MAKE ANY BREAKING BACKWARD CHANGES HERE UNLESS YOu'RE SURE
|
||||||
|
* IT WON'T BREAK EMBEDDINGS.
|
||||||
|
*
|
||||||
|
* Keep this in sync with the embedded repo (the types + schema)
|
||||||
|
*/
|
||||||
|
export const ZEnvelopeEditorSettingsSchema = z.object({
|
||||||
|
/**
|
||||||
|
* Generic editor related configurations.
|
||||||
|
*/
|
||||||
|
general: z.object({
|
||||||
|
allowConfigureEnvelopeTitle: z.boolean(),
|
||||||
|
allowUploadAndRecipientStep: z.boolean(),
|
||||||
|
allowAddFieldsStep: z.boolean(),
|
||||||
|
allowPreviewStep: z.boolean(),
|
||||||
|
minimizeLeftSidebar: z.boolean(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envelope meta/settings related configuration
|
||||||
|
*
|
||||||
|
* If null, the settings will not be available to be seen/updated.
|
||||||
|
*/
|
||||||
|
settings: z
|
||||||
|
.object({
|
||||||
|
allowConfigureSignatureTypes: z.boolean(),
|
||||||
|
allowConfigureLanguage: z.boolean(),
|
||||||
|
allowConfigureDateFormat: z.boolean(),
|
||||||
|
allowConfigureTimezone: z.boolean(),
|
||||||
|
allowConfigureRedirectUrl: z.boolean(),
|
||||||
|
allowConfigureDistribution: z.boolean(),
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action related configurations.
|
||||||
|
*/
|
||||||
|
actions: z.object({
|
||||||
|
allowAttachments: z.boolean(),
|
||||||
|
allowDistributing: z.boolean(),
|
||||||
|
allowDirectLink: z.boolean(),
|
||||||
|
allowDuplication: z.boolean(),
|
||||||
|
allowDownloadPDF: z.boolean(),
|
||||||
|
allowDeletion: z.boolean(),
|
||||||
|
allowReturnToPreviousPage: z.boolean(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envelope items related configurations.
|
||||||
|
*
|
||||||
|
* If null, no adjustments to envelope items will be allowed.
|
||||||
|
*/
|
||||||
|
envelopeItems: z
|
||||||
|
.object({
|
||||||
|
allowConfigureTitle: z.boolean(),
|
||||||
|
allowConfigureOrder: z.boolean(),
|
||||||
|
allowUpload: z.boolean(),
|
||||||
|
allowDelete: z.boolean(),
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recipient related configurations.
|
||||||
|
*
|
||||||
|
* If null, recipients will not be configurable at all.
|
||||||
|
*/
|
||||||
|
recipients: z
|
||||||
|
.object({
|
||||||
|
allowAIDetection: z.boolean(),
|
||||||
|
allowConfigureSigningOrder: z.boolean(),
|
||||||
|
allowConfigureDictateNextSigner: z.boolean(),
|
||||||
|
allowApproverRole: z.boolean(),
|
||||||
|
allowViewerRole: z.boolean(),
|
||||||
|
allowCCerRole: z.boolean(),
|
||||||
|
allowAssistantRole: z.boolean(),
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fields related configurations.
|
||||||
|
*/
|
||||||
|
fields: z.object({
|
||||||
|
allowAIDetection: z.boolean(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TEnvelopeEditorSettings = z.infer<typeof ZEnvelopeEditorSettingsSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default editor configuration for normal flows.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_EDITOR_CONFIG: EnvelopeEditorConfig = {
|
||||||
|
general: {
|
||||||
|
allowConfigureEnvelopeTitle: true,
|
||||||
|
allowUploadAndRecipientStep: true,
|
||||||
|
allowAddFieldsStep: true,
|
||||||
|
allowPreviewStep: true,
|
||||||
|
minimizeLeftSidebar: false,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
allowConfigureSignatureTypes: true,
|
||||||
|
allowConfigureLanguage: true,
|
||||||
|
allowConfigureDateFormat: true,
|
||||||
|
allowConfigureTimezone: true,
|
||||||
|
allowConfigureRedirectUrl: true,
|
||||||
|
allowConfigureDistribution: true,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
allowAttachments: true,
|
||||||
|
allowDistributing: true,
|
||||||
|
allowDirectLink: true,
|
||||||
|
allowDuplication: true,
|
||||||
|
allowDownloadPDF: true,
|
||||||
|
allowDeletion: true,
|
||||||
|
allowReturnToPreviousPage: true,
|
||||||
|
},
|
||||||
|
envelopeItems: {
|
||||||
|
allowConfigureTitle: true,
|
||||||
|
allowConfigureOrder: true,
|
||||||
|
allowUpload: true,
|
||||||
|
allowDelete: true,
|
||||||
|
},
|
||||||
|
recipients: {
|
||||||
|
allowAIDetection: true,
|
||||||
|
allowConfigureSigningOrder: true,
|
||||||
|
allowConfigureDictateNextSigner: true,
|
||||||
|
|
||||||
|
allowApproverRole: true,
|
||||||
|
allowViewerRole: true,
|
||||||
|
allowCCerRole: true,
|
||||||
|
allowAssistantRole: true,
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
allowAIDetection: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default configuration for the embedded editor. This is merged with whatever is provided
|
||||||
|
* by the embedded hash.
|
||||||
|
*
|
||||||
|
* This is duplicated in the embedded repo playground
|
||||||
|
*
|
||||||
|
* /playground/src/components/embedddings/envelope-feature.ts
|
||||||
|
*/
|
||||||
|
export const DEFAULT_EMBEDDED_EDITOR_CONFIG = {
|
||||||
|
general: {
|
||||||
|
allowConfigureEnvelopeTitle: true,
|
||||||
|
allowUploadAndRecipientStep: true,
|
||||||
|
allowAddFieldsStep: true,
|
||||||
|
allowPreviewStep: true,
|
||||||
|
minimizeLeftSidebar: true,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
allowConfigureSignatureTypes: true,
|
||||||
|
allowConfigureLanguage: true,
|
||||||
|
allowConfigureDateFormat: true,
|
||||||
|
allowConfigureTimezone: true,
|
||||||
|
allowConfigureRedirectUrl: true,
|
||||||
|
allowConfigureDistribution: true,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
allowAttachments: true,
|
||||||
|
allowDistributing: false,
|
||||||
|
allowDirectLink: false,
|
||||||
|
allowDuplication: false,
|
||||||
|
allowDownloadPDF: false,
|
||||||
|
allowDeletion: false,
|
||||||
|
allowReturnToPreviousPage: true,
|
||||||
|
},
|
||||||
|
envelopeItems: {
|
||||||
|
allowConfigureTitle: true,
|
||||||
|
allowConfigureOrder: true,
|
||||||
|
allowUpload: true,
|
||||||
|
allowDelete: true,
|
||||||
|
},
|
||||||
|
recipients: {
|
||||||
|
allowAIDetection: false,
|
||||||
|
allowConfigureSigningOrder: true,
|
||||||
|
allowConfigureDictateNextSigner: true,
|
||||||
|
allowApproverRole: true,
|
||||||
|
allowViewerRole: true,
|
||||||
|
allowCCerRole: true,
|
||||||
|
allowAssistantRole: true,
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
allowAIDetection: false,
|
||||||
|
},
|
||||||
|
} as const satisfies EnvelopeEditorConfig;
|
||||||
|
|
||||||
|
export const ZEmbedCreateEnvelopeAuthoringSchema = ZBaseEmbedDataSchema.extend({
|
||||||
|
externalId: z.string().optional(),
|
||||||
|
type: z.nativeEnum(EnvelopeType),
|
||||||
|
features: z.object({}).passthrough().optional().default(DEFAULT_EMBEDDED_EDITOR_CONFIG),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZEmbedEditEnvelopeAuthoringSchema = ZBaseEmbedDataSchema.extend({
|
||||||
|
externalId: z.string().optional(),
|
||||||
|
features: z.object({}).passthrough().optional().default(DEFAULT_EMBEDDED_EDITOR_CONFIG),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TEmbedCreateEnvelopeAuthoring = z.infer<typeof ZEmbedCreateEnvelopeAuthoringSchema>;
|
||||||
|
export type TEmbedEditEnvelopeAuthoring = z.infer<typeof ZEmbedEditEnvelopeAuthoringSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A subset of the full envelope response schema used for the envelope editor.
|
||||||
|
*
|
||||||
|
* Internal usage only.
|
||||||
|
*/
|
||||||
|
export const ZEditorEnvelopeSchema = EnvelopeSchema.pick({
|
||||||
|
internalVersion: true,
|
||||||
|
type: true,
|
||||||
|
status: true,
|
||||||
|
source: true,
|
||||||
|
visibility: true,
|
||||||
|
templateType: true,
|
||||||
|
id: true,
|
||||||
|
secondaryId: true,
|
||||||
|
externalId: true,
|
||||||
|
completedAt: true,
|
||||||
|
deletedAt: true,
|
||||||
|
title: true,
|
||||||
|
authOptions: true,
|
||||||
|
publicTitle: true,
|
||||||
|
publicDescription: true,
|
||||||
|
userId: true,
|
||||||
|
teamId: true,
|
||||||
|
folderId: true,
|
||||||
|
}).extend({
|
||||||
|
documentMeta: DocumentMetaSchema.pick({
|
||||||
|
signingOrder: true,
|
||||||
|
distributionMethod: true,
|
||||||
|
id: true,
|
||||||
|
subject: true,
|
||||||
|
message: true,
|
||||||
|
timezone: true,
|
||||||
|
dateFormat: true,
|
||||||
|
redirectUrl: true,
|
||||||
|
typedSignatureEnabled: true,
|
||||||
|
uploadSignatureEnabled: true,
|
||||||
|
drawSignatureEnabled: true,
|
||||||
|
allowDictateNextSigner: true,
|
||||||
|
language: true,
|
||||||
|
emailSettings: true,
|
||||||
|
emailId: true,
|
||||||
|
emailReplyTo: true,
|
||||||
|
}),
|
||||||
|
recipients: ZEnvelopeRecipientLiteSchema.array(),
|
||||||
|
fields: ZEnvelopeFieldSchema.array(),
|
||||||
|
envelopeItems: EnvelopeItemSchema.pick({
|
||||||
|
envelopeId: true,
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
order: true,
|
||||||
|
})
|
||||||
|
.extend({
|
||||||
|
// Only used for embedded.
|
||||||
|
data: z.instanceof(Uint8Array).optional(),
|
||||||
|
})
|
||||||
|
.array(),
|
||||||
|
directLink: TemplateDirectLinkSchema.pick({
|
||||||
|
directTemplateRecipientId: true,
|
||||||
|
enabled: true,
|
||||||
|
id: true,
|
||||||
|
token: true,
|
||||||
|
}).nullable(),
|
||||||
|
team: TeamSchema.pick({
|
||||||
|
id: true,
|
||||||
|
url: true,
|
||||||
|
}),
|
||||||
|
user: z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
email: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TEditorEnvelope = z.infer<typeof ZEditorEnvelopeSchema>;
|
||||||
|
|
||||||
|
export type EnvelopeEditorConfig = TEnvelopeEditorSettings & {
|
||||||
|
embeded?: {
|
||||||
|
presignToken: string;
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
onCreate?: (envelope: Omit<TEditorEnvelope, 'id'>) => void;
|
||||||
|
onUpdate?: (envelope: TEditorEnvelope) => void;
|
||||||
|
customBrandingLogo?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -419,4 +419,4 @@ export const ZEnvelopeFieldAndMetaSchema = z.discriminatedUnion('type', [
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
type TEnvelopeFieldAndMeta = z.infer<typeof ZEnvelopeFieldAndMetaSchema>;
|
export type TEnvelopeFieldAndMeta = z.infer<typeof ZEnvelopeFieldAndMetaSchema>;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { RecipientSchema } from '@documenso/prisma/generated/zod/modelSchema/RecipientSchema';
|
import { RecipientSchema } from '@documenso/prisma/generated/zod/modelSchema/RecipientSchema';
|
||||||
@@ -114,10 +113,5 @@ export const ZEnvelopeRecipientManySchema = ZRecipientManySchema.omit({
|
|||||||
|
|
||||||
export const ZRecipientEmailSchema = z.union([
|
export const ZRecipientEmailSchema = z.union([
|
||||||
z.literal(''),
|
z.literal(''),
|
||||||
z
|
z.string().trim().toLowerCase().email({ message: 'Invalid email' }).max(254),
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.email({ message: msg`Invalid email`.id })
|
|
||||||
.max(254),
|
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { PDF } from '@libpdf/core';
|
import { PDF } from '@libpdf/core';
|
||||||
import { DocumentDataType } from '@prisma/client';
|
import { DocumentDataType } from '@prisma/client';
|
||||||
import { base64 } from '@scure/base';
|
import { base64 } from '@scure/base';
|
||||||
|
import pMap from 'p-map';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import type { TDocumentDataMeta } from '@documenso/lib/types/document-data';
|
||||||
|
import { ZDocumentDataMetaSchema } from '@documenso/lib/types/document-data';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { AppError } from '../../errors/app-error';
|
import { AppError } from '../../errors/app-error';
|
||||||
|
import { pdfToImages } from '../../server-only/ai/pdf-to-images';
|
||||||
import { createDocumentData } from '../../server-only/document-data/create-document-data';
|
import { createDocumentData } from '../../server-only/document-data/create-document-data';
|
||||||
import { normalizePdf } from '../../server-only/pdf/normalize-pdf';
|
import { normalizePdf } from '../../server-only/pdf/normalize-pdf';
|
||||||
|
import { getEnvelopeItemPageImageS3Key } from '../../utils/envelope-images';
|
||||||
import { uploadS3File } from './server-actions';
|
import { uploadS3File } from './server-actions';
|
||||||
|
|
||||||
type File = {
|
type File = {
|
||||||
@@ -41,7 +47,73 @@ export const putPdfFileServerSide = async (file: File) => {
|
|||||||
|
|
||||||
const { type, data } = await putFileServerSide(file);
|
const { type, data } = await putFileServerSide(file);
|
||||||
|
|
||||||
return await createDocumentData({ type, data });
|
const newDocumentData = await createDocumentData({ type, data });
|
||||||
|
|
||||||
|
void extractAndStorePdfImages(arrayBuffer, newDocumentData.id).catch((err) => {
|
||||||
|
console.error(`Error extracting and storing PDF images: ${err}`);
|
||||||
|
|
||||||
|
// Do nothing.
|
||||||
|
});
|
||||||
|
|
||||||
|
return newDocumentData;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract and stores page images and metadata to S3.
|
||||||
|
*/
|
||||||
|
export const extractAndStorePdfImages = async (
|
||||||
|
arrayBuffer: ArrayBuffer,
|
||||||
|
documentDataId: string,
|
||||||
|
): Promise<TDocumentDataMeta['pages']> => {
|
||||||
|
const images = await pdfToImages(new Uint8Array(arrayBuffer));
|
||||||
|
|
||||||
|
const pageMetadata = images.map((image) => ({
|
||||||
|
originalWidth: image.originalWidth,
|
||||||
|
originalHeight: image.originalHeight,
|
||||||
|
scale: image.scale,
|
||||||
|
scaledWidth: image.scaledWidth,
|
||||||
|
scaledHeight: image.scaledHeight,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const documentDataMetadata = ZDocumentDataMetaSchema.parse({
|
||||||
|
pages: pageMetadata,
|
||||||
|
} satisfies TDocumentDataMeta);
|
||||||
|
|
||||||
|
// Only update metadata (page dimensions). Never update type, data, or initialData:
|
||||||
|
// DocumentData content is immutable so cache keys (documentDataId) stay valid.
|
||||||
|
const updatedDocumentData = await prisma.documentData.update({
|
||||||
|
where: { id: documentDataId },
|
||||||
|
data: {
|
||||||
|
metadata: documentDataMetadata,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
env('NEXT_PUBLIC_UPLOAD_TRANSPORT') === 's3' &&
|
||||||
|
updatedDocumentData.type === DocumentDataType.S3_PATH
|
||||||
|
) {
|
||||||
|
await pMap(
|
||||||
|
images,
|
||||||
|
async (image) => {
|
||||||
|
const imageBlob = new Blob([new Uint8Array(image.image)], { type: 'image/jpeg' });
|
||||||
|
|
||||||
|
const pageIndex = image.pageIndex;
|
||||||
|
|
||||||
|
const s3Key = getEnvelopeItemPageImageS3Key(updatedDocumentData.data, pageIndex);
|
||||||
|
|
||||||
|
const imageFile = new File([imageBlob], `${pageIndex}.jpeg`, {
|
||||||
|
type: 'image/jpeg',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { key } = await uploadS3File(imageFile, s3Key);
|
||||||
|
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
{ concurrency: 100 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,10 +135,18 @@ export const putNormalizedPdfFileServerSide = async (
|
|||||||
arrayBuffer: async () => Promise.resolve(normalized),
|
arrayBuffer: async () => Promise.resolve(normalized),
|
||||||
});
|
});
|
||||||
|
|
||||||
return await createDocumentData({
|
const newDocumentData = await createDocumentData({
|
||||||
type: documentData.type,
|
type: documentData.type,
|
||||||
data: documentData.data,
|
data: documentData.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void extractAndStorePdfImages(normalized, newDocumentData.id).catch((err) => {
|
||||||
|
console.error(`Error extracting and storing PDF images: ${err}`);
|
||||||
|
|
||||||
|
// Do nothing.
|
||||||
|
});
|
||||||
|
|
||||||
|
return newDocumentData;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -91,13 +91,13 @@ export const getPresignGetUrl = async (key: string) => {
|
|||||||
/**
|
/**
|
||||||
* Uploads a file to S3.
|
* Uploads a file to S3.
|
||||||
*/
|
*/
|
||||||
export const uploadS3File = async (file: File) => {
|
export const uploadS3File = async (file: File, keyOverride?: string) => {
|
||||||
const client = getS3Client();
|
const client = getS3Client();
|
||||||
|
|
||||||
// Get the basename and extension for the file
|
// Get the basename and extension for the file
|
||||||
const { name, ext } = path.parse(file.name);
|
const { name, ext } = path.parse(file.name);
|
||||||
|
|
||||||
const key = `${alphaid(12)}/${slugify(name)}${ext}`;
|
const key = keyOverride ?? `${alphaid(12)}/${slugify(name)}${ext}`;
|
||||||
|
|
||||||
const fileBuffer = await file.arrayBuffer();
|
const fileBuffer = await file.arrayBuffer();
|
||||||
|
|
||||||
@@ -124,6 +124,28 @@ export const deleteS3File = async (key: string) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Be careful about using this function as we don't allow the
|
||||||
|
* frontend to ever pull a file from S3 directly.
|
||||||
|
*/
|
||||||
|
export const UNSAFE_getS3File = async (key: string) => {
|
||||||
|
// Additional safeguard to prevent path traversal.
|
||||||
|
if (key.includes('..') || key.startsWith('/')) {
|
||||||
|
throw new Error('Invalid S3 key');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getS3Client();
|
||||||
|
|
||||||
|
const response = await client.send(
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
|
||||||
|
Key: key,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.Body;
|
||||||
|
};
|
||||||
|
|
||||||
const getS3Client = () => {
|
const getS3Client = () => {
|
||||||
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
|
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import type { EnvelopeEditorConfig } from '../types/envelope-editor';
|
||||||
|
import { DEFAULT_EMBEDDED_EDITOR_CONFIG } from '../types/envelope-editor';
|
||||||
|
|
||||||
|
export const PRESIGNED_ENVELOPE_ITEM_ID_PREFIX = 'PRESIGNED_';
|
||||||
|
|
||||||
|
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes parsed `features` from the embedding hash and an `embeded` config,
|
||||||
|
* and produces a complete `EnvelopeEditorConfig` with sensible embedded-mode defaults.
|
||||||
|
*
|
||||||
|
* Any explicitly provided feature flag overrides the embedded default.
|
||||||
|
*/
|
||||||
|
export function buildEmbeddedEditorOptions(
|
||||||
|
features: DeepPartial<EnvelopeEditorConfig>,
|
||||||
|
embeded: EnvelopeEditorConfig['embeded'],
|
||||||
|
): EnvelopeEditorConfig {
|
||||||
|
return {
|
||||||
|
embeded,
|
||||||
|
...buildEmbeddedFeatures(features),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildEmbeddedFeatures = (
|
||||||
|
features: DeepPartial<EnvelopeEditorConfig>,
|
||||||
|
): EnvelopeEditorConfig => {
|
||||||
|
return {
|
||||||
|
general: {
|
||||||
|
allowConfigureEnvelopeTitle:
|
||||||
|
features.general?.allowConfigureEnvelopeTitle ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowConfigureEnvelopeTitle,
|
||||||
|
allowUploadAndRecipientStep:
|
||||||
|
features.general?.allowUploadAndRecipientStep ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowUploadAndRecipientStep,
|
||||||
|
allowAddFieldsStep:
|
||||||
|
features.general?.allowAddFieldsStep ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowAddFieldsStep,
|
||||||
|
allowPreviewStep:
|
||||||
|
features.general?.allowPreviewStep ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowPreviewStep,
|
||||||
|
minimizeLeftSidebar:
|
||||||
|
features.general?.minimizeLeftSidebar ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.minimizeLeftSidebar,
|
||||||
|
},
|
||||||
|
|
||||||
|
settings:
|
||||||
|
features.settings !== null
|
||||||
|
? {
|
||||||
|
allowConfigureSignatureTypes:
|
||||||
|
features.settings?.allowConfigureSignatureTypes ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureSignatureTypes,
|
||||||
|
allowConfigureLanguage:
|
||||||
|
features.settings?.allowConfigureLanguage ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureLanguage,
|
||||||
|
allowConfigureDateFormat:
|
||||||
|
features.settings?.allowConfigureDateFormat ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureDateFormat,
|
||||||
|
allowConfigureTimezone:
|
||||||
|
features.settings?.allowConfigureTimezone ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureTimezone,
|
||||||
|
allowConfigureRedirectUrl:
|
||||||
|
features.settings?.allowConfigureRedirectUrl ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureRedirectUrl,
|
||||||
|
allowConfigureDistribution:
|
||||||
|
features.settings?.allowConfigureDistribution ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureDistribution,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
allowAttachments:
|
||||||
|
features.actions?.allowAttachments ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowAttachments,
|
||||||
|
allowDistributing:
|
||||||
|
features.actions?.allowDistributing ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDistributing,
|
||||||
|
allowDirectLink:
|
||||||
|
features.actions?.allowDirectLink ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDirectLink,
|
||||||
|
allowDuplication:
|
||||||
|
features.actions?.allowDuplication ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDuplication,
|
||||||
|
allowDownloadPDF:
|
||||||
|
features.actions?.allowDownloadPDF ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDownloadPDF,
|
||||||
|
allowDeletion:
|
||||||
|
features.actions?.allowDeletion ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDeletion,
|
||||||
|
allowReturnToPreviousPage:
|
||||||
|
features.actions?.allowReturnToPreviousPage ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowReturnToPreviousPage,
|
||||||
|
},
|
||||||
|
|
||||||
|
envelopeItems:
|
||||||
|
features.envelopeItems !== null
|
||||||
|
? {
|
||||||
|
allowConfigureTitle:
|
||||||
|
features.envelopeItems?.allowConfigureTitle ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowConfigureTitle,
|
||||||
|
allowConfigureOrder:
|
||||||
|
features.envelopeItems?.allowConfigureOrder ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowConfigureOrder,
|
||||||
|
allowUpload:
|
||||||
|
features.envelopeItems?.allowUpload ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowUpload,
|
||||||
|
allowDelete:
|
||||||
|
features.envelopeItems?.allowDelete ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowDelete,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
|
||||||
|
recipients:
|
||||||
|
features.recipients !== null
|
||||||
|
? {
|
||||||
|
allowAIDetection:
|
||||||
|
features.recipients?.allowAIDetection ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowAIDetection,
|
||||||
|
allowConfigureSigningOrder:
|
||||||
|
features.recipients?.allowConfigureSigningOrder ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowConfigureSigningOrder,
|
||||||
|
allowConfigureDictateNextSigner:
|
||||||
|
features.recipients?.allowConfigureDictateNextSigner ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowConfigureDictateNextSigner,
|
||||||
|
allowApproverRole:
|
||||||
|
features.recipients?.allowApproverRole ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowApproverRole,
|
||||||
|
allowViewerRole:
|
||||||
|
features.recipients?.allowViewerRole ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowViewerRole,
|
||||||
|
allowCCerRole:
|
||||||
|
features.recipients?.allowCCerRole ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowCCerRole,
|
||||||
|
allowAssistantRole:
|
||||||
|
features.recipients?.allowAssistantRole ??
|
||||||
|
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowAssistantRole,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
|
||||||
|
fields: {
|
||||||
|
allowAIDetection:
|
||||||
|
features.fields?.allowAIDetection ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.fields.allowAIDetection,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||||
|
import type { DocumentDataVersion } from '../types/document-data';
|
||||||
|
|
||||||
|
export type EnvelopeItemPageImageUrlOptions = {
|
||||||
|
envelopeId: string;
|
||||||
|
envelopeItemId: string;
|
||||||
|
documentDataId: string;
|
||||||
|
pageIndex: number;
|
||||||
|
token: string | undefined;
|
||||||
|
presignToken?: string | undefined;
|
||||||
|
version: DocumentDataVersion;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the URL for fetching a single page of a PDF as an image.
|
||||||
|
*/
|
||||||
|
export const getEnvelopeItemPageImageUrl = (options: EnvelopeItemPageImageUrlOptions): string => {
|
||||||
|
const { envelopeId, envelopeItemId, documentDataId, pageIndex, token, presignToken, version } =
|
||||||
|
options;
|
||||||
|
|
||||||
|
const partialUrl = `envelope/${envelopeId}/envelopeItem/${envelopeItemId}/dataId/${documentDataId}/${version}/${pageIndex}/image.jpeg`;
|
||||||
|
|
||||||
|
// Recipient token endpoint.
|
||||||
|
if (token) {
|
||||||
|
return `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/${partialUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoint authenticated by session or presigned token.
|
||||||
|
const baseUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/${partialUrl}`;
|
||||||
|
|
||||||
|
if (presignToken) {
|
||||||
|
return `${baseUrl}?presignToken=${presignToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EnvelopeItemMetaUrlOptions = {
|
||||||
|
envelopeId: string;
|
||||||
|
token: string | undefined;
|
||||||
|
presignToken?: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the URL for fetching envelope metadata (page counts and dimensions).
|
||||||
|
*/
|
||||||
|
export const getEnvelopeItemMetaUrl = (options: EnvelopeItemMetaUrlOptions): string => {
|
||||||
|
const { envelopeId, token, presignToken } = options;
|
||||||
|
|
||||||
|
// Recipient token endpoint.
|
||||||
|
if (token) {
|
||||||
|
return `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelope/${envelopeId}/meta`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoint authenticated by session or presigned token.
|
||||||
|
const baseUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/meta`;
|
||||||
|
|
||||||
|
if (presignToken) {
|
||||||
|
return `${baseUrl}?presignToken=${presignToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEnvelopeItemPageImageS3Key = (
|
||||||
|
documentDataId: string,
|
||||||
|
pageIndex: number,
|
||||||
|
): string => {
|
||||||
|
// Sanity check incase someone passes in a base64 PDF somehow.
|
||||||
|
if (documentDataId.length > 100) {
|
||||||
|
throw new Error('Document data is too long to be a valid S3 key');
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseKey = documentDataId.split('/')[0];
|
||||||
|
|
||||||
|
return `${baseKey}/${pageIndex}.jpeg`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DocumentData" ADD COLUMN "metadata" JSONB;
|
||||||
@@ -487,11 +487,13 @@ enum DocumentSigningOrder {
|
|||||||
SEQUENTIAL
|
SEQUENTIAL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @zod.import(["import { ZDocumentDataMetaSchema } from '@documenso/lib/types/document-data';"])
|
||||||
model DocumentData {
|
model DocumentData {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
type DocumentDataType
|
type DocumentDataType
|
||||||
data String
|
data String
|
||||||
initialData String
|
initialData String
|
||||||
|
metadata Json? /// [DocumentDataMeta] @zod.custom.use(ZDocumentDataMetaSchema)
|
||||||
envelopeItem EnvelopeItem?
|
envelopeItem EnvelopeItem?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+2
@@ -4,6 +4,7 @@ import type {
|
|||||||
TDocumentAuthOptions,
|
TDocumentAuthOptions,
|
||||||
TRecipientAuthOptions,
|
TRecipientAuthOptions,
|
||||||
} from '@documenso/lib/types/document-auth';
|
} from '@documenso/lib/types/document-auth';
|
||||||
|
import type { TDocumentDataMeta } from '@documenso/lib/types/document-data';
|
||||||
import type { TDocumentEmailSettings } from '@documenso/lib/types/document-email';
|
import type { TDocumentEmailSettings } from '@documenso/lib/types/document-email';
|
||||||
import type { TDocumentFormValues } from '@documenso/lib/types/document-form-values';
|
import type { TDocumentFormValues } from '@documenso/lib/types/document-form-values';
|
||||||
import type { TEnvelopeAttachmentType } from '@documenso/lib/types/envelope-attachment';
|
import type { TEnvelopeAttachmentType } from '@documenso/lib/types/envelope-attachment';
|
||||||
@@ -29,6 +30,7 @@ declare global {
|
|||||||
type EnvelopeAttachmentType = TEnvelopeAttachmentType;
|
type EnvelopeAttachmentType = TEnvelopeAttachmentType;
|
||||||
|
|
||||||
type DefaultRecipient = TDefaultRecipient;
|
type DefaultRecipient = TDefaultRecipient;
|
||||||
|
type DocumentDataMeta = TDocumentDataMeta;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,14 @@ export const getDocumentByTokenRoute = authenticatedProcedure
|
|||||||
include: {
|
include: {
|
||||||
envelopeItems: {
|
envelopeItems: {
|
||||||
include: {
|
include: {
|
||||||
documentData: true,
|
documentData: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
type: true,
|
||||||
|
data: true,
|
||||||
|
initialData: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ export const ZGetDocumentByTokenRequestSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const ZGetDocumentByTokenResponseSchema = z.object({
|
export const ZGetDocumentByTokenResponseSchema = z.object({
|
||||||
documentData: DocumentDataSchema,
|
documentData: DocumentDataSchema.pick({
|
||||||
|
id: true,
|
||||||
|
type: true,
|
||||||
|
data: true,
|
||||||
|
initialData: true,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TGetDocumentByTokenRequest = z.infer<typeof ZGetDocumentByTokenRequestSchema>;
|
export type TGetDocumentByTokenRequest = z.infer<typeof ZGetDocumentByTokenRequestSchema>;
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import { router } from '../trpc';
|
import { router } from '../trpc';
|
||||||
import { createEmbeddingDocumentRoute } from './create-embedding-document';
|
import { createEmbeddingDocumentRoute } from './create-embedding-document';
|
||||||
|
import { createEmbeddingEnvelopeRoute } from './create-embedding-envelope';
|
||||||
import { createEmbeddingPresignTokenRoute } from './create-embedding-presign-token';
|
import { createEmbeddingPresignTokenRoute } from './create-embedding-presign-token';
|
||||||
import { createEmbeddingTemplateRoute } from './create-embedding-template';
|
import { createEmbeddingTemplateRoute } from './create-embedding-template';
|
||||||
import { getMultiSignDocumentRoute } from './get-multi-sign-document';
|
import { getMultiSignDocumentRoute } from './get-multi-sign-document';
|
||||||
import { updateEmbeddingDocumentRoute } from './update-embedding-document';
|
import { updateEmbeddingDocumentRoute } from './update-embedding-document';
|
||||||
|
import { updateEmbeddingEnvelopeRoute } from './update-embedding-envelope';
|
||||||
import { updateEmbeddingTemplateRoute } from './update-embedding-template';
|
import { updateEmbeddingTemplateRoute } from './update-embedding-template';
|
||||||
import { verifyEmbeddingPresignTokenRoute } from './verify-embedding-presign-token';
|
import { verifyEmbeddingPresignTokenRoute } from './verify-embedding-presign-token';
|
||||||
|
|
||||||
export const embeddingPresignRouter = router({
|
export const embeddingPresignRouter = router({
|
||||||
createEmbeddingPresignToken: createEmbeddingPresignTokenRoute,
|
createEmbeddingPresignToken: createEmbeddingPresignTokenRoute,
|
||||||
verifyEmbeddingPresignToken: verifyEmbeddingPresignTokenRoute,
|
verifyEmbeddingPresignToken: verifyEmbeddingPresignTokenRoute,
|
||||||
|
createEmbeddingEnvelope: createEmbeddingEnvelopeRoute,
|
||||||
createEmbeddingDocument: createEmbeddingDocumentRoute,
|
createEmbeddingDocument: createEmbeddingDocumentRoute,
|
||||||
createEmbeddingTemplate: createEmbeddingTemplateRoute,
|
createEmbeddingTemplate: createEmbeddingTemplateRoute,
|
||||||
|
updateEmbeddingEnvelope: updateEmbeddingEnvelopeRoute,
|
||||||
updateEmbeddingDocument: updateEmbeddingDocumentRoute,
|
updateEmbeddingDocument: updateEmbeddingDocumentRoute,
|
||||||
updateEmbeddingTemplate: updateEmbeddingTemplateRoute,
|
updateEmbeddingTemplate: updateEmbeddingTemplateRoute,
|
||||||
// applyMultiSignSignature: applyMultiSignSignatureRoute,
|
// applyMultiSignSignature: applyMultiSignSignatureRoute,
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||||
|
|
||||||
|
import { createEnvelopeRouteCaller } from '../envelope-router/create-envelope';
|
||||||
|
import { procedure } from '../trpc';
|
||||||
|
import {
|
||||||
|
ZCreateEmbeddingEnvelopeRequestSchema,
|
||||||
|
ZCreateEmbeddingEnvelopeResponseSchema,
|
||||||
|
} from './create-embedding-envelope.types';
|
||||||
|
|
||||||
|
export const createEmbeddingEnvelopeRoute = procedure
|
||||||
|
.input(ZCreateEmbeddingEnvelopeRequestSchema)
|
||||||
|
.output(ZCreateEmbeddingEnvelopeResponseSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const { req } = ctx;
|
||||||
|
|
||||||
|
const authorizationHeader = req.headers.get('authorization');
|
||||||
|
|
||||||
|
const [presignToken] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
|
||||||
|
|
||||||
|
if (!presignToken) {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
|
message: 'No presign token provided',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiToken = await verifyEmbeddingPresignToken({ token: presignToken });
|
||||||
|
|
||||||
|
const { userId, teamId } = apiToken;
|
||||||
|
|
||||||
|
return await createEnvelopeRouteCaller({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
input,
|
||||||
|
options: {
|
||||||
|
// Default recipients should be added on the frontend automatically for embeds.
|
||||||
|
bypassDefaultRecipients: true,
|
||||||
|
},
|
||||||
|
apiRequestMetadata: ctx.metadata,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import {
|
||||||
|
ZCreateEnvelopeRequestSchema,
|
||||||
|
ZCreateEnvelopeResponseSchema,
|
||||||
|
} from '../envelope-router/create-envelope.types';
|
||||||
|
|
||||||
|
export const ZCreateEmbeddingEnvelopeRequestSchema = ZCreateEnvelopeRequestSchema;
|
||||||
|
|
||||||
|
export const ZCreateEmbeddingEnvelopeResponseSchema = ZCreateEnvelopeResponseSchema;
|
||||||
@@ -24,7 +24,9 @@ export const ZCreateEmbeddingPresignTokenRequestSchema = z.object({
|
|||||||
scope: z
|
scope: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Resource restriction. Example: documentId:1, templateId:2'),
|
.describe(
|
||||||
|
'Resource restriction. V1 embeds only support documentId:1, templateId:2. V2 embeds only support envelopeId:envelope_123',
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZCreateEmbeddingPresignTokenResponseSchema = z.object({
|
export const ZCreateEmbeddingPresignTokenResponseSchema = z.object({
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||||
|
import pMap from 'p-map';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||||
|
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||||
|
import { updateEnvelope } from '@documenso/lib/server-only/envelope/update-envelope';
|
||||||
|
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
||||||
|
import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template';
|
||||||
|
import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients';
|
||||||
|
import { setTemplateRecipients } from '@documenso/lib/server-only/recipient/set-template-recipients';
|
||||||
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
|
import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
|
||||||
|
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { UNSAFE_createEnvelopeItems } from '@documenso/trpc/server/envelope-router/create-envelope-items';
|
||||||
|
import { UNSAFE_deleteEnvelopeItem } from '@documenso/trpc/server/envelope-router/delete-envelope-item';
|
||||||
|
import { UNSAFE_updateEnvelopeItems } from '@documenso/trpc/server/envelope-router/update-envelope-items';
|
||||||
|
|
||||||
|
import { procedure } from '../trpc';
|
||||||
|
import {
|
||||||
|
ZUpdateEmbeddingEnvelopeRequestSchema,
|
||||||
|
ZUpdateEmbeddingEnvelopeResponseSchema,
|
||||||
|
} from './update-embedding-envelope.types';
|
||||||
|
|
||||||
|
export const updateEmbeddingEnvelopeRoute = procedure
|
||||||
|
.input(ZUpdateEmbeddingEnvelopeRequestSchema)
|
||||||
|
.output(ZUpdateEmbeddingEnvelopeResponseSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const { payload, files } = input;
|
||||||
|
const { envelopeId, data, meta } = payload;
|
||||||
|
|
||||||
|
ctx.logger.info({
|
||||||
|
input: {
|
||||||
|
envelopeId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const authorizationHeader = ctx.req.headers.get('authorization');
|
||||||
|
|
||||||
|
const [presignToken] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
|
||||||
|
|
||||||
|
if (!presignToken) {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
|
message: 'No presign token provided',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiToken = await verifyEmbeddingPresignToken({
|
||||||
|
token: presignToken,
|
||||||
|
scope: `envelopeId:${envelopeId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||||
|
id: {
|
||||||
|
type: 'envelopeId',
|
||||||
|
id: envelopeId,
|
||||||
|
},
|
||||||
|
type: null, // Allow updating both documents and templates.
|
||||||
|
userId: apiToken.userId,
|
||||||
|
teamId: apiToken.teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const envelope = await prisma.envelope.findFirst({
|
||||||
|
where: envelopeWhereInput,
|
||||||
|
include: {
|
||||||
|
envelopeItems: true,
|
||||||
|
team: {
|
||||||
|
select: {
|
||||||
|
organisation: {
|
||||||
|
select: {
|
||||||
|
organisationClaim: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
recipients: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!envelope) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Envelope not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.status === DocumentStatus.COMPLETED) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Cannot modify completed envelope',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Update the envelope items.
|
||||||
|
const envelopeItemsToUpdate: EnvelopeItemUpdateOptions[] = [];
|
||||||
|
const envelopeItemsToCreate: EnvelopeItemCreateOptions[] = [];
|
||||||
|
|
||||||
|
// Sort and group envelope items to update and create.
|
||||||
|
data.envelopeItems.forEach((item) => {
|
||||||
|
const isNewEnvelopeItem = item.id.startsWith(PRESIGNED_ENVELOPE_ITEM_ID_PREFIX);
|
||||||
|
|
||||||
|
// Handle existing envelope items.
|
||||||
|
if (!isNewEnvelopeItem) {
|
||||||
|
const envelopeItem = envelope.envelopeItems.find(
|
||||||
|
(envelopeItem) => envelopeItem.id === item.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!envelopeItem) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Envelope item not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasEnvelopeItemChanged =
|
||||||
|
envelopeItem.title !== item.title || envelopeItem.order !== item.order;
|
||||||
|
|
||||||
|
if (hasEnvelopeItemChanged) {
|
||||||
|
envelopeItemsToUpdate.push({
|
||||||
|
envelopeItemId: envelopeItem.id,
|
||||||
|
title: item.title,
|
||||||
|
order: item.order,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return to continue loop.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEnvelopeItemFile = item.index !== undefined ? files[item.index] : undefined;
|
||||||
|
|
||||||
|
if (!newEnvelopeItemFile) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
|
message: 'Invalid envelope item index',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle not yet uploaded envelope items.
|
||||||
|
envelopeItemsToCreate.push({
|
||||||
|
embeddedEnvelopeItemId: item.id,
|
||||||
|
title: item.title,
|
||||||
|
order: item.order,
|
||||||
|
file: newEnvelopeItemFile,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete envelope items that have been removed from the payload.
|
||||||
|
const envelopeItemIdsToDelete = envelope.envelopeItems
|
||||||
|
.filter((item) => !data.envelopeItems.some((i) => i.id === item.id))
|
||||||
|
.map((item) => item.id);
|
||||||
|
|
||||||
|
const willEnvelopeItemsBeModified =
|
||||||
|
envelopeItemIdsToDelete.length > 0 ||
|
||||||
|
envelopeItemsToCreate.length > 0 ||
|
||||||
|
envelopeItemsToUpdate.length > 0;
|
||||||
|
|
||||||
|
const organisationClaim = envelope.team.organisation.organisationClaim;
|
||||||
|
const resultingEnvelopeItemCount =
|
||||||
|
envelope.envelopeItems.length - envelopeItemIdsToDelete.length + envelopeItemsToCreate.length;
|
||||||
|
|
||||||
|
if (resultingEnvelopeItemCount > organisationClaim.envelopeItemCount) {
|
||||||
|
throw new AppError('ENVELOPE_ITEM_LIMIT_EXCEEDED', {
|
||||||
|
message: `You cannot upload more than ${organisationClaim.envelopeItemCount} envelope items`,
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be safe to use stale envelope.recipients since only signed or sent
|
||||||
|
// recipients affect the outcome.
|
||||||
|
if (willEnvelopeItemsBeModified && !canEnvelopeItemsBeModified(envelope, envelope.recipients)) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Envelope item is not editable',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelopeItemIdsToDelete.length > 0) {
|
||||||
|
console.log('[DEBUG]: Deleting envelope items', envelopeItemIdsToDelete);
|
||||||
|
|
||||||
|
await pMap(
|
||||||
|
envelopeItemIdsToDelete,
|
||||||
|
async (envelopeItemId) => {
|
||||||
|
await UNSAFE_deleteEnvelopeItem({
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
envelopeItemId,
|
||||||
|
user: apiToken.user,
|
||||||
|
apiRequestMetadata: ctx.metadata,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ concurrency: 2 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapping for the client side embedded prefix envelope item IDs to the real envelope item IDs.
|
||||||
|
const embeddedEnvelopeItemIdMapping: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Create new envelope items.
|
||||||
|
if (envelopeItemsToCreate.length > 0) {
|
||||||
|
console.log('[DEBUG]: Creating envelope items', envelopeItemsToCreate);
|
||||||
|
|
||||||
|
const createdEnvelopeItems = await UNSAFE_createEnvelopeItems({
|
||||||
|
files: envelopeItemsToCreate.map((item) => ({
|
||||||
|
clientId: item.embeddedEnvelopeItemId,
|
||||||
|
file: item.file,
|
||||||
|
orderOverride: item.order,
|
||||||
|
})),
|
||||||
|
envelope: {
|
||||||
|
...envelope,
|
||||||
|
// Purposefully putting empty recipients here since placeholders should automatically injected on the client side for
|
||||||
|
// embeded purposes. Todo: Embeds - (Not implemeneted yet)
|
||||||
|
recipients: [],
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: apiToken.user.id,
|
||||||
|
name: apiToken.user.name,
|
||||||
|
email: apiToken.user.email,
|
||||||
|
},
|
||||||
|
apiRequestMetadata: ctx.metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build the map from the envelope item order.
|
||||||
|
createdEnvelopeItems.forEach((item) => {
|
||||||
|
if (!item.clientId) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Client ID not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
embeddedEnvelopeItemIdMapping[item.clientId] = item.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelopeItemsToUpdate.length > 0) {
|
||||||
|
console.log('[DEBUG]: Updating envelope items', envelopeItemsToUpdate);
|
||||||
|
|
||||||
|
await UNSAFE_updateEnvelopeItems({
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
data: envelopeItemsToUpdate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Update the general envelope data and meta.
|
||||||
|
await updateEnvelope({
|
||||||
|
userId: apiToken.userId,
|
||||||
|
teamId: apiToken.teamId,
|
||||||
|
id: {
|
||||||
|
type: 'envelopeId',
|
||||||
|
id: envelope.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
title: data.title,
|
||||||
|
externalId: data.externalId,
|
||||||
|
visibility: data.visibility,
|
||||||
|
globalAccessAuth: data.globalAccessAuth,
|
||||||
|
globalActionAuth: data.globalActionAuth,
|
||||||
|
folderId: data.folderId,
|
||||||
|
},
|
||||||
|
meta,
|
||||||
|
requestMetadata: ctx.metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: Update the recipients
|
||||||
|
const recipientsWithClientId = data.recipients.map((recipient) => ({
|
||||||
|
...recipient,
|
||||||
|
clientId: nanoid(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { recipients: updatedRecipients } = await match(envelope.type)
|
||||||
|
.with(EnvelopeType.DOCUMENT, async () =>
|
||||||
|
setDocumentRecipients({
|
||||||
|
userId: apiToken.userId,
|
||||||
|
teamId: apiToken.teamId,
|
||||||
|
id: {
|
||||||
|
type: 'envelopeId',
|
||||||
|
id: envelope.id,
|
||||||
|
},
|
||||||
|
recipients: recipientsWithClientId.map((recipient) => ({
|
||||||
|
id: recipient.id,
|
||||||
|
clientId: recipient.clientId,
|
||||||
|
email: recipient.email,
|
||||||
|
name: recipient.name ?? '',
|
||||||
|
role: recipient.role,
|
||||||
|
signingOrder: recipient.signingOrder,
|
||||||
|
})),
|
||||||
|
requestMetadata: ctx.metadata,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.with(EnvelopeType.TEMPLATE, async () =>
|
||||||
|
setTemplateRecipients({
|
||||||
|
userId: apiToken.userId,
|
||||||
|
teamId: apiToken.teamId,
|
||||||
|
id: {
|
||||||
|
type: 'envelopeId',
|
||||||
|
id: envelope.id,
|
||||||
|
},
|
||||||
|
recipients: recipientsWithClientId.map((recipient) => ({
|
||||||
|
id: recipient.id,
|
||||||
|
clientId: recipient.clientId,
|
||||||
|
email: recipient.email,
|
||||||
|
name: recipient.name ?? '',
|
||||||
|
role: recipient.role,
|
||||||
|
signingOrder: recipient.signingOrder,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.exhaustive();
|
||||||
|
|
||||||
|
// Step 4: Update the fields.
|
||||||
|
const fields = recipientsWithClientId.flatMap((recipient) => {
|
||||||
|
const recipientId = updatedRecipients.find((r) => r.clientId === recipient.clientId)?.id;
|
||||||
|
|
||||||
|
if (!recipientId) {
|
||||||
|
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||||
|
message: 'Recipient not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (recipient.fields ?? []).map((field) => {
|
||||||
|
let envelopeItemId = field.envelopeItemId;
|
||||||
|
|
||||||
|
if (envelopeItemId.startsWith(PRESIGNED_ENVELOPE_ITEM_ID_PREFIX)) {
|
||||||
|
envelopeItemId = embeddedEnvelopeItemIdMapping[envelopeItemId];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!envelopeItemId) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Envelope item not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
recipientId,
|
||||||
|
envelopeItemId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await match(envelope.type)
|
||||||
|
.with(EnvelopeType.DOCUMENT, async () =>
|
||||||
|
setFieldsForDocument({
|
||||||
|
userId: apiToken.userId,
|
||||||
|
teamId: apiToken.teamId,
|
||||||
|
id: {
|
||||||
|
type: 'envelopeId',
|
||||||
|
id: envelopeId,
|
||||||
|
},
|
||||||
|
fields: fields.map((field) => ({
|
||||||
|
...field,
|
||||||
|
pageNumber: field.page,
|
||||||
|
pageX: field.positionX,
|
||||||
|
pageY: field.positionY,
|
||||||
|
pageWidth: field.width,
|
||||||
|
pageHeight: field.height,
|
||||||
|
})),
|
||||||
|
requestMetadata: ctx.metadata,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.with(EnvelopeType.TEMPLATE, async () =>
|
||||||
|
setFieldsForTemplate({
|
||||||
|
userId: apiToken.userId,
|
||||||
|
teamId: apiToken.teamId,
|
||||||
|
id: {
|
||||||
|
type: 'envelopeId',
|
||||||
|
id: envelopeId,
|
||||||
|
},
|
||||||
|
fields: fields.map((field) => ({
|
||||||
|
...field,
|
||||||
|
pageNumber: field.page,
|
||||||
|
pageX: field.positionX,
|
||||||
|
pageY: field.positionY,
|
||||||
|
pageWidth: field.width,
|
||||||
|
pageHeight: field.height,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.exhaustive();
|
||||||
|
});
|
||||||
|
|
||||||
|
type EnvelopeItemUpdateOptions = {
|
||||||
|
envelopeItemId: string;
|
||||||
|
title?: string;
|
||||||
|
order?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EnvelopeItemCreateOptions = {
|
||||||
|
embeddedEnvelopeItemId: string;
|
||||||
|
title: string;
|
||||||
|
order: number;
|
||||||
|
file: File;
|
||||||
|
};
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { zfd } from 'zod-form-data';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ZDocumentAccessAuthTypesSchema,
|
||||||
|
ZDocumentActionAuthTypesSchema,
|
||||||
|
} from '@documenso/lib/types/document-auth';
|
||||||
|
import { ZDocumentMetaUpdateSchema } from '@documenso/lib/types/document-meta';
|
||||||
|
import {
|
||||||
|
ZClampedFieldHeightSchema,
|
||||||
|
ZClampedFieldPositionXSchema,
|
||||||
|
ZClampedFieldPositionYSchema,
|
||||||
|
ZClampedFieldWidthSchema,
|
||||||
|
ZFieldPageNumberSchema,
|
||||||
|
} from '@documenso/lib/types/field';
|
||||||
|
import { ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
|
import { ZSetEnvelopeRecipientSchema } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
|
||||||
|
|
||||||
|
import { zodFormData } from '../../utils/zod-form-data';
|
||||||
|
import {
|
||||||
|
ZDocumentExternalIdSchema,
|
||||||
|
ZDocumentTitleSchema,
|
||||||
|
ZDocumentVisibilitySchema,
|
||||||
|
} from '../document-router/schema';
|
||||||
|
|
||||||
|
export const ZUpdateEmbeddingEnvelopePayloadSchema = z.object({
|
||||||
|
envelopeId: z.string(),
|
||||||
|
data: z.object({
|
||||||
|
title: ZDocumentTitleSchema.optional(),
|
||||||
|
externalId: ZDocumentExternalIdSchema.nullish(),
|
||||||
|
visibility: ZDocumentVisibilitySchema.optional(),
|
||||||
|
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
|
||||||
|
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
|
||||||
|
folderId: z.string().nullish(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list of envelope items that are part of the envelope.
|
||||||
|
*
|
||||||
|
* Any missing IDs will be treated as deleting the envelope item.
|
||||||
|
*/
|
||||||
|
envelopeItems: z
|
||||||
|
.object({
|
||||||
|
/**
|
||||||
|
* This is not necesssarily a real id, it can be a temporary id for the envelope item.
|
||||||
|
*/
|
||||||
|
id: z.string(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The title of the envelope item.
|
||||||
|
*/
|
||||||
|
title: z.string(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The order of the envelope item in the envelope.
|
||||||
|
*/
|
||||||
|
order: z.number().int().min(0),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The file index for items that are not yet uploaded.
|
||||||
|
*/
|
||||||
|
index: z.number().int().min(0).optional(),
|
||||||
|
})
|
||||||
|
.array(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a set command.
|
||||||
|
*/
|
||||||
|
recipients: ZSetEnvelopeRecipientSchema.extend({
|
||||||
|
fields: ZEnvelopeFieldAndMetaSchema.and(
|
||||||
|
z.object({
|
||||||
|
id: z.number().optional(),
|
||||||
|
page: ZFieldPageNumberSchema,
|
||||||
|
positionX: ZClampedFieldPositionXSchema,
|
||||||
|
positionY: ZClampedFieldPositionYSchema,
|
||||||
|
width: ZClampedFieldWidthSchema,
|
||||||
|
height: ZClampedFieldHeightSchema,
|
||||||
|
envelopeItemId: z.string(),
|
||||||
|
}),
|
||||||
|
).array(),
|
||||||
|
}).array(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
meta: ZDocumentMetaUpdateSchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZUpdateEmbeddingEnvelopeRequestSchema = zodFormData({
|
||||||
|
payload: zfd.json(ZUpdateEmbeddingEnvelopePayloadSchema),
|
||||||
|
files: zfd.repeatableOfType(zfd.file()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZUpdateEmbeddingEnvelopeResponseSchema = z.void();
|
||||||
|
|
||||||
|
export type TUpdateEmbeddingEnvelopePayload = z.infer<typeof ZUpdateEmbeddingEnvelopePayloadSchema>;
|
||||||
|
export type TUpdateEmbeddingEnvelopeRequest = z.infer<typeof ZUpdateEmbeddingEnvelopeRequestSchema>;
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Envelope, EnvelopeItem, Recipient } from '@prisma/client';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +10,7 @@ import { findRecipientByPlaceholder } from '@documenso/lib/server-only/pdf/helpe
|
|||||||
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
|
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
|
||||||
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
|
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import { prefixedId } from '@documenso/lib/universal/id';
|
import { prefixedId } from '@documenso/lib/universal/id';
|
||||||
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||||
@@ -91,133 +94,186 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// For each file: normalize, extract & clean placeholders, then upload.
|
const result = await UNSAFE_createEnvelopeItems({
|
||||||
const envelopeItems = await Promise.all(
|
files: files.map((file) => ({
|
||||||
files.map(async (file) => {
|
file,
|
||||||
let buffer = Buffer.from(await file.arrayBuffer());
|
})),
|
||||||
|
envelope,
|
||||||
if (envelope.formValues) {
|
user: {
|
||||||
buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues });
|
id: user.id,
|
||||||
}
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
const normalized = await normalizePdf(buffer, {
|
},
|
||||||
flattenForm: envelope.type !== 'TEMPLATE',
|
apiRequestMetadata: metadata,
|
||||||
});
|
|
||||||
|
|
||||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
|
||||||
|
|
||||||
const { id: documentDataId } = await putPdfFileServerSide({
|
|
||||||
name: file.name,
|
|
||||||
type: 'application/pdf',
|
|
||||||
arrayBuffer: async () => Promise.resolve(cleanedPdf),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: file.name,
|
|
||||||
documentDataId,
|
|
||||||
placeholders,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentHighestOrderValue =
|
|
||||||
envelope.envelopeItems[envelope.envelopeItems.length - 1]?.order ?? 1;
|
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
|
||||||
const createdItems = await tx.envelopeItem.createManyAndReturn({
|
|
||||||
data: envelopeItems.map((item) => ({
|
|
||||||
id: prefixedId('envelope_item'),
|
|
||||||
envelopeId,
|
|
||||||
title: item.title,
|
|
||||||
documentDataId: item.documentDataId,
|
|
||||||
order: currentHighestOrderValue + 1,
|
|
||||||
})),
|
|
||||||
include: {
|
|
||||||
documentData: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.documentAuditLog.createMany({
|
|
||||||
data: createdItems.map((item) =>
|
|
||||||
createDocumentAuditLogData({
|
|
||||||
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_CREATED,
|
|
||||||
envelopeId: envelope.id,
|
|
||||||
data: {
|
|
||||||
envelopeItemId: item.id,
|
|
||||||
envelopeItemTitle: item.title,
|
|
||||||
},
|
|
||||||
user: {
|
|
||||||
name: user.name,
|
|
||||||
email: user.email,
|
|
||||||
},
|
|
||||||
requestMetadata: metadata.requestMetadata,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create fields from placeholders if the envelope already has recipients.
|
|
||||||
if (envelope.recipients.length > 0) {
|
|
||||||
const orderedRecipients = [...envelope.recipients].sort((a, b) => {
|
|
||||||
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
|
||||||
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
|
||||||
|
|
||||||
if (aOrder !== bOrder) {
|
|
||||||
return aOrder - bOrder;
|
|
||||||
}
|
|
||||||
|
|
||||||
return a.id - b.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const uploadedItem of envelopeItems) {
|
|
||||||
if (!uploadedItem.placeholders || uploadedItem.placeholders.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdItem = createdItems.find(
|
|
||||||
(ci) => ci.documentDataId === uploadedItem.documentDataId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!createdItem) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldsToCreate = convertPlaceholdersToFieldInputs(
|
|
||||||
uploadedItem.placeholders,
|
|
||||||
(recipientPlaceholder, placeholder) =>
|
|
||||||
findRecipientByPlaceholder(
|
|
||||||
recipientPlaceholder,
|
|
||||||
placeholder,
|
|
||||||
orderedRecipients,
|
|
||||||
orderedRecipients,
|
|
||||||
),
|
|
||||||
createdItem.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (fieldsToCreate.length > 0) {
|
|
||||||
await tx.field.createMany({
|
|
||||||
data: fieldsToCreate.map((field) => ({
|
|
||||||
envelopeId: envelope.id,
|
|
||||||
envelopeItemId: createdItem.id,
|
|
||||||
recipientId: field.recipientId,
|
|
||||||
type: field.type,
|
|
||||||
page: field.page,
|
|
||||||
positionX: field.positionX,
|
|
||||||
positionY: field.positionY,
|
|
||||||
width: field.width,
|
|
||||||
height: field.height,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: field.fieldMeta || undefined,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return createdItems;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: result,
|
data: result,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type UnsafeCreateEnvelopeItemsOptions = {
|
||||||
|
files: {
|
||||||
|
clientId?: string;
|
||||||
|
file: File;
|
||||||
|
orderOverride?: number;
|
||||||
|
}[];
|
||||||
|
envelope: Envelope & {
|
||||||
|
envelopeItems: EnvelopeItem[];
|
||||||
|
recipients: Recipient[];
|
||||||
|
};
|
||||||
|
user: {
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
apiRequestMetadata: ApiRequestMetadata;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create envelope items.
|
||||||
|
*
|
||||||
|
* It is assumed all prior validation has been completed.
|
||||||
|
*/
|
||||||
|
export const UNSAFE_createEnvelopeItems = async ({
|
||||||
|
files,
|
||||||
|
envelope,
|
||||||
|
user,
|
||||||
|
apiRequestMetadata,
|
||||||
|
}: UnsafeCreateEnvelopeItemsOptions) => {
|
||||||
|
const currentHighestOrderValue =
|
||||||
|
envelope.envelopeItems[envelope.envelopeItems.length - 1]?.order ?? 1;
|
||||||
|
|
||||||
|
// For each file: normalize, extract & clean placeholders, then upload.
|
||||||
|
const envelopeItemsToCreate = await Promise.all(
|
||||||
|
files.map(async ({ file, orderOverride, clientId }, index) => {
|
||||||
|
let buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
|
if (envelope.formValues) {
|
||||||
|
buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues });
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = await normalizePdf(buffer, {
|
||||||
|
flattenForm: envelope.type !== 'TEMPLATE',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
||||||
|
|
||||||
|
const { id: documentDataId } = await putPdfFileServerSide({
|
||||||
|
name: file.name,
|
||||||
|
type: 'application/pdf',
|
||||||
|
arrayBuffer: async () => Promise.resolve(cleanedPdf),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: prefixedId('envelope_item'),
|
||||||
|
title: file.name,
|
||||||
|
clientId,
|
||||||
|
documentDataId,
|
||||||
|
placeholders,
|
||||||
|
order: orderOverride ?? currentHighestOrderValue + index + 1,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
const createdItems = await tx.envelopeItem.createManyAndReturn({
|
||||||
|
data: envelopeItemsToCreate.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
title: item.title,
|
||||||
|
documentDataId: item.documentDataId,
|
||||||
|
order: item.order,
|
||||||
|
})),
|
||||||
|
include: {
|
||||||
|
documentData: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.documentAuditLog.createMany({
|
||||||
|
data: createdItems.map((item) =>
|
||||||
|
createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_CREATED,
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
data: {
|
||||||
|
envelopeItemId: item.id,
|
||||||
|
envelopeItemTitle: item.title,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
},
|
||||||
|
requestMetadata: apiRequestMetadata.requestMetadata,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create fields from placeholders if the envelope already has recipients.
|
||||||
|
if (envelope.recipients.length > 0) {
|
||||||
|
const orderedRecipients = [...envelope.recipients].sort((a, b) => {
|
||||||
|
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
if (aOrder !== bOrder) {
|
||||||
|
return aOrder - bOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.id - b.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const uploadedItem of envelopeItemsToCreate) {
|
||||||
|
if (!uploadedItem.placeholders || uploadedItem.placeholders.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdItem = createdItems.find(
|
||||||
|
(ci) => ci.documentDataId === uploadedItem.documentDataId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!createdItem) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldsToCreate = convertPlaceholdersToFieldInputs(
|
||||||
|
uploadedItem.placeholders,
|
||||||
|
(recipientPlaceholder, placeholder) =>
|
||||||
|
findRecipientByPlaceholder(
|
||||||
|
recipientPlaceholder,
|
||||||
|
placeholder,
|
||||||
|
orderedRecipients,
|
||||||
|
orderedRecipients,
|
||||||
|
),
|
||||||
|
createdItem.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fieldsToCreate.length > 0) {
|
||||||
|
await tx.field.createMany({
|
||||||
|
data: fieldsToCreate.map((field) => ({
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
envelopeItemId: createdItem.id,
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
type: field.type,
|
||||||
|
page: field.page,
|
||||||
|
positionX: field.positionX,
|
||||||
|
positionY: field.positionY,
|
||||||
|
width: field.width,
|
||||||
|
height: field.height,
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
fieldMeta: field.fieldMeta || undefined,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdItems.map((item) => {
|
||||||
|
const clientId = envelopeItemsToCreate.find((file) => file.id === item.id)?.clientId;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
clientId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user