Compare commits

..
Author SHA1 Message Date
David Nguyen 724f5f62c5 Merge branch 'main' into feat/add-content-fields 2026-09-29 17:03:35 +10:00
David Nguyen 58b5e6b5d3 fix: embed z-index 2026-09-29 16:53:28 +10:00
David Nguyen 573c928a0e feat: add recipient grouping (#3319) 2026-09-29 16:52:43 +10:00
David Nguyen d87ac2b552 feat: add embed content support 2026-09-29 16:21:48 +10:00
Lucas Smith be94bddd40 fix: use legacy pdfjs build for older devices (#3410) 2026-09-29 16:13:39 +10:00
Lucas Smith 5a123be46c fix: embed signing completion and reload states (#3409)
Send completed/rejected events when reopening an actioned v1 embed,
show the completed page after signing in v2, and tidy the completed
page.
2026-09-29 15:44:25 +10:00
Lucas Smith 586b1f5cb5 v2.19.0 2026-09-29 14:37:03 +10:00
David Nguyen b90aa86e24 fix: test 2026-09-27 22:04:46 +10:00
David Nguyen 3c84d25d5a fix: various improvements 2026-09-27 20:08:47 +10:00
David Nguyen c96ee7c84f fix: correctly deselect settings when changing envelope items 2026-09-26 16:47:27 +10:00
David Nguyen 941f4a1e4c fix: auto show and hide editor action bar 2026-09-26 16:33:03 +10:00
David Nguyen 30a0979573 fix: stuff 2026-09-26 12:23:47 +10:00
Lucas Smith a1d4bec143 fix: accept owner-password protected pdfs (#3396)
Strip encryption from PDFs that open with an empty user password via
libpdf's ignorePermissions, still rejecting user-password PDFs.

Upgrade @libpdf/core to 0.5.1, which also keeps overlapping and layered
text intact during text extraction.

Resolves #3303
2026-09-26 11:23:33 +10:00
David Nguyen 94793c7cce feat: add content fields 2026-09-25 12:02:52 +10:00
294 changed files with 28505 additions and 3008 deletions
@@ -120,20 +120,25 @@ Specific parts of the embed can be targeted with CSS classes for granular stylin
### Component Classes
| Class | Description |
| --------------------------------- | --------------------------------------------- |
| `.embed--Root` | Main container for the embedded experience |
| `.embed--DocumentContainer` | Container for the document and signing widget |
| `.embed--DocumentViewer` | Container for the document viewer |
| `.embed--DocumentWidget` | The signing widget container |
| `.embed--DocumentWidgetContainer` | Outer container for the signing widget |
| `.embed--DocumentWidgetHeader` | Header section of the signing widget |
| `.embed--DocumentWidgetContent` | Main content area of the signing widget |
| `.embed--DocumentWidgetForm` | Form section within the signing widget |
| `.embed--DocumentWidgetFooter` | Footer section of the signing widget |
| `.embed--WaitingForTurn` | Waiting screen when it is not the user's turn |
| `.embed--DocumentCompleted` | Completion screen after signing |
| `.field--FieldRootContainer` | Base container for document fields |
| Class | Description |
| ---------------------------------------- | --------------------------------------------- |
| `.embed--Root` | Main container for the embedded experience |
| `.embed--DocumentContainer` | Container for the document and signing widget |
| `.embed--DocumentViewer` | Container for the document viewer |
| `.embed--DocumentWidget` | The signing widget container |
| `.embed--DocumentWidgetContainer` | Outer container for the signing widget |
| `.embed--DocumentWidgetHeader` | Header section of the signing widget |
| `.embed--DocumentWidgetContent` | Main content area of the signing widget |
| `.embed--DocumentWidgetForm` | Form section within the signing widget |
| `.embed--DocumentWidgetFooter` | Footer section of the signing widget |
| `.embed--WaitingForTurn` | Waiting screen when it is not the user's turn |
| `.embed--DocumentCompleted` | Completion screen after signing |
| `.embed--DocumentCompletedCard` | Signature card on the completion screen |
| `.embed--DocumentCompletedTitle` | Title on the completion screen |
| `.embed--DocumentCompletedStatus` | Status line on the completion screen |
| `.embed--DocumentCompletedDescription` | Description text on the completion screen |
| `.embed--DocumentRejected` | Rejection screen after rejecting the document |
| `.field--FieldRootContainer` | Base container for document fields |
### Field Data Attributes
@@ -4,6 +4,7 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { getContentsMissingImages, resolveEnvelopeContentLimits } from '@documenso/lib/utils/envelope-content';
import { hasOverlappingFields } from '@documenso/lib/utils/fields-overlap';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { zEmail } from '@documenso/lib/utils/zod';
@@ -31,7 +32,7 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
import { AlertTriangleIcon, InfoIcon } from 'lucide-react';
@@ -162,6 +163,25 @@ export const EnvelopeDistributeDialog = ({
[envelope.fields],
);
/**
* Image related contents without an image render nothing once sent.
*/
const contentsMissingImages = useMemo(() => getContentsMissingImages(envelope.contents), [envelope.contents]);
/**
* An envelope can hold more contents than the organisation's plan allows,
* e.g. after the plan was lowered or a template was copied, so this is
* reported here rather than letting the send fail.
*/
const contentLimits = useMemo(
() =>
resolveEnvelopeContentLimits(
envelope.contents.map((content) => content.contentMeta.type),
organisation.organisationClaim,
),
[envelope.contents, organisation.organisationClaim],
);
const invalidEnvelopeCode = useMemo(() => {
if (recipientsMissingSignatureFields.length > 0) {
return 'MISSING_SIGNATURES';
@@ -175,8 +195,26 @@ export const EnvelopeDistributeDialog = ({
return 'MISSING_REQUIRED_EMAIL';
}
if (contentsMissingImages.length > 0) {
return 'MISSING_CONTENT_IMAGES';
}
if (contentLimits.isContentLimitExceeded) {
return 'ENVELOPE_CONTENT_LIMIT_EXCEEDED';
}
if (contentLimits.isImageLimitExceeded) {
return 'ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED';
}
return null;
}, [envelope.recipients, recipientsMissingRequiredEmail, recipientsMissingSignatureFields]);
}, [
envelope.recipients,
recipientsMissingRequiredEmail,
recipientsMissingSignatureFields,
contentsMissingImages,
contentLimits,
]);
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
try {
@@ -520,6 +558,33 @@ export const EnvelopeDistributeDialog = ({
</ul>
</AlertDescription>
))
.with('MISSING_CONTENT_IMAGES', () => (
<AlertDescription>
<Plural
value={contentsMissingImages.length}
one="An image content has no image. Upload an image for it, or remove it."
other="# image contents have no image. Upload an image for each, or remove them."
/>
</AlertDescription>
))
.with('ENVELOPE_CONTENT_LIMIT_EXCEEDED', () => (
<AlertDescription>
<Plural
value={contentLimits.contentLimit}
one="This envelope cannot have more than # content item. Remove some, or contact support if you need more."
other="This envelope cannot have more than # content items. Remove some, or contact support if you need more."
/>
</AlertDescription>
))
.with('ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED', () => (
<AlertDescription>
<Plural
value={contentLimits.imageLimit}
one="This envelope cannot have more than # image content item. Remove some, or contact support if you need more."
other="This envelope cannot have more than # image content items. Remove some, or contact support if you need more."
/>
</AlertDescription>
))
.exhaustive()}
</Alert>
@@ -44,6 +44,7 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
defaultValues: {
includeRecipients: true,
includeFields: true,
includeContents: true,
},
});
@@ -67,13 +68,14 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
});
const onDuplicate = async () => {
const { includeRecipients, includeFields } = form.getValues();
const { includeRecipients, includeFields, includeContents } = form.getValues();
try {
await duplicateEnvelope({
envelopeId,
includeRecipients,
includeFields: includeRecipients && includeFields,
includeContents,
});
} catch {
toast({
@@ -159,6 +161,23 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
</div>
)}
/>
<Controller
control={form.control}
name="includeContents"
render={({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="envelopeDuplicateIncludeContents"
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
<Label htmlFor="envelopeDuplicateIncludeContents">
<Trans>Include Contents</Trans>
</Label>
</div>
)}
/>
</div>
<DialogFooter>
@@ -1,6 +1,6 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { formatFileSize, megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { trpc } from '@documenso/trpc/react';
import { ZDocumentTitleSchema } from '@documenso/trpc/server/document-router/schema';
import type { TReplaceEnvelopeItemPdfPayload } from '@documenso/trpc/server/envelope-router/replace-envelope-item-pdf.types';
@@ -122,7 +122,7 @@ export const EnvelopeItemEditDialog = ({
toast({
title: t`Failed to read file`,
description: t`The file is not a valid PDF.`,
description: t`The file is not a valid PDF or is password protected.`,
variant: 'destructive',
});
}
@@ -195,18 +195,6 @@ export const EnvelopeItemEditDialog = ({
}
}, [isOpen, form, envelopeItem.title]);
const formatFileSize = (bytes: number) => {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<Dialog {...props} open={isOpen} onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
@@ -6,9 +6,9 @@ import {
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { DOCUMENT_TITLE_MAX_LENGTH } from '@documenso/trpc/server/document-router/schema';
import type { TUseEnvelopePayload } from '@documenso/trpc/server/envelope-router/use-envelope.types';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
@@ -66,6 +66,7 @@ const isUploadedFileNameUsable = (file?: File): file is File => {
const ZAddRecipientsForNewDocumentSchema = z
.object({
distributeDocument: z.boolean(),
includeContents: z.boolean().default(true),
useCustomDocument: z.boolean().default(false),
documentNameSource: z.enum([
DOCUMENT_NAME_SOURCE.TEMPLATE,
@@ -103,7 +104,6 @@ type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDoc
export type TemplateUseDialogProps = {
envelopeId: string;
templateId: number;
templateSigningOrder?: DocumentSigningOrder | null;
recipients: TRecipientLite[];
documentDistributionMethod?: DocumentDistributionMethod;
@@ -116,7 +116,6 @@ export function TemplateUseDialog({
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
documentRootPath,
envelopeId,
templateId,
templateSigningOrder,
trigger,
}: TemplateUseDialogProps) {
@@ -145,6 +144,7 @@ export function TemplateUseDialog({
const generateDefaultFormValues = () => {
return {
distributeDocument: false,
includeContents: true,
useCustomDocument: false,
documentNameSource: DOCUMENT_NAME_SOURCE.TEMPLATE,
customDocumentName: '',
@@ -180,7 +180,7 @@ export function TemplateUseDialog({
name: 'customDocumentData',
});
const { mutateAsync: createDocumentFromTemplate } = trpc.template.createDocumentFromTemplate.useMutation();
const { mutateAsync: createDocumentFromTemplate } = trpc.envelope.use.useMutation();
/**
* Track the most recently uploaded file so its name can be used as the document name.
@@ -217,24 +217,29 @@ export function TemplateUseDialog({
(item): item is typeof item & { data: File } => item.data !== undefined,
);
const customDocumentData = await Promise.all(
customFilesToUpload.map(async (item) => {
const customDocumentData = await putPdfFile(item.data);
return {
documentDataId: customDocumentData.id,
envelopeItemId: item.envelopeItemId,
};
}),
);
const { envelopeId } = await createDocumentFromTemplate({
templateId,
// The files are sent alongside the payload and mapped to their envelope
// items by index, which is robust against duplicate file names.
const payload: TUseEnvelopePayload = {
envelopeId,
recipients: data.recipients,
distributeDocument: data.distributeDocument,
customDocumentData,
includeContents: data.includeContents,
customDocumentData: customFilesToUpload.map((item, index) => ({
identifier: index,
envelopeItemId: item.envelopeItemId,
})),
...(documentTitle ? { override: { title: documentTitle } } : {}),
});
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
for (const item of customFilesToUpload) {
formData.append('files', item.data);
}
const { id: createdEnvelopeId } = await createDocumentFromTemplate(formData);
toast({
title: _(msg`Document created`),
@@ -242,7 +247,7 @@ export function TemplateUseDialog({
duration: 5000,
});
let documentPath = `${documentRootPath}/${envelopeId}`;
let documentPath = `${documentRootPath}/${createdEnvelopeId}`;
if (data.distributeDocument && documentDistributionMethod === DocumentDistributionMethod.NONE) {
documentPath += '?action=view-signing-links';
@@ -464,6 +469,46 @@ export function TemplateUseDialog({
</div>
)}
<FormField
control={form.control}
name="includeContents"
render={({ field }) => (
<FormItem>
<div className="flex flex-row items-center">
<Checkbox
id="includeContents"
className="h-5 w-5"
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
<label
className="ml-2 flex items-center text-muted-foreground text-sm"
htmlFor="includeContents"
>
<Trans>Include contents</Trans>
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>
Copy the text, shapes, images and other contents from the template onto the new
document.
</Trans>
</p>
<p>
<Trans>Uncheck this to create the document without them.</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="useCustomDocument"
@@ -1,4 +1,5 @@
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { formatFileSize } from '@documenso/lib/universal/unit-convertions';
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -98,16 +99,6 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
form.unregister('documentData');
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) {
return '0 Bytes';
}
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / 1024 ** i).toFixed(2))} ${sizes[i]}`;
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
@@ -2,6 +2,7 @@ import signingCelebration from '@documenso/assets/images/signing-celebration.png
import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { Trans } from '@lingui/react/macro';
import type { Signature } from '@prisma/client';
import { CheckCircle2Icon } from 'lucide-react';
export type EmbedDocumentCompletedPageProps = {
name?: string;
@@ -10,12 +11,8 @@ export type EmbedDocumentCompletedPageProps = {
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
return (
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<h3 className="font-semibold text-2xl text-foreground">
<Trans>Document Completed!</Trans>
</h3>
<div className="mt-8 w-full max-w-md">
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center overflow-hidden p-6">
<div className="embed--DocumentCompletedCard w-full max-w-sm md:max-w-md">
<SigningCard3D
className="mx-auto w-full"
name={name || 'Documenso'}
@@ -24,10 +21,19 @@ export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentComplet
/>
</div>
<p className="mt-8 max-w-[50ch] text-center text-muted-foreground text-sm">
<Trans>
The document is now completed, please follow any instructions provided within the parent application.
</Trans>
<h2 className="embed--DocumentCompletedTitle mt-8 max-w-[35ch] text-center font-semibold text-2xl text-foreground leading-normal md:text-3xl">
<Trans>Document Completed</Trans>
</h2>
<div className="embed--DocumentCompletedStatus mt-4 flex items-center text-center text-documenso-700">
<CheckCircle2Icon className="mr-2 h-5 w-5" />
<span className="text-sm">
<Trans>No further action is required</Trans>
</span>
</div>
<p className="embed--DocumentCompletedDescription mt-2.5 max-w-[50ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
<Trans>Please follow any instructions provided within the parent application.</Trans>
</p>
</div>
);
@@ -55,6 +55,7 @@ export type EmbedSignDocumentV1ClientPageProps = {
completedFields: DocumentField[];
metadata?: DocumentMeta | null;
isCompleted?: boolean;
isRejected?: boolean;
hidePoweredBy?: boolean;
allowWhitelabelling?: boolean;
allRecipients?: RecipientWithFields[];
@@ -70,6 +71,7 @@ export const EmbedSignDocumentV1ClientPage = ({
completedFields,
metadata,
isCompleted,
isRejected,
hidePoweredBy = false,
allowWhitelabelling = false,
allRecipients = [],
@@ -83,7 +85,9 @@ export const EmbedSignDocumentV1ClientPage = ({
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
const [hasRejectedDocument, setHasRejectedDocument] = useState(recipient.signingStatus === SigningStatus.REJECTED);
const [hasRejectedDocument, setHasRejectedDocument] = useState(
isRejected ?? recipient.signingStatus === SigningStatus.REJECTED,
);
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
allRecipients.length > 0 ? allRecipients[0].id : null,
);
@@ -263,6 +267,44 @@ export const EmbedSignDocumentV1ClientPage = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!window.parent) {
return;
}
if (hasRejectedDocument) {
window.parent.postMessage(
{
action: 'document-rejected',
data: {
token,
documentId,
recipientId: recipient.id,
},
},
'*',
);
return;
}
if (hasCompletedDocument) {
window.parent.postMessage(
{
action: 'document-completed',
data: {
token,
documentId,
recipientId: recipient.id,
},
},
'*',
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (hasFinishedInit && hasDocumentLoaded && window.parent) {
window.parent.postMessage(
@@ -40,12 +40,18 @@ export const EmbedSignDocumentV2ClientPage = ({
const [isNameLocked, setIsNameLocked] = useState(false);
const [isEmailLocked, setIsEmailLocked] = useState(envelope.type === EnvelopeType.DOCUMENT && !!email);
// The signing provider's envelope data isn't refreshed on revalidation.
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
const [hasRejectedDocument, setHasRejectedDocument] = useState(isRejected);
const onDocumentCompleted = (data: {
token: string;
documentId: number;
envelopeId: string;
recipientId: number;
}) => {
setHasCompletedDocument(true);
if (window.parent) {
window.parent.postMessage(
{
@@ -112,6 +118,8 @@ export const EmbedSignDocumentV2ClientPage = ({
recipientId: number;
reason?: string;
}) => {
setHasRejectedDocument(true);
if (window.parent) {
window.parent.postMessage(
{
@@ -219,23 +227,26 @@ export const EmbedSignDocumentV2ClientPage = ({
}
}, [isRejected, envelope.id, recipient.id, recipient.token]);
if (isRejected) {
if (hasRejectedDocument) {
return <EmbedDocumentRejected />;
}
if (isCompleted) {
if (hasCompletedDocument) {
const completedSignature =
recipient.fields.find((field) => field.signature)?.signature ?? recipientSignature ?? null;
return (
<EmbedDocumentCompleted
name={fullName}
signature={
recipientSignature
completedSignature
? {
id: 1,
fieldId: 1,
recipientId: recipient.id,
created: new Date(),
signatureImageAsBase64: recipientSignature.signatureImageAsBase64,
typedSignature: recipientSignature.typedSignature,
signatureImageAsBase64: completedSignature.signatureImageAsBase64,
typedSignature: completedSignature.typedSignature,
}
: undefined
}
@@ -0,0 +1,175 @@
import {
CONTENT_MAX_STROKE_WIDTH,
CONTENT_MIN_STROKE_WIDTH,
DEFAULT_CONTENT_TEXT_COLOR,
} from '@documenso/lib/types/envelope-content-meta';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Slider } from '@documenso/ui/primitives/slider';
import { Trans, useLingui } from '@lingui/react/macro';
import type { Control } from 'react-hook-form';
// Can't seem to get the non-any type to work with correct types.
// Eg Control<{ fontSize?: number } doesn't seem to work when there are required items.
// biome-ignore lint/suspicious/noExplicitAny: See above
type FormControlType = Control<any>;
type GenericContentFieldProps = {
formControl: FormControlType;
className?: string;
};
export const EditorContentColorField = ({
formControl,
className,
name = 'color',
label,
defaultColor = DEFAULT_CONTENT_TEXT_COLOR,
}: GenericContentFieldProps & {
name?: string;
label?: React.ReactNode;
defaultColor?: string;
}) => {
return (
<FormField
control={formControl}
name={name}
render={({ field }) => (
<FormItem className={className}>
<FormLabel>{label ?? <Trans>Color</Trans>}</FormLabel>
<FormControl>
<div className="flex items-center gap-x-3">
<ColorPicker
data-testid={`content-form-${name}`}
value={field.value ?? ''}
defaultValue={defaultColor}
onChange={(color) => field.onChange(color)}
/>
<span className="font-mono text-muted-foreground text-xs uppercase">{field.value ?? defaultColor}</span>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export const EditorContentStrokeWidthField = ({ formControl, className }: GenericContentFieldProps) => {
const { t } = useLingui();
return (
<FormField
control={formControl}
name="strokeWidth"
render={({ field }) => (
<FormItem className={className}>
<FormLabel>
<Trans>Thickness</Trans>
</FormLabel>
<FormControl>
<Input
data-testid="content-form-strokeWidth"
type="number"
min={CONTENT_MIN_STROKE_WIDTH}
max={CONTENT_MAX_STROKE_WIDTH}
step={0.5}
className="bg-background"
placeholder={t`Thickness`}
{...field}
onChange={(e) => {
field.onChange(Number(e.target.value));
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export const EditorContentStrokeStyleField = ({ formControl, className }: GenericContentFieldProps) => {
const { t } = useLingui();
return (
<FormField
control={formControl}
name="strokeStyle"
render={({ field }) => (
<FormItem className={className}>
<FormLabel>
<Trans>Style</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger data-testid="content-form-strokeStyle">
<SelectValue placeholder={t`Select style`} />
</SelectTrigger>
<SelectContent>
<SelectItem value="solid">
<Trans>Solid</Trans>
</SelectItem>
<SelectItem value="dashed">
<Trans>Dashed</Trans>
</SelectItem>
<SelectItem value="dotted">
<Trans>Dotted</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
/**
* An opacity slider. The form value is a fraction (0-1) while the slider and
* label display a percentage.
*/
export const EditorContentOpacityField = ({
formControl,
className,
name = 'fillOpacity',
label,
}: GenericContentFieldProps & {
name?: string;
label?: React.ReactNode;
}) => {
return (
<FormField
control={formControl}
name={name}
render={({ field }) => {
const percentage = Math.round((field.value ?? 1) * 100);
return (
<FormItem className={className}>
<FormLabel className="flex items-center justify-between">
{label ?? <Trans>Opacity</Trans>}
<span className="font-mono text-muted-foreground tabular-nums">{percentage}%</span>
</FormLabel>
<FormControl>
<Slider
data-testid={`content-form-${name}`}
className="py-2"
min={0}
max={100}
step={1}
value={[percentage]}
onValueChange={([value]) => field.onChange(value / 100)}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
);
};
@@ -0,0 +1,31 @@
import { DEFAULT_CONTENT_HIGHLIGHT_COLOR } from '@documenso/lib/types/envelope-content-meta';
import {
type TContentHighlightFormSchema,
useContentSettingsForm,
} from '~/components/general/envelope-editor/content-settings-form-provider';
import { EditorContentColorField, EditorContentOpacityField } from './editor-content-generic-field-forms';
/**
* Settings form for highlight contents, bound to the selected content's
* settings form. Only the presentational settings are editable here;
* geometry (page, position and size) is managed on the canvas.
*/
export const EditorContentHighlightForm = () => {
const { form, isReady } = useContentSettingsForm<TContentHighlightFormSchema>();
// Mount the inputs only once the form holds this content's values.
if (!isReady) {
return null;
}
return (
<div>
<fieldset className="flex flex-col gap-2">
<EditorContentColorField formControl={form.control} defaultColor={DEFAULT_CONTENT_HIGHLIGHT_COLOR} />
<EditorContentOpacityField formControl={form.control} name="fillOpacity" />
</fieldset>
</div>
);
};
@@ -0,0 +1,136 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import {
APP_CONTENT_IMAGE_MIME_TYPES,
APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT,
} from '@documenso/lib/constants/envelope-content';
import { formatFileSize, megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Trans, useLingui } from '@lingui/react/macro';
import { FileImageIcon, ImageUpIcon, RefreshCwIcon, TrashIcon } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { ContentImageUploadDialog } from '~/components/general/envelope-editor/content-image-upload-dialog';
type EditorContentImageSettingsProps = {
formId: string;
dataContentId?: string | null;
};
/**
* Settings for image contents: the image itself.
*
* Uploads go through the shared upload dialog. This panel only offers the
* ways to start one (drop, click, replace) and to remove the current image.
* Geometry (page, position, size and rotation) is managed on the canvas.
*/
export const EditorContentImageSettings = ({ formId, dataContentId }: EditorContentImageSettingsProps) => {
const { t } = useLingui();
const { editorContents } = useCurrentEnvelopeEditor();
const { contentImages } = useCurrentEnvelopeRender();
const image = dataContentId ? contentImages.images.get(dataContentId) : undefined;
const details = dataContentId ? contentImages.details.get(dataContentId) : undefined;
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: Object.fromEntries(APP_CONTENT_IMAGE_MIME_TYPES.map((mimeType) => [mimeType, []])),
multiple: false,
maxSize: megabytesToBytes(APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT),
// A dropped file goes straight to the dialog; a click opens the picker
// through it instead of the dropzone's own input.
noClick: true,
onDrop: (acceptedFiles) => {
const [file] = acceptedFiles;
if (file) {
void ContentImageUploadDialog.call({ formId, file });
}
},
// A file which is too large or not a supported image goes to the dialog
// as well, which shows why it cannot be used. When several files were
// dropped, the first is used.
onDropRejected: (fileRejections) => {
const [rejection] = fileRejections;
if (rejection) {
void ContentImageUploadDialog.call({ formId, file: rejection.file });
}
},
});
if (dataContentId) {
return (
<div className="mt-2 flex items-center gap-3 rounded-lg border border-border p-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-md bg-muted/40">
<FileImageIcon className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground text-xs">
<Trans>Image</Trans>
</p>
<p className="truncate text-muted-foreground text-xs">
{[details ? formatFileSize(details.fileSize) : null, image ? `${image.width} * ${image.height}` : null]
.filter((part) => part !== null)
.join(' · ')}
</p>
</div>
<div className="flex shrink-0 flex-row gap-1">
<Button
type="button"
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
title={t`Replace image`}
onClick={() => void ContentImageUploadDialog.call({ formId })}
>
<RefreshCwIcon className="h-4 w-4" strokeWidth={1.5} />
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
title={t`Remove image`}
onClick={() => editorContents.updateContentByFormId(formId, { dataContentId: null, data: undefined })}
>
<TrashIcon className="h-4 w-4" strokeWidth={1.5} />
</Button>
</div>
</div>
);
}
return (
<div
{...getRootProps({
role: 'button',
tabIndex: 0,
onClick: () => void ContentImageUploadDialog.call({ formId }),
onKeyDown: (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
void ContentImageUploadDialog.call({ formId });
}
},
})}
className={cn(
'mt-2 flex cursor-pointer flex-col items-center justify-center gap-1.5 rounded-lg border border-border border-dashed px-4 py-6 text-center transition-colors hover:border-primary/60',
isDragActive && 'border-primary bg-primary/5',
)}
>
<input {...getInputProps()} />
<ImageUpIcon className="h-6 w-6 text-muted-foreground" strokeWidth={1.5} />
<p className="text-foreground text-sm">
<Trans>Click to upload or drag and drop</Trans>
</p>
<p className="text-muted-foreground text-xs">
<Trans>PNG, JPEG or WebP up to {APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT} MB</Trans>
</p>
</div>
);
};
@@ -0,0 +1,43 @@
import { DEFAULT_CONTENT_STROKE_COLOR } from '@documenso/lib/types/envelope-content-meta';
import {
type TContentLineFormSchema,
useContentSettingsForm,
} from '~/components/general/envelope-editor/content-settings-form-provider';
import {
EditorContentColorField,
EditorContentStrokeStyleField,
EditorContentStrokeWidthField,
} from './editor-content-generic-field-forms';
/**
* Settings form for line contents, bound to the selected content's settings
* form. Only the presentational settings are editable here; geometry (page
* and endpoints) is managed on the canvas.
*/
export const EditorContentLineForm = () => {
const { form, isReady } = useContentSettingsForm<TContentLineFormSchema>();
// Mount the inputs only once the form holds this content's values.
if (!isReady) {
return null;
}
return (
<div>
<fieldset className="flex flex-col gap-2">
<div className="flex w-full flex-row gap-x-4">
<EditorContentStrokeWidthField className="w-full" formControl={form.control} />
<EditorContentStrokeStyleField className="w-full" formControl={form.control} />
</div>
<EditorContentColorField
formControl={form.control}
name="strokeColor"
defaultColor={DEFAULT_CONTENT_STROKE_COLOR}
/>
</fieldset>
</div>
);
};
@@ -0,0 +1,105 @@
import {
DEFAULT_CONTENT_STROKE_COLOR,
EnvelopeContentShapeType,
EnvelopeContentType,
} from '@documenso/lib/types/envelope-content-meta';
import { FormControl, FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Trans } from '@lingui/react/macro';
import { useWatch } from 'react-hook-form';
import { match } from 'ts-pattern';
import {
type TContentShapeFormSchema,
useContentSettingsForm,
} from '~/components/general/envelope-editor/content-settings-form-provider';
import {
EditorContentColorField,
EditorContentOpacityField,
EditorContentStrokeStyleField,
EditorContentStrokeWidthField,
} from './editor-content-generic-field-forms';
/**
* The fill color used when the fill is first enabled.
*
* Todo: Contents
*/
export const DEFAULT_ENABLED_FILL_COLOR = '#e5e7eb';
/**
* Settings form for shape contents, bound to the selected content's settings
* form, with the fields for the content's shape. Only the presentational
* settings are editable here; geometry (page, position and size) is managed
* on the canvas.
*/
export const EditorContentShapeForm = () => {
const { form, content, isReady } = useContentSettingsForm<TContentShapeFormSchema>();
const fillColor = useWatch({ control: form.control, name: 'fillColor' });
const hasFill = Boolean(fillColor);
const onFillToggle = (enabled: boolean) => {
// The fill is represented purely by the fill color, so toggling sets it
// or clears it to `null`.
form.setValue('fillColor', enabled ? DEFAULT_ENABLED_FILL_COLOR : null, {
shouldDirty: true,
shouldValidate: true,
});
};
// Mount the inputs only once the form holds this content's values.
if (!isReady || content?.contentMeta.type !== EnvelopeContentType.SHAPE) {
return null;
}
return (
<div>
{match(content.contentMeta.shape)
.with(EnvelopeContentShapeType.RECTANGLE, () => (
<fieldset className="flex flex-col gap-2">
<div className="flex w-full flex-row gap-x-4">
<EditorContentStrokeWidthField className="w-full" formControl={form.control} />
<EditorContentStrokeStyleField className="w-full" formControl={form.control} />
</div>
<EditorContentColorField
formControl={form.control}
name="strokeColor"
label={<Trans>Border Color</Trans>}
defaultColor={DEFAULT_CONTENT_STROKE_COLOR}
/>
<FormItem className="mt-1 flex flex-row items-center justify-between">
<FormLabel>
<Trans>Fill</Trans>
</FormLabel>
<FormControl>
<Switch data-testid="content-form-fill" checked={hasFill} onCheckedChange={onFillToggle} />
</FormControl>
</FormItem>
{hasFill && (
<>
<EditorContentColorField
formControl={form.control}
name="fillColor"
label={<Trans>Fill Color</Trans>}
defaultColor={DEFAULT_ENABLED_FILL_COLOR}
/>
<EditorContentOpacityField
formControl={form.control}
name="fillOpacity"
label={<Trans>Fill Opacity</Trans>}
/>
</>
)}
</fieldset>
))
.exhaustive()}
</div>
);
};
@@ -0,0 +1,78 @@
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { Trans, useLingui } from '@lingui/react/macro';
import {
type TContentTextFormSchema,
useContentSettingsForm,
} from '~/components/general/envelope-editor/content-settings-form-provider';
import { EditorContentColorField } from './editor-content-generic-field-forms';
import {
EditorGenericFontSizeField,
EditorGenericLetterSpacingField,
EditorGenericLineHeightField,
EditorGenericTextAlignField,
EditorGenericVerticalAlignField,
} from './editor-field-generic-field-forms';
/**
* Settings form for text contents, bound to the selected content's settings
* form. Only the presentational settings are editable here; geometry (page,
* position and size) is managed on the canvas.
*/
export const EditorContentTextForm = () => {
const { t } = useLingui();
const { form, isReady } = useContentSettingsForm<TContentTextFormSchema>();
// Mount the inputs only once the form holds this content's values.
if (!isReady) {
return null;
}
return (
<div>
<fieldset className="flex flex-col gap-2">
<FormField
control={form.control}
name="text"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Text</Trans>
</FormLabel>
<FormControl>
<Textarea
data-testid="content-form-text"
className="h-auto"
placeholder={t`Add text to the document`}
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex w-full flex-row gap-x-4">
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<EditorContentColorField className="w-full" formControl={form.control} />
</div>
<div className="flex w-full flex-row gap-x-4">
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
<EditorGenericVerticalAlignField className="w-full" formControl={form.control} />
</div>
<div className="flex w-full flex-row gap-x-4">
<EditorGenericLineHeightField className="w-full" formControl={form.control} />
<EditorGenericLetterSpacingField className="w-full" formControl={form.control} />
</div>
</fieldset>
</div>
);
};
@@ -15,7 +15,7 @@ import { type Control, useFormContext } from 'react-hook-form';
// Can't seem to get the non-any type to work with correct types.
// Eg Control<{ fontSize?: number } doesn't seem to work when there are required items.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: See above
type FormControlType = Control<any>;
export const EditorGenericFontSizeField = ({
@@ -54,6 +54,8 @@ export const SubscriptionClaimForm = ({
memberCount: subscriptionClaim.memberCount,
envelopeItemCount: subscriptionClaim.envelopeItemCount,
recipientCount: subscriptionClaim.recipientCount,
envelopeContentCount: subscriptionClaim.envelopeContentCount,
envelopeContentImageCount: subscriptionClaim.envelopeContentImageCount,
flags: subscriptionClaim.flags,
documentRateLimits: subscriptionClaim.documentRateLimits,
documentQuota: subscriptionClaim.documentQuota,
@@ -185,6 +187,54 @@ export const SubscriptionClaimForm = ({
)}
/>
<FormField
control={form.control}
name="envelopeContentCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Content Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of contents per envelope allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="envelopeContentImageCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Content Image Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of image contents per envelope allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel>
<Trans>Feature Flags</Trans>
@@ -1,7 +1,18 @@
import { Trans } from '@lingui/react/macro';
import { AlertTriangleIcon } from 'lucide-react';
import { match } from 'ts-pattern';
export const DirectTemplateInvalidPageView = () => {
/**
* Why a direct link template cannot be used. Each reason would otherwise only
* surface once the signer submits, since the template renders normally.
*/
export type DirectTemplateInvalidReason = 'MISSING_SIGNATURE_FIELD' | 'MISSING_CONTENT_IMAGE';
export type DirectTemplateInvalidPageViewProps = {
reason: DirectTemplateInvalidReason;
};
export const DirectTemplateInvalidPageView = ({ reason }: DirectTemplateInvalidPageViewProps) => {
return (
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
<div>
@@ -12,10 +23,20 @@ export const DirectTemplateInvalidPageView = () => {
</h1>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
This direct link template cannot be used because one or more signers do not have a signature field assigned.
Please contact the sender to update the template.
</Trans>
{match(reason)
.with('MISSING_SIGNATURE_FIELD', () => (
<Trans>
This direct link template cannot be used because one or more signers do not have a signature field
assigned. Please contact the sender to update the template.
</Trans>
))
.with('MISSING_CONTENT_IMAGE', () => (
<Trans>
This direct link template cannot be used because one or more image contents have no image attached.
Please contact the sender to update the template.
</Trans>
))
.exhaustive()}
</p>
</div>
</div>
@@ -11,6 +11,7 @@ import {
import type { TTemplate } from '@documenso/lib/types/template';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { getNextDictatableRecipient } from '@documenso/lib/utils/recipient-groups';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
@@ -223,27 +224,10 @@ export const DirectTemplateSigningForm = ({
return undefined;
}
const sortedRecipients = template.recipients.sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) {
return a.id - b.id;
}
if (a.signingOrder === null) {
return 1;
}
if (b.signingOrder === null) {
return -1;
}
if (a.signingOrder === b.signingOrder) {
return a.id - b.id;
}
return a.signingOrder - b.signingOrder;
return getNextDictatableRecipient({
recipients: template.recipients,
currentRecipientId: directRecipient.id,
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === directRecipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [template.templateMeta?.signingOrder, template.recipients, directRecipient.id]);
return (
@@ -435,7 +419,7 @@ export const DirectTemplateSigningForm = ({
fields={localFields}
fieldsValidated={fieldsValidated}
recipient={directRecipient}
allowDictateNextSigner={nextRecipient && template.templateMeta?.allowDictateNextSigner}
allowDictateNextSigner={Boolean(nextRecipient && template.templateMeta?.allowDictateNextSigner)}
defaultNextSigner={nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined}
/>
</div>
@@ -102,8 +102,10 @@ export const DocumentSigningCompleteDialog = ({
const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {};
const canDictateNextSigner = allowDictateNextSigner && Boolean(defaultNextSigner);
const form = useForm<TNextSignerFormSchema>({
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
resolver: canDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
defaultValues: {
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
@@ -324,7 +326,7 @@ export const DocumentSigningCompleteDialog = ({
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
{allowDictateNextSigner && defaultNextSigner && (
{canDictateNextSigner && (
<div className="mb-4 flex flex-col gap-4">
<div className="flex flex-col gap-4 md:flex-row">
<FormField
@@ -39,7 +39,11 @@ export type DocumentSigningFormProps = {
}) => Promise<void>;
isSubmitting: boolean;
fieldsValidated: () => void;
nextRecipient?: RecipientWithFields;
/**
* The dictatable next recipient, decided server-side. Only their identity
* is needed — for the dictation flag and the prefilled inputs.
*/
nextRecipient?: Pick<Recipient, 'name' | 'email'>;
};
export const DocumentSigningForm = ({
@@ -84,6 +88,10 @@ export const DocumentSigningForm = ({
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
}, [fieldsRequiringValidation, recipient]);
const allowDictateNextSigner = Boolean(nextRecipient && document.documentMeta?.allowDictateNextSigner);
const defaultNextSigner = nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined;
const localFieldsValidated = () => {
setValidateUninsertedFields(true);
fieldsValidated();
@@ -151,10 +159,8 @@ export const DocumentSigningForm = ({
completeDocument({ nextSigner, accessAuthOptions })
}
recipient={recipient}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
allowDictateNextSigner={allowDictateNextSigner}
defaultNextSigner={defaultNextSigner}
/>
</div>
</div>
@@ -223,8 +229,8 @@ export const DocumentSigningForm = ({
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
onConfirm={handleAssistantConfirmDialogSubmit}
isSubmitting={isAssistantSubmitting}
allowDictateNextSigner={nextRecipient && document.documentMeta?.allowDictateNextSigner}
defaultNextSigner={nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined}
allowDictateNextSigner={allowDictateNextSigner}
defaultNextSigner={defaultNextSigner}
/>
</form>
) : (
@@ -291,10 +297,8 @@ export const DocumentSigningForm = ({
})
}
recipient={recipient}
allowDictateNextSigner={nextRecipient && document.documentMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
allowDictateNextSigner={allowDictateNextSigner}
defaultNextSigner={defaultNextSigner}
/>
</div>
</>
@@ -22,7 +22,7 @@ import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Trans } from '@lingui/react/macro';
import type { Field } from '@prisma/client';
import type { Field, Recipient } from '@prisma/client';
import { FieldType, RecipientRole } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useMemo, useState } from 'react';
@@ -60,6 +60,12 @@ export type DocumentSigningPageViewV1Props = {
completedFields: CompletedField[];
isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
/**
* The dictatable next recipient, computed server-side over the FULL
* recipient list — must not be re-derived from the role-scoped
* `allRecipients`.
*/
nextRecipient?: Pick<Recipient, 'name' | 'email'>;
branding: DocumentSigningBranding;
includeSenderDetails: boolean;
};
@@ -71,6 +77,7 @@ export const DocumentSigningPageViewV1 = ({
completedFields,
isRecipientsTurn,
allRecipients = [],
nextRecipient,
includeSenderDetails,
branding,
}: DocumentSigningPageViewV1Props) => {
@@ -133,34 +140,6 @@ export const DocumentSigningPageViewV1 = ({
const selectedSigner = allRecipients?.find((r) => r.id === selectedSignerId);
const targetSigner = recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null;
const nextRecipient = useMemo(() => {
if (!documentMeta?.signingOrder || documentMeta.signingOrder !== 'SEQUENTIAL') {
return undefined;
}
const sortedRecipients = [...allRecipients].sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) {
return a.id - b.id;
}
if (a.signingOrder === null) {
return 1;
}
if (b.signingOrder === null) {
return -1;
}
if (a.signingOrder === b.signingOrder) {
return a.id - b.id;
}
return a.signingOrder - b.signingOrder;
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
const hasPendingFields = pendingFields.length > 0;
@@ -235,7 +235,10 @@ export const DocumentSigningPageViewV2 = () => {
</div>
</div>
<div className="embed--DocumentContainer min-w-0 flex-1 overflow-y-auto" ref={scrollableContainerRef}>
<div
className="embed--DocumentContainer min-w-0 flex-1 overflow-x-auto overflow-y-auto"
ref={scrollableContainerRef}
>
<div className="flex flex-col">
{/* Horizontal envelope item selector */}
{envelopeItems.length > 1 && (
@@ -267,6 +270,9 @@ export const DocumentSigningPageViewV2 = () => {
customPageRenderer={EnvelopeSignerPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.signing}
toolbar={['zoom']}
// Todo: Content - Decide how to manage zooming on mobile.
toolbarClassName="hidden lg:flex"
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
@@ -6,6 +6,8 @@ import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/enve
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired, isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
import { getNextDictatableRecipient } from '@documenso/lib/utils/recipient-groups';
import { isRecipientBefore } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
import { EnvelopeType, type Field, FieldType, type Recipient, RecipientRole, SigningStatus } from '@prisma/client';
@@ -236,12 +238,16 @@ export const EnvelopeSigningProvider = ({
}, [envelopeData.recipient.fields]);
/**
* Assistant recipients are those that have a signing order after the assistant.
* Assistant recipients are those positioned strictly after the assistant —
* never their own group peers.
*/
const assistantRecipients =
recipient.role === RecipientRole.ASSISTANT
? envelope.recipients.filter((r) => (r.signingOrder ?? 0) > (recipient.signingOrder ?? 0))
: [];
const assistantRecipients = useMemo(() => {
if (recipient.role !== RecipientRole.ASSISTANT) {
return [];
}
return envelope.recipients.filter((r) => isRecipientBefore(recipient, r));
}, [envelope.recipients, recipient]);
/**
* Assistant fields are those fulfill all of the following:
@@ -249,12 +255,11 @@ export const EnvelopeSigningProvider = ({
* - After the assistant signing order
* - Are not signature fields
*/
const assistantFields =
recipient.role === RecipientRole.ASSISTANT
? assistantRecipients
.filter((r) => r.signingStatus !== SigningStatus.SIGNED)
.flatMap((r) => r.fields.filter((field) => field.type !== FieldType.SIGNATURE))
: [];
const assistantFields = useMemo(() => {
return assistantRecipients
.filter((r) => r.signingStatus !== SigningStatus.SIGNED)
.flatMap((r) => r.fields.filter((field) => field.type !== FieldType.SIGNATURE));
}, [assistantRecipients]);
/**
* The recipient that the assistant has currently selected to sign on behalf of.
@@ -269,7 +274,7 @@ export const EnvelopeSigningProvider = ({
const selectedAssistantRecipientFields = useMemo(() => {
return assistantFields.filter((field) => field.recipientId === selectedAssistantRecipient?.id);
}, [recipientFields, selectedAssistantRecipient]);
}, [assistantFields, selectedAssistantRecipient]);
/**
* Fields that have been completed by other recipients.
@@ -290,32 +295,14 @@ export const EnvelopeSigningProvider = ({
.filter((field) => field.inserted);
const nextRecipient = useMemo(() => {
if (!envelope.documentMeta.signingOrder || envelope.documentMeta.signingOrder !== 'SEQUENTIAL') {
if (envelope.documentMeta.signingOrder !== 'SEQUENTIAL') {
return null;
}
const sortedRecipients = [...envelope.recipients].sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) {
return a.id - b.id;
}
if (a.signingOrder === null) {
return 1;
}
if (b.signingOrder === null) {
return -1;
}
if (a.signingOrder === b.signingOrder) {
return a.id - b.id;
}
return a.signingOrder - b.signingOrder;
return getNextDictatableRecipient({
recipients: envelope.recipients,
currentRecipientId: recipient.id,
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: null;
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
const signField = async (
@@ -0,0 +1,131 @@
import type Konva from 'konva';
import type { LucideIcon } from 'lucide-react';
/**
* The gap between a selection and its floating action bar.
*/
const ACTION_BAR_OFFSET_PX = 5;
/**
* Resolve the absolute CSS position for an action bar hanging below the
* bottom center of a set of nodes.
*
* Uses the union of the nodes' own client rects rather than the transformer's,
* since the transformer rect includes its handles (e.g. the rotate anchor and
* its stem above the selection) which would otherwise push the bar away from
* the selection.
*
* Client rects are in scaled stage coordinates, matching the CSS pixel space
* of the konva container.
*/
const getNodesActionBarPosition = (nodes: Konva.Node[]) => {
let minX = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const node of nodes) {
const rect = node.getClientRect({ skipStroke: true, skipShadow: true });
minX = Math.min(minX, rect.x);
maxX = Math.max(maxX, rect.x + rect.width);
maxY = Math.max(maxY, rect.y + rect.height);
}
if (!Number.isFinite(minX) || !Number.isFinite(maxY)) {
return null;
}
return {
top: `${maxY + ACTION_BAR_OFFSET_PX}px`,
left: `${minX + (maxX - minX) / 2}px`,
};
};
type EnvelopeCanvasActionBarProps = {
/**
* The Konva nodes the bar hangs below.
*/
nodes: Konva.Node[];
/**
* Hide the bar, e.g. while the nodes are being transformed.
*/
hidden?: boolean;
children: React.ReactNode;
};
/**
* A floating container positioned below the bottom center of a set of canvas
* nodes, for action bars and menus attached to a selection.
*
* Must be rendered within the page's relatively positioned container.
*/
export const EnvelopeCanvasActionBar = ({ nodes, hidden = false, children }: EnvelopeCanvasActionBarProps) => {
const position = nodes.length > 0 ? getNodesActionBarPosition(nodes) : null;
if (!position || hidden) {
return null;
}
return (
<div
data-testid="envelope-canvas-action-bar"
className="flex flex-col items-center"
style={{
position: 'absolute',
top: position.top,
left: position.left,
transform: 'translateX(-50%)',
gap: '8px',
pointerEvents: 'auto',
zIndex: 50,
}}
>
{children}
</div>
);
};
/**
* The floating pill which groups action buttons, themed like the viewer
* toolbar so the two float over the page consistently.
*
* The ring and shadow are stronger than the toolbar's since the bar sits
* directly on the page, which is usually white, rather than on the viewer
* background.
*/
export const EnvelopeCanvasActionButtonGroup = ({ children }: { children: React.ReactNode }) => {
return (
<div className="flex w-fit items-center gap-x-0.5 rounded-lg bg-popover p-1 text-popover-foreground shadow-black/25 shadow-lg ring-1 ring-black/15 dark:shadow-black/60 dark:ring-white/20">
{children}
</div>
);
};
/**
* A thin vertical separator between groups of actions within the pill.
*/
export const EnvelopeCanvasActionDivider = () => {
return <div className="mx-0.5 h-4 w-px bg-border" />;
};
type EnvelopeCanvasActionButtonProps = {
title: string;
icon: LucideIcon;
onClick: () => void;
};
export const EnvelopeCanvasActionButton = ({ title, icon: Icon, onClick }: EnvelopeCanvasActionButtonProps) => {
return (
<button
type="button"
title={title}
className="rounded-md p-1.5 text-foreground/70 transition-colors hover:bg-muted hover:text-foreground"
onClick={onClick}
onTouchEnd={onClick}
>
<Icon className="h-3.5 w-3.5" />
</button>
);
};
@@ -0,0 +1,423 @@
import type { TLocalContent } from '@documenso/lib/client-only/hooks/use-editor-contents';
import {
CONTENT_MAX_FONT_SIZE,
CONTENT_MAX_STROKE_WIDTH,
CONTENT_MIN_FONT_SIZE,
CONTENT_MIN_STROKE_WIDTH,
DEFAULT_CONTENT_FONT_SIZE,
DEFAULT_CONTENT_HIGHLIGHT_COLOR,
DEFAULT_CONTENT_STROKE_COLOR,
DEFAULT_CONTENT_STROKE_WIDTH,
DEFAULT_CONTENT_TEXT_ALIGN,
DEFAULT_CONTENT_TEXT_COLOR,
DEFAULT_CONTENT_VERTICAL_ALIGN,
EnvelopeContentType,
} from '@documenso/lib/types/envelope-content-meta';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import { FormField } from '@documenso/ui/primitives/form/form';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { Slider } from '@documenso/ui/primitives/slider';
import { Trans, useLingui } from '@lingui/react/macro';
import type { LucideIcon } from 'lucide-react';
import {
ALargeSmallIcon,
AlignCenterIcon,
AlignLeftIcon,
AlignRightIcon,
AlignVerticalJustifyCenterIcon,
AlignVerticalJustifyEndIcon,
AlignVerticalJustifyStartIcon,
ImageUpIcon,
} from 'lucide-react';
import { match } from 'ts-pattern';
import { DEFAULT_ENABLED_FILL_COLOR } from '~/components/forms/editor/editor-content-shape-form';
import { ContentImageUploadDialog } from '~/components/general/envelope-editor/content-image-upload-dialog';
import {
type TContentHighlightFormSchema,
type TContentLineFormSchema,
type TContentShapeFormSchema,
type TContentTextFormSchema,
useContentSettingsForm,
} from '~/components/general/envelope-editor/content-settings-form-provider';
import { EnvelopeCanvasActionButton, EnvelopeCanvasActionDivider } from './envelope-canvas-action-bar';
type EnvelopeCanvasContentActionsProps = {
/**
* The single selected content.
*/
content: TLocalContent;
};
/**
* The type specific quick actions for a single selected content, shown at the
* start of the content action bar: the frequently adjusted styles (colors,
* stroke width) and the image upload for image contents.
*
* The style actions are fields of the selected content's settings form, the
* same form the sidebar edits, so the two never disagree. The full set of
* settings remains in the sidebar. Renders nothing for types without quick
* actions.
*/
export const EnvelopeCanvasContentActions = ({ content }: EnvelopeCanvasContentActionsProps) => {
const { t } = useLingui();
const { content: formContent, isReady } = useContentSettingsForm();
// The canvas selection and the settings form follow each other, but guard
// against a render in between so a stale selection never edits another
// content's form, and wait for the form to hold this content's values.
if (formContent?.formId !== content.formId || !isReady) {
return null;
}
const actions = match(content.contentMeta)
.with({ type: EnvelopeContentType.TEXT }, () => <TextContentActions />)
.with({ type: EnvelopeContentType.LINE }, () => <LineContentActions />)
.with({ type: EnvelopeContentType.SHAPE }, () => <ShapeContentActions />)
.with({ type: EnvelopeContentType.HIGHLIGHT }, () => <HighlightContentActions />)
.with({ type: EnvelopeContentType.IMAGE }, () => (
<EnvelopeCanvasActionButton
title={content.dataContentId ? t`Replace image` : t`Upload image`}
icon={ImageUpIcon}
onClick={() => void ContentImageUploadDialog.call({ formId: content.formId })}
/>
))
.exhaustive();
return (
<>
{actions}
<EnvelopeCanvasActionDivider />
</>
);
};
const TextContentActions = () => {
const { t } = useLingui();
const { form } = useContentSettingsForm<TContentTextFormSchema>();
return (
<>
<FormField
control={form.control}
name="color"
render={({ field }) => (
<ColorAction
title={t`Text color`}
value={field.value ?? DEFAULT_CONTENT_TEXT_COLOR}
onChange={field.onChange}
/>
)}
/>
<FormField
control={form.control}
name="fontSize"
render={({ field }) => (
<FontSizeAction value={field.value ?? DEFAULT_CONTENT_FONT_SIZE} onChange={field.onChange} />
)}
/>
<FormField
control={form.control}
name="textAlign"
render={({ field }) => (
<ChoiceAction
title={t`Text align`}
value={field.value ?? DEFAULT_CONTENT_TEXT_ALIGN}
options={[
{ value: 'left', icon: AlignLeftIcon, label: t`Left` },
{ value: 'center', icon: AlignCenterIcon, label: t`Center` },
{ value: 'right', icon: AlignRightIcon, label: t`Right` },
]}
onChange={field.onChange}
/>
)}
/>
<FormField
control={form.control}
name="verticalAlign"
render={({ field }) => (
<ChoiceAction
title={t`Vertical align`}
value={field.value ?? DEFAULT_CONTENT_VERTICAL_ALIGN}
options={[
{ value: 'top', icon: AlignVerticalJustifyStartIcon, label: t`Top` },
{ value: 'middle', icon: AlignVerticalJustifyCenterIcon, label: t`Middle` },
{ value: 'bottom', icon: AlignVerticalJustifyEndIcon, label: t`Bottom` },
]}
onChange={field.onChange}
/>
)}
/>
</>
);
};
const LineContentActions = () => {
const { t } = useLingui();
const { form } = useContentSettingsForm<TContentLineFormSchema>();
return (
<>
<FormField
control={form.control}
name="strokeColor"
render={({ field }) => (
<ColorAction
title={t`Line color`}
value={field.value ?? DEFAULT_CONTENT_STROKE_COLOR}
onChange={field.onChange}
/>
)}
/>
<FormField
control={form.control}
name="strokeWidth"
render={({ field }) => (
<StrokeWidthAction value={field.value ?? DEFAULT_CONTENT_STROKE_WIDTH} onChange={field.onChange} />
)}
/>
</>
);
};
const ShapeContentActions = () => {
const { t } = useLingui();
const { form } = useContentSettingsForm<TContentShapeFormSchema>();
return (
<>
<FormField
control={form.control}
name="strokeColor"
render={({ field }) => (
<ColorAction
title={t`Border color`}
value={field.value ?? DEFAULT_CONTENT_STROKE_COLOR}
onChange={field.onChange}
/>
)}
/>
<FormField
control={form.control}
name="fillColor"
render={({ field }) => (
<ColorAction
title={field.value ? t`Fill color` : t`Add fill`}
value={field.value}
defaultValue={DEFAULT_ENABLED_FILL_COLOR}
onChange={field.onChange}
/>
)}
/>
<FormField
control={form.control}
name="strokeWidth"
render={({ field }) => (
<StrokeWidthAction value={field.value ?? DEFAULT_CONTENT_STROKE_WIDTH} onChange={field.onChange} />
)}
/>
</>
);
};
const HighlightContentActions = () => {
const { t } = useLingui();
const { form } = useContentSettingsForm<TContentHighlightFormSchema>();
return (
<FormField
control={form.control}
name="color"
render={({ field }) => (
<ColorAction
title={t`Highlight color`}
value={field.value ?? DEFAULT_CONTENT_HIGHLIGHT_COLOR}
onChange={field.onChange}
/>
)}
/>
);
};
type ColorActionProps = {
title: string;
/**
* The current color, or null when none is set (e.g. no fill).
*/
value: string | null;
/**
* The color the picker starts from when none is set.
*/
defaultValue?: string;
onChange: (color: string) => void;
};
/**
* A swatch button which opens a color picker. An unset color shows a struck
* through swatch.
*/
const ColorAction = ({ title, value, defaultValue, onChange }: ColorActionProps) => {
return (
<ColorPicker
value={value ?? ''}
defaultValue={defaultValue}
onChange={onChange}
trigger={
<button type="button" title={title} className="rounded-md p-1.5 transition-colors hover:bg-muted">
<span
className="relative block h-3.5 w-3.5 overflow-hidden rounded-full ring-1 ring-black/20 ring-inset dark:ring-white/30"
style={{ backgroundColor: value ?? 'transparent' }}
>
{!value && <span className="absolute -inset-x-1 top-1/2 h-px -translate-y-1/2 rotate-45 bg-destructive" />}
</span>
</button>
}
/>
);
};
type FontSizeActionProps = {
value: number;
onChange: (fontSize: number) => void;
};
/**
* A button showing the current font size which opens a slider.
*/
const FontSizeAction = ({ value, onChange }: FontSizeActionProps) => {
const { t } = useLingui();
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
title={t`Font size`}
className="rounded-md p-1.5 text-foreground/70 transition-colors hover:bg-muted hover:text-foreground"
>
<ALargeSmallIcon className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-3" onOpenAutoFocus={(event) => event.preventDefault()}>
<div className="mb-2 flex items-center justify-between text-xs">
<span className="text-muted-foreground">
<Trans>Font size</Trans>
</span>
<span className="tabular-nums">{value}</span>
</div>
<Slider
value={[value]}
min={CONTENT_MIN_FONT_SIZE}
max={CONTENT_MAX_FONT_SIZE}
step={1}
onValueChange={([fontSize]) => onChange(fontSize)}
/>
</PopoverContent>
</Popover>
);
};
type ChoiceActionProps<T extends string> = {
title: string;
value: T;
options: { value: T; icon: LucideIcon; label: string }[];
onChange: (value: T) => void;
};
/**
* A button showing the icon of the current choice which opens the other
* choices, e.g. text alignment.
*/
const ChoiceAction = <T extends string>({ title, value, options, onChange }: ChoiceActionProps<T>) => {
const current = options.find((option) => option.value === value) ?? options[0];
const CurrentIcon = current.icon;
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
title={title}
className="rounded-md p-1.5 text-foreground/70 transition-colors hover:bg-muted hover:text-foreground"
>
<CurrentIcon className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="flex w-auto gap-0.5 p-1" onOpenAutoFocus={(event) => event.preventDefault()}>
{options.map((option) => {
const OptionIcon = option.icon;
return (
<button
key={option.value}
type="button"
title={option.label}
aria-pressed={option.value === value}
onClick={() => onChange(option.value)}
className="rounded-md p-1.5 text-foreground/70 transition-colors hover:bg-muted hover:text-foreground aria-pressed:bg-muted aria-pressed:text-foreground"
>
<OptionIcon className="h-4 w-4" />
</button>
);
})}
</PopoverContent>
</Popover>
);
};
type StrokeWidthActionProps = {
value: number;
onChange: (width: number) => void;
};
/**
* A button showing the current stroke width which opens a slider.
*/
const StrokeWidthAction = ({ value, onChange }: StrokeWidthActionProps) => {
const { t } = useLingui();
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
title={t`Thickness`}
className="flex h-[26px] w-[26px] items-center justify-center rounded-md text-foreground/70 transition-colors hover:bg-muted hover:text-foreground"
>
{/* A bar whose thickness follows the value, so the current width is still glanceable. */}
<span className="block w-3.5 rounded-full bg-current" style={{ height: Math.max(1.5, Math.min(value, 6)) }} />
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-3" onOpenAutoFocus={(event) => event.preventDefault()}>
<div className="mb-2 flex items-center justify-between text-xs">
<span className="text-muted-foreground">
<Trans>Thickness</Trans>
</span>
<span className="tabular-nums">{value}</span>
</div>
<Slider
value={[value]}
min={CONTENT_MIN_STROKE_WIDTH}
max={CONTENT_MAX_STROKE_WIDTH}
step={0.5}
onValueChange={([width]) => onChange(width)}
/>
</PopoverContent>
</Popover>
);
};
@@ -0,0 +1,169 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@documenso/ui/primitives/command';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { useLingui } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client';
import { CopyPlusIcon, ShapesIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { fieldButtonList } from '../envelope-editor-fields-drag-drop';
import { EnvelopeRecipientSelectorCommand } from '../envelope-recipient-selector';
import { EnvelopeCanvasActionButton, EnvelopeCanvasActionButtonGroup } from './envelope-canvas-action-bar';
type EnvelopeCanvasFieldActionButtonsProps = {
selectedFieldFormIds: string[];
onDuplicate: () => void;
onDuplicateOnAllPages: () => void;
onDelete: () => void;
onChangeRecipient: (recipientId: number) => void;
onChangeFieldType: (type: FieldType) => void;
};
/**
* The floating actions for the selected fields.
*/
export const EnvelopeCanvasFieldActionButtons = ({
selectedFieldFormIds,
onDuplicate,
onDuplicateOnAllPages,
onDelete,
onChangeRecipient,
onChangeFieldType,
}: EnvelopeCanvasFieldActionButtonsProps) => {
const { t } = useLingui();
const [showRecipientSelector, setShowRecipientSelector] = useState(false);
const [showFieldTypeSelector, setShowFieldTypeSelector] = useState(false);
const { editorFields, envelope } = useCurrentEnvelopeEditor();
const selectedFields = useMemo(
() => editorFields.localFields.filter((field) => selectedFieldFormIds.includes(field.formId)),
[editorFields.localFields, selectedFieldFormIds],
);
/**
* Decide the preselected field type in the command input.
*
* If all fields share the same type, use that as the default selection.
* Otherwise show no preselection.
*/
const preselectedFieldType = useMemo(() => {
if (selectedFields.length === 0) {
return null;
}
const firstType = selectedFields[0].type;
const isTypesSame = selectedFields.every((field) => field.type === firstType);
return isTypesSame ? firstType : null;
}, [selectedFields]);
/**
* Decide the preselected recipient in the command input.
*
* If all fields belong to the same recipient then use that recipient as the default.
*
* Otherwise show the placeholder.
*/
const preselectedRecipient = useMemo(() => {
if (selectedFields.length === 0) {
return null;
}
const recipient = envelope.recipients.find((recipient) => recipient.id === selectedFields[0].recipientId);
if (!recipient) {
return null;
}
const isRecipientsSame = selectedFields.every((field) => field.recipientId === recipient.id);
return isRecipientsSame ? recipient : null;
}, [selectedFields, envelope.recipients]);
return (
<>
<EnvelopeCanvasActionButtonGroup>
<EnvelopeCanvasActionButton
title={t`Change Recipient`}
icon={UserCircleIcon}
onClick={() => setShowRecipientSelector(true)}
/>
<EnvelopeCanvasActionButton
title={t`Change Field Type`}
icon={ShapesIcon}
onClick={() => setShowFieldTypeSelector(true)}
/>
<EnvelopeCanvasActionButton title={t`Duplicate`} icon={CopyPlusIcon} onClick={onDuplicate} />
<EnvelopeCanvasActionButton
title={t`Duplicate on all pages`}
icon={SquareStackIcon}
onClick={onDuplicateOnAllPages}
/>
<EnvelopeCanvasActionButton title={t`Remove`} icon={TrashIcon} onClick={onDelete} />
</EnvelopeCanvasActionButtonGroup>
<CommandDialog position="start" open={showRecipientSelector} onOpenChange={setShowRecipientSelector}>
<EnvelopeRecipientSelectorCommand
placeholder={t`Select a recipient`}
selectedRecipient={preselectedRecipient}
onSelectedRecipientChange={(recipient) => {
editorFields.setSelectedRecipient(recipient.id);
onChangeRecipient(recipient.id);
setShowRecipientSelector(false);
}}
recipients={envelope.recipients}
fields={envelope.fields}
/>
</CommandDialog>
<CommandDialog position="start" open={showFieldTypeSelector} onOpenChange={setShowFieldTypeSelector}>
<Command defaultValue={preselectedFieldType ? t(FRIENDLY_FIELD_TYPE[preselectedFieldType]) : undefined}>
<CommandInput placeholder={t`Select a field type`} />
<CommandList>
<CommandEmpty>
<span className="inline-block px-4 text-muted-foreground">
{t`No field type matching this description was found.`}
</span>
</CommandEmpty>
<CommandGroup>
{fieldButtonList.map((field) => {
const FieldIcon = field.icon;
const label = t(FRIENDLY_FIELD_TYPE[field.type]);
return (
<CommandItem
key={field.type}
className="px-2"
onSelect={() => {
onChangeFieldType(field.type);
setShowFieldTypeSelector(false);
}}
>
<FieldIcon className="mr-2 h-4 w-4" />
<span className="truncate">{label}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
</>
);
};
@@ -0,0 +1,40 @@
import { useLingui } from '@lingui/react/macro';
import type { ContentDragDropItem } from '../envelope-editor-content-drag-drop';
type EnvelopeCanvasPendingContentMenuProps = {
/**
* The palette items on offer, e.g. without images once the organisation's
* image allowance is used up.
*/
items: ContentDragDropItem[];
onSelectItem: (item: ContentDragDropItem) => void;
};
/**
* The content type picker shown after drawing a marquee on an empty area of
* the page while editing contents, to create a content of that size.
*/
export const EnvelopeCanvasPendingContentMenu = ({ items, onSelectItem }: EnvelopeCanvasPendingContentMenuProps) => {
const { t } = useLingui();
return (
<div
// Don't use darkmode for this component, it should look the same for both light/dark modes.
className="flex w-max items-center gap-x-1 rounded-md border border-gray-300 bg-white p-1 text-gray-500 shadow-sm"
>
{items.map((item) => (
<button
key={item.key}
type="button"
onClick={() => onSelectItem(item)}
className="flex flex-shrink-0 items-center gap-x-1.5 rounded-sm px-2 py-1 text-xs hover:bg-gray-100 hover:text-gray-600"
>
<item.icon className="h-3.5 w-3.5" />
{t(item.name)}
</button>
))}
</div>
);
};
@@ -0,0 +1,34 @@
import { useLingui } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client';
import { fieldButtonList } from '../envelope-editor-fields-drag-drop';
type EnvelopeCanvasPendingFieldMenuProps = {
onSelectType: (type: FieldType) => void;
};
/**
* The field type picker shown after drawing a marquee on an empty area of the
* page, to create a field of that size.
*/
export const EnvelopeCanvasPendingFieldMenu = ({ onSelectType }: EnvelopeCanvasPendingFieldMenuProps) => {
const { t } = useLingui();
return (
<div
// Don't use darkmode for this component, it should look the same for both light/dark modes.
className="grid w-max grid-cols-5 gap-x-1 gap-y-0.5 rounded-md border border-gray-300 bg-white p-1 text-gray-500 shadow-sm"
>
{fieldButtonList.map((field) => (
<button
key={field.type}
type="button"
onClick={() => onSelectType(field.type)}
className="col-span-1 w-full flex-shrink-0 rounded-sm px-2 py-1 text-xs hover:bg-gray-100 hover:text-gray-600"
>
{t(field.name)}
</button>
))}
</div>
);
};
@@ -0,0 +1,59 @@
import { CONTENT_GROUP_NODE_NAME } from '@documenso/lib/universal/content-renderer/content-renderer';
import type Konva from 'konva';
import type { RefObject } from 'react';
/**
* The shared canvas handles and page metrics passed to the envelope canvas
* hooks, sourced from `usePageRenderer` and the page render data.
*/
export type EnvelopeCanvas = {
stage: RefObject<Konva.Stage | null>;
pageLayer: RefObject<Konva.Layer | null>;
/**
* The scale of the stage relative to the unscaled page.
*/
scale: number;
pageNumber: number;
/**
* The raw page size in pixels.
*/
unscaledViewport: { width: number; height: number };
/**
* The page size in pixels as rendered on the stage.
*/
scaledViewport: { width: number; height: number };
};
export type EnvelopeCanvasSelectionKind = 'field' | 'content';
/**
* The current canvas selection. Fields and contents share one transformer, so
* only one kind can be selected at a time.
*/
export type EnvelopeCanvasSelection = {
kind: EnvelopeCanvasSelectionKind;
groups: Konva.Group[];
} | null;
/**
* The Konva group names for each selectable kind.
*/
export const ENVELOPE_CANVAS_GROUP_NAMES: Record<EnvelopeCanvasSelectionKind, string> = {
// Defined by the field renderer in `field-generic-items.ts`.
field: 'field-group',
content: CONTENT_GROUP_NODE_NAME,
};
/**
* A box in scaled stage coordinates, e.g. from a client rect.
*/
export type EnvelopeCanvasBox = {
x: number;
y: number;
width: number;
height: number;
};
@@ -0,0 +1,112 @@
import Konva from 'konva';
import type { EnvelopeCanvasSelectionKind } from './envelope-canvas-types';
type ReconcileEnvelopeCanvasGroupsOptions<T> = {
layer: Konva.Layer;
/**
* The Konva group name of the items being reconciled.
*/
groupName: string;
items: T[];
getRenderId: (item: T) => string;
render: (item: T) => void;
};
/**
* Sync a layer's groups of a given name with a list of items.
*
* Groups whose ID no longer matches an item are destroyed, then every item is
* (re)rendered so existing groups pick up any changes.
*/
export const reconcileEnvelopeCanvasGroups = <T>({
layer,
groupName,
items,
getRenderId,
render,
}: ReconcileEnvelopeCanvasGroupsOptions<T>) => {
const renderIds = new Set(items.map(getRenderId));
layer.find('Group').forEach((group) => {
if (group.name() === groupName && !renderIds.has(group.id())) {
group.destroy();
}
});
for (const item of items) {
render(item);
}
};
/**
* Filter selected groups down to those which are still attached to the stage
* and still correspond to an item on the page, e.g. after items are deleted
* or the page is resynced.
*/
export const getLiveEnvelopeCanvasGroups = (groups: Konva.Group[], isOnPage: (renderId: string) => boolean) => {
return groups.filter((group) => Boolean(group.getStage()) && Boolean(group.getParent()) && isOnPage(group.id()));
};
type SyncEditorSelectionToCanvasOptions = {
layer: Konva.Layer;
kind: EnvelopeCanvasSelectionKind;
/**
* The form ID of the item selected within the editor, if any.
*/
editorFormId: string | null;
isOnPage: (renderId: string) => boolean;
/**
* The currently selected groups of this kind.
*/
selectedGroups: Konva.Group[];
select: (kind: EnvelopeCanvasSelectionKind, nodes: Konva.Node[]) => void;
clear: () => void;
};
/**
* Sync the editor's single selected item onto the canvas selection.
*
* Creating an item marks it as selected within the editor, so this makes a
* newly placed item show its transformer immediately without a second click.
* It also clears the canvas selection when the editor selection is cleared,
* so a stale transformer can't linger.
*
* Must run after the groups have been rendered so the item's group exists.
*/
export const syncEditorSelectionToCanvas = ({
layer,
kind,
editorFormId,
isOnPage,
selectedGroups,
select,
clear,
}: SyncEditorSelectionToCanvasOptions) => {
const isSingleSelection = selectedGroups.length === 1;
if (editorFormId && isOnPage(editorFormId)) {
const isAlreadySelected = isSingleSelection && selectedGroups[0].id() === editorFormId;
if (isAlreadySelected) {
return;
}
const groupToSelect = layer.findOne(`#${editorFormId}`);
if (groupToSelect instanceof Konva.Group) {
select(kind, [groupToSelect]);
}
return;
}
if (editorFormId === null && isSingleSelection) {
clear();
}
};
@@ -0,0 +1,448 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import type { TLocalContent } from '@documenso/lib/client-only/hooks/use-editor-contents';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { TEnvelopeContentMeta } from '@documenso/lib/types/envelope-content-meta';
import {
CONTENT_META_DEFAULT_VALUES,
CONTENT_SHAPE_META_DEFAULT_VALUES_BY_SHAPE,
EnvelopeContentShapeType,
EnvelopeContentType,
} from '@documenso/lib/types/envelope-content-meta';
import {
readContentGroupTransform,
resolveContentMetaFromTransform,
} from '@documenso/lib/universal/content-renderer/content-geometry';
import {
MIN_CONTENT_HEIGHT_PX,
MIN_CONTENT_WIDTH_PX,
} from '@documenso/lib/universal/content-renderer/content-renderer';
import { renderContent } from '@documenso/lib/universal/content-renderer/render-content';
import { getLocalContentOrderId, sortContentsForRender } from '@documenso/lib/utils/envelope-content';
import { getClientSideContentTranslations } from '@documenso/lib/utils/envelope-content-translations';
import type { PercentageBox } from '@documenso/lib/utils/geometry';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import { useLingui } from '@lingui/react/macro';
import Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import { useEffect, useMemo } from 'react';
import type { ContentDragDropItem } from '../envelope-editor-content-drag-drop';
import type { EnvelopeCanvas, EnvelopeCanvasBox } from './envelope-canvas-types';
import { ENVELOPE_CANVAS_GROUP_NAMES } from './envelope-canvas-types';
import {
getLiveEnvelopeCanvasGroups,
reconcileEnvelopeCanvasGroups,
syncEditorSelectionToCanvas,
} from './reconcile-envelope-canvas-groups';
import { findEnvelopeCanvasGroupsInBox } from './use-envelope-canvas-marquee';
import { useEnvelopeCanvasPendingCreation } from './use-envelope-canvas-pending-creation';
import type { EnvelopeCanvasSelectionApi } from './use-envelope-canvas-selection';
const PENDING_CONTENT_NODE_NAME = 'pending-content-creation';
type UseEnvelopeCanvasContentsLayerOptions = {
canvas: EnvelopeCanvas;
selection: EnvelopeCanvasSelectionApi;
/**
* Whether contents can currently be edited on the canvas.
*/
isEditable: boolean;
/**
* Whether the envelope has used up the organisation's content allowance,
* in which case no further contents can be created from the canvas.
*/
isContentLimitReached: boolean;
applyPageItemsVisibility: () => void;
};
/**
* Renders and manages the contents of the current page on the canvas.
*
* Owns content rendering, z-ordering relative to fields, geometry write back,
* the marquee-to-create flow and the selection actions for contents.
*/
export const useEnvelopeCanvasContentsLayer = ({
canvas,
selection,
isEditable,
isContentLimitReached,
applyPageItemsVisibility,
}: UseEnvelopeCanvasContentsLayerOptions) => {
const { i18n } = useLingui();
const analytics = useAnalytics();
const { envelope, editorContents, selectedEditorTab } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, contentImages, setRenderError } = useCurrentEnvelopeRender();
const { stage, pageLayer, scale, pageNumber, unscaledViewport } = canvas;
const { contentGroups: selectedGroups } = selection;
/**
* The rectangle drawn via the marquee which is pending a content type choice.
*/
const pending = useEnvelopeCanvasPendingCreation({ canvas, nodeName: PENDING_CONTENT_NODE_NAME });
/**
* The page's contents in stacking order, so rendering them in sequence
* puts the last one on top. Local contents are ordered by their form ID as
* the tiebreak, the same way persisted contents are ordered by their ID.
*/
const localPageContents = useMemo(
() =>
sortContentsForRender(
editorContents.localContents
.filter(
(content) => content.contentMeta.page === pageNumber && content.envelopeItemId === currentEnvelopeItem?.id,
)
.map((content) => ({ ...content, id: getLocalContentOrderId(content) })),
(content) => content.contentMeta.zIndex,
),
[editorContents.localContents, pageNumber, currentEnvelopeItem?.id],
);
const isOnPage = (formId: string) => localPageContents.some((content) => content.formId === formId);
/**
* Write the new geometry of a content back into its content meta after a drag
* or resize/rotate gesture.
*/
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
const isDragEvent = event.type === 'dragend';
const contentGroup = event.target as Konva.Group;
const contentFormId = contentGroup.id();
const content = editorContents.getContentByFormId(contentFormId);
if (!content) {
return;
}
editorContents.updateContentByFormId(contentFormId, {
contentMeta: resolveContentMetaFromTransform(
content.contentMeta,
readContentGroupTransform(contentGroup),
isDragEvent ? 'drag' : 'transform',
unscaledViewport.width,
unscaledViewport.height,
),
});
// Select the content if it is not already selected.
if (isDragEvent && !selection.isSelected(contentGroup)) {
selection.select('content', [contentGroup]);
}
pageLayer.current?.batchDraw();
};
const unsafeRenderContent = (content: TLocalContent) => {
if (!pageLayer.current) {
return;
}
const { contentGroup } = renderContent(
{
renderId: content.formId,
contentMeta: content.contentMeta,
dataContentId: content.dataContentId,
},
{
pageLayer: pageLayer.current,
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
scale,
mode: 'edit',
editable: isEditable,
translations: getClientSideContentTranslations(i18n),
hoverOutlineColor: getRecipientColorStyles('green').baseRing,
images: contentImages.images,
},
);
// Contents are inert while the fields tab is active so they behave as part
// of the document. Their stacking relative to the fields is applied to
// all of them together afterwards, see `applyContentsStacking`.
contentGroup.listening(isEditable);
if (!isEditable) {
return;
}
contentGroup.off('click');
contentGroup.off('transformend');
contentGroup.off('dragend');
// A plain click selects just this content, shift + click toggles it
// in/out of the current selection.
contentGroup.on('click', (event) => {
if (event.evt.shiftKey) {
selection.toggle('content', contentGroup);
} else {
selection.select('content', [contentGroup]);
}
pageLayer.current?.batchDraw();
});
contentGroup.on('transformend', handleResizeOrMove);
contentGroup.on('dragend', handleResizeOrMove);
};
const renderContentOnLayer = (content: TLocalContent) => {
try {
unsafeRenderContent(content);
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'editor',
location: 'envelope_page_render',
envelopeId: envelope.id,
});
setRenderError(true);
}
};
/**
* Place the contents as a band relative to the fields, keeping their
* stacking order intact: above the muted fields while the contents tab is
* active so they are fully visible and clickable, beneath the fields
* otherwise as part of the document.
*
* Konva stacks by child order, so the band is moved one group at a time.
* Sending to the bottom iterates in reverse so the last content stays on
* top, as when sending to the top in order.
*/
const applyContentsStacking = () => {
const layer = pageLayer.current;
if (!layer) {
return;
}
const groups = localPageContents.flatMap((content) => {
const group = layer.findOne(`#${content.formId}`);
return group instanceof Konva.Group ? [group] : [];
});
if (selectedEditorTab === 'contents') {
for (const group of groups) {
group.moveToTop();
}
} else {
for (const group of [...groups].reverse()) {
group.moveToBottom();
}
}
};
const renderAll = () => {
for (const content of localPageContents) {
renderContentOnLayer(content);
}
applyContentsStacking();
};
/**
* Resolve a marquee selection box into a content selection, or into a
* pending content creation when nothing was selected and the box is large
* enough.
*/
const selectInBox = (box: EnvelopeCanvasBox) => {
const currentStage = stage.current;
if (!currentStage || !isEditable) {
return;
}
const groupsInBox = findEnvelopeCanvasGroupsInBox(currentStage, ENVELOPE_CANVAS_GROUP_NAMES.content, box);
selection.select('content', groupsInBox);
const unscaledBoxWidth = box.width / scale;
const unscaledBoxHeight = box.height / scale;
// Create a content if no items are selected or the size is too small.
if (
groupsInBox.length === 0 &&
!isContentLimitReached &&
unscaledBoxWidth > MIN_CONTENT_WIDTH_PX &&
unscaledBoxHeight > MIN_CONTENT_HEIGHT_PX
) {
pending.setPendingFromBox(box);
}
};
/**
* Create a content of the given palette item from the pending creation
* rectangle.
*
* Unlike a palette drop, which places a content at its default size, the
* content is fitted to the box the author drew.
*/
const createFromPending = (item: ContentDragDropItem) => {
const box = pending.getPendingBox();
pending.clearPending();
if (!box || !currentEnvelopeItem) {
return;
}
editorContents.addContent({
envelopeItemId: currentEnvelopeItem.id,
contentMeta: buildContentMetaFromBox(item, pageNumber, box),
});
};
/**
* Render contents when they are added, removed or updated, and when an
* image arrives after the page was created (a failed image simply keeps
* its placeholder so the author can re-upload).
*/
useEffect(() => {
const layer = pageLayer.current;
if (!layer || !stage.current) {
return;
}
reconcileEnvelopeCanvasGroups({
layer,
groupName: ENVELOPE_CANVAS_GROUP_NAMES.content,
items: localPageContents,
getRenderId: (content) => content.formId,
render: renderContentOnLayer,
});
applyContentsStacking();
// Reconcile selection state with live content nodes after flush/sync updates.
const liveSelectedGroups = getLiveEnvelopeCanvasGroups(selectedGroups, isOnPage);
if (liveSelectedGroups.length !== selectedGroups.length) {
selection.select('content', liveSelectedGroups);
}
// Only sync while editable, otherwise a transformer would be attached
// to an inert group.
if (isEditable) {
syncEditorSelectionToCanvas({
layer,
kind: 'content',
editorFormId: editorContents.selectedContent?.formId ?? null,
isOnPage,
selectedGroups,
select: selection.select,
clear: selection.clear,
});
}
applyPageItemsVisibility();
selection.refreshTransformer();
layer.batchDraw();
}, [
localPageContents,
selectedGroups,
selectedEditorTab,
isEditable,
editorContents.selectedContent?.formId,
contentImages.images,
]);
/**
* Selecting a single content brings it to the front of its page, so the
* content being worked on is never hidden behind another.
*/
useEffect(() => {
if (isEditable && selectedGroups.length === 1) {
editorContents.bringContentToFront(selectedGroups[0].id());
}
}, [isEditable, selectedGroups]);
/**
* Clear any active content selection and pending content creation when
* contents are no longer editable, e.g. when switching back to the fields
* tab or hiding contents via the viewer toolbar.
*/
useEffect(() => {
if (!isEditable && (selectedGroups.length > 0 || pending.pendingCreation)) {
pending.clearPending();
selection.clear();
pageLayer.current?.batchDraw();
}
}, [isEditable, selectedGroups, pending.pendingCreation]);
const getSelectedContents = () =>
selectedGroups
.map((group) => editorContents.getContentByFormId(group.id()))
.filter((content) => content !== undefined);
const deleteSelected = () => {
editorContents.removeContentsByFormId(selectedGroups.map((group) => group.id()));
selection.clear();
};
const duplicateSelected = () => {
for (const content of getSelectedContents()) {
editorContents.duplicateContent(content);
}
};
return {
localPageContents,
renderAll,
selectInBox,
pendingCreation: pending.pendingCreation,
createFromPending,
clearPending: pending.clearPending,
deleteSelected,
duplicateSelected,
};
};
/**
* Build the meta of a content created from a box drawn on the page, merging
* the box into the default values of the item.
*
* Box contents take the box as their bounds. Lines have no box of their own,
* so they run horizontally along its top edge, from the top left to the top
* right corner.
*/
const buildContentMetaFromBox = (item: ContentDragDropItem, page: number, box: PercentageBox): TEnvelopeContentMeta => {
// Shapes share a content type and are told apart by `shape`.
const defaultMeta =
item.type === EnvelopeContentType.SHAPE
? CONTENT_SHAPE_META_DEFAULT_VALUES_BY_SHAPE[item.shape ?? EnvelopeContentShapeType.RECTANGLE]
: CONTENT_META_DEFAULT_VALUES[item.type];
const meta = { ...structuredClone(defaultMeta), page };
if (meta.type === EnvelopeContentType.LINE) {
return {
...meta,
x1: box.positionX,
y1: box.positionY,
x2: box.positionX + box.width,
y2: box.positionY,
};
}
return {
...meta,
positionX: box.positionX,
positionY: box.positionY,
width: box.width,
height: box.height,
};
};
@@ -0,0 +1,472 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { EnvelopePageItemsVisibility } from '@documenso/lib/types/envelope-page-items-visibility';
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import { MIN_FIELD_HEIGHT_PX, MIN_FIELD_WIDTH_PX } from '@documenso/lib/universal/field-renderer/field-renderer';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { useLingui } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client';
import Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import { useEffect, useMemo } from 'react';
import type { EnvelopeCanvas, EnvelopeCanvasBox } from './envelope-canvas-types';
import { ENVELOPE_CANVAS_GROUP_NAMES } from './envelope-canvas-types';
import {
getLiveEnvelopeCanvasGroups,
reconcileEnvelopeCanvasGroups,
syncEditorSelectionToCanvas,
} from './reconcile-envelope-canvas-groups';
import { findEnvelopeCanvasGroupsInBox } from './use-envelope-canvas-marquee';
import { useEnvelopeCanvasPendingCreation } from './use-envelope-canvas-pending-creation';
import type { EnvelopeCanvasSelectionApi } from './use-envelope-canvas-selection';
const PENDING_FIELD_NODE_NAME = 'pending-field-creation';
type UseEnvelopeCanvasFieldsLayerOptions = {
canvas: EnvelopeCanvas;
selection: EnvelopeCanvasSelectionApi;
fieldsVisibility: EnvelopePageItemsVisibility;
applyPageItemsVisibility: () => void;
};
/**
* Renders and manages the fields of the current page on the canvas.
*
* Owns field rendering, overlap highlighting, geometry write back, the
* marquee-to-create flow and the selection actions for fields.
*/
export const useEnvelopeCanvasFieldsLayer = ({
canvas,
selection,
fieldsVisibility,
applyPageItemsVisibility,
}: UseEnvelopeCanvasFieldsLayerOptions) => {
const { i18n } = useLingui();
const analytics = useAnalytics();
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
const { stage, pageLayer, scale, pageNumber, unscaledViewport, scaledViewport } = canvas;
const { fieldGroups: selectedGroups, isTransforming } = selection;
/**
* The rectangle drawn via the marquee which is pending a field type choice.
*/
const pending = useEnvelopeCanvasPendingCreation({ canvas, nodeName: PENDING_FIELD_NODE_NAME });
const localPageFields = useMemo(
() =>
editorFields.localFields.filter(
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
),
[editorFields.localFields, pageNumber, currentEnvelopeItem?.id],
);
const isOnPage = (formId: string) => localPageFields.some((field) => field.formId === formId);
/**
* Debounce the fields used for overlap highlighting so we don't recompute on every
* small drag/resize tick. Overlaps only occur within the same page and envelope
* item, so computing from this page's fields alone is sufficient.
*/
const debouncedPageFields = useDebouncedValue(localPageFields, 300);
const overlappingFieldFormIds = useMemo(() => {
const formIds = new Set<string>();
const pairs = getOverlappingFieldPairs(
debouncedPageFields.map((field) => ({
id: field.formId,
envelopeItemId: field.envelopeItemId,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
})),
);
for (const pair of pairs) {
formIds.add(pair.fieldA.id);
formIds.add(pair.fieldB.id);
}
return formIds;
}, [debouncedPageFields]);
/**
* Write the new geometry of a field back after a drag or resize gesture.
*/
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
const isDragEvent = event.type === 'dragend';
const fieldGroup = event.target as Konva.Group;
const fieldFormId = fieldGroup.id();
// Note: This values are scaled.
const {
width: fieldPixelWidth,
height: fieldPixelHeight,
x: fieldX,
y: fieldY,
} = fieldGroup.getClientRect({
skipStroke: true,
skipShadow: true,
});
const pageHeight = scaledViewport.height;
const pageWidth = scaledViewport.width;
// Calculate x and y as a percentage of the page width and height
const positionPercentX = (fieldX / pageWidth) * 100;
const positionPercentY = (fieldY / pageHeight) * 100;
// Get the bounds as a percentage of the page width and height
const fieldPageWidth = (fieldPixelWidth / pageWidth) * 100;
const fieldPageHeight = (fieldPixelHeight / pageHeight) * 100;
const fieldUpdates: Partial<TLocalField> = {
positionX: positionPercentX,
positionY: positionPercentY,
};
// Do not update the width/height unless the field has actually been resized.
// This is because our calculations will shift the width/height slightly
// due to the way we convert between pixel and percentage.
if (!isDragEvent) {
fieldUpdates.width = fieldPageWidth;
fieldUpdates.height = fieldPageHeight;
}
editorFields.updateFieldByFormId(fieldFormId, fieldUpdates);
// Select the field if it is not already selected.
if (isDragEvent && !selection.isSelected(fieldGroup)) {
selection.select('field', [fieldGroup]);
}
pageLayer.current?.batchDraw();
};
/**
* Draws (or removes) a dashed warning outline over a field that significantly
* overlaps another field. The highlight is a child of the field group so it moves
* and resizes with the field, and sits on top of the field's own rect (which is
* re-styled on every render and would otherwise clobber a direct stroke change).
*/
const syncOverlapHighlight = (fieldGroup: Konva.Group, isOverlapping: boolean) => {
const existingHighlight = fieldGroup.findOne('.field-overlap-highlight');
// Skip while a field is actively being dragged/resized. The highlight is driven
// by debounced field data, so it would lag behind and distort during the gesture.
// It is repainted once the gesture settles (the effect re-runs on isTransforming).
if (isTransforming || !isOverlapping) {
existingHighlight?.destroy();
return;
}
const fieldRect = fieldGroup.findOne('.field-rect');
if (!fieldRect) {
return;
}
const highlightAttrs = {
x: 0,
y: 0,
width: fieldRect.width(),
height: fieldRect.height(),
stroke: '#f59e0b',
strokeWidth: 2,
dash: [6, 4],
cornerRadius: 2,
strokeScaleEnabled: false,
listening: false,
} satisfies Partial<Konva.RectConfig>;
if (existingHighlight instanceof Konva.Rect) {
existingHighlight.setAttrs(highlightAttrs);
existingHighlight.moveToTop();
return;
}
const highlight = new Konva.Rect({
name: 'field-overlap-highlight',
...highlightAttrs,
});
fieldGroup.add(highlight);
highlight.moveToTop();
};
const unsafeRenderField = (field: TLocalField) => {
if (!pageLayer.current) {
return;
}
// Muted fields are rendered with the read-only styling and cannot be
// edited, mirroring how other recipients' fields look during signing.
const isMuted = fieldsVisibility === 'muted';
const recipient = envelope.recipients.find((r) => r.id === field.recipientId);
const isFieldEditable =
!isMuted && recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
const { fieldGroup } = renderField({
scale,
pageLayer: pageLayer.current,
field: {
renderId: field.formId,
...field,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
},
translations: getClientSideFieldTranslations(i18n),
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
color: isMuted ? 'readOnly' : getRecipientColorKey(field.recipientId),
editable: isFieldEditable,
mode: 'edit',
});
syncOverlapHighlight(fieldGroup, overlappingFieldFormIds.has(field.formId));
if (!isFieldEditable) {
return;
}
fieldGroup.off('click');
fieldGroup.off('transformend');
fieldGroup.off('dragend');
// A plain click selects just this field, shift + click toggles it in/out
// of the current selection.
fieldGroup.on('click', (event) => {
pending.clearPending();
if (event.evt.shiftKey) {
selection.toggle('field', fieldGroup);
} else {
selection.select('field', [fieldGroup]);
}
pageLayer.current?.batchDraw();
});
fieldGroup.on('transformend', handleResizeOrMove);
fieldGroup.on('dragend', handleResizeOrMove);
};
const renderFieldOnLayer = (field: TLocalField) => {
try {
unsafeRenderField(field);
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'editor',
location: 'envelope_page_render',
envelopeId: envelope.id,
});
setRenderError(true);
}
};
/**
* Render every field on the page. Called when the page canvas is created.
*/
const renderAll = () => {
for (const field of localPageFields) {
renderFieldOnLayer(field);
}
};
/**
* Resolve a marquee selection box into a field selection, or into a pending
* field creation when nothing was selected and the box is large enough.
*/
const selectInBox = (box: EnvelopeCanvasBox) => {
const currentStage = stage.current;
if (!currentStage) {
return;
}
// While fields are hidden they cannot be selected or created.
if (fieldsVisibility !== 'visible') {
return;
}
const groupsInBox = findEnvelopeCanvasGroupsInBox(currentStage, ENVELOPE_CANVAS_GROUP_NAMES.field, box);
selection.select('field', groupsInBox);
const unscaledBoxWidth = box.width / scale;
const unscaledBoxHeight = box.height / scale;
// Create a field if no items are selected or the size is too small.
if (
groupsInBox.length === 0 &&
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
editorFields.selectedRecipient &&
canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields)
) {
pending.setPendingFromBox(box);
}
};
/**
* Create a field of the given type from the pending creation rectangle.
*/
const createFromPending = (type: FieldType) => {
const box = pending.getPendingBox();
pending.clearPending();
if (!box || !currentEnvelopeItem || !editorFields.selectedRecipient) {
return;
}
editorFields.addField({
envelopeItemId: currentEnvelopeItem.id,
page: pageNumber,
type,
positionX: box.positionX,
positionY: box.positionY,
width: box.width,
height: box.height,
recipientId: editorFields.selectedRecipient.id,
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[type]),
});
};
/**
* Render fields when they are added, removed or updated.
*/
useEffect(() => {
const layer = pageLayer.current;
if (!layer || !stage.current) {
return;
}
reconcileEnvelopeCanvasGroups({
layer,
groupName: ENVELOPE_CANVAS_GROUP_NAMES.field,
items: localPageFields,
getRenderId: (field) => field.formId,
render: renderFieldOnLayer,
});
// Reconcile selection state with live field nodes after flush/sync updates.
const liveSelectedGroups = getLiveEnvelopeCanvasGroups(selectedGroups, isOnPage);
if (liveSelectedGroups.length !== selectedGroups.length) {
selection.select('field', liveSelectedGroups);
}
syncEditorSelectionToCanvas({
layer,
kind: 'field',
editorFormId: editorFields.selectedField?.formId ?? null,
isOnPage,
selectedGroups,
select: selection.select,
clear: selection.clear,
});
applyPageItemsVisibility();
selection.refreshTransformer();
layer.batchDraw();
}, [
localPageFields,
selectedGroups,
overlappingFieldFormIds,
isTransforming,
editorFields.selectedField?.formId,
fieldsVisibility,
]);
/**
* Clear any active selection and pending field creation when fields are no
* longer visible, since the transformer and floating toolbars would
* otherwise remain anchored to hidden fields.
*/
useEffect(() => {
if (fieldsVisibility !== 'visible' && (selectedGroups.length > 0 || pending.pendingCreation)) {
pending.clearPending();
selection.clear();
pageLayer.current?.batchDraw();
}
}, [fieldsVisibility, selectedGroups, pending.pendingCreation]);
const getSelectedFields = () =>
selectedGroups.map((group) => editorFields.getFieldByFormId(group.id())).filter((field) => field !== undefined);
const deleteSelected = () => {
editorFields.removeFieldsByFormId(selectedGroups.map((group) => group.id()));
selection.clear();
};
const duplicateSelected = () => {
for (const field of getSelectedFields()) {
editorFields.duplicateField(field);
}
};
const duplicateSelectedOnAllPages = () => {
for (const field of getSelectedFields()) {
editorFields.duplicateFieldToAllPages(field);
}
selection.clear();
};
const changeSelectedRecipient = (recipientId: number) => {
for (const field of getSelectedFields()) {
if (field.recipientId !== recipientId) {
editorFields.updateFieldByFormId(field.formId, { recipientId, id: undefined });
}
}
};
const changeSelectedType = (type: FieldType) => {
for (const field of getSelectedFields()) {
if (field.type !== type) {
editorFields.updateFieldByFormId(field.formId, {
type,
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[type]),
id: undefined,
});
}
}
};
return {
localPageFields,
renderAll,
selectInBox,
pendingCreation: pending.pendingCreation,
createFromPending,
clearPending: pending.clearPending,
deleteSelected,
duplicateSelected,
duplicateSelectedOnAllPages,
changeSelectedRecipient,
changeSelectedType,
};
};
@@ -0,0 +1,195 @@
import type { TLocalContent } from '@documenso/lib/client-only/hooks/use-editor-contents';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { EnvelopeContentType } from '@documenso/lib/types/envelope-content-meta';
import { resolveLineMetaFromPoints } from '@documenso/lib/universal/content-renderer/content-geometry';
import {
CONTENT_LINE_NODE_NAME,
calculateContentLineGeometry,
} from '@documenso/lib/universal/content-renderer/content-renderer';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import Konva from 'konva';
import { useEffect, useMemo, useState } from 'react';
import type { EnvelopeCanvas } from './envelope-canvas-types';
import type { EnvelopeCanvasSelectionApi } from './use-envelope-canvas-selection';
const LINE_ANCHOR_NODE_NAME = 'content-line-anchor';
/**
* The screen size radius of the line endpoint anchors.
*/
const LINE_ANCHOR_RADIUS = 5;
type UseEnvelopeCanvasLineAnchorsOptions = {
canvas: EnvelopeCanvas;
selection: EnvelopeCanvasSelectionApi;
isEditable: boolean;
localPageContents: TLocalContent[];
};
/**
* Draggable endpoint anchors for a single selected line content.
*
* Lines are move-only within the shared transformer, so the endpoints are
* edited through these custom anchors instead. Anchors are hidden while the
* line body itself is being dragged, and recreated afterwards.
*/
export const useEnvelopeCanvasLineAnchors = ({
canvas,
selection,
isEditable,
localPageContents,
}: UseEnvelopeCanvasLineAnchorsOptions) => {
const { editorContents } = useCurrentEnvelopeEditor();
const { pageLayer, scale, unscaledViewport, scaledViewport } = canvas;
const { contentGroups: selectedGroups, isTransforming } = selection;
/**
* Whether an anchor is being dragged.
*
* Tracked separately from the selection's `isTransforming` since that would
* destroy the anchors mid drag (this effect depends on it), whereas this only
* needs to hide the floating action bar.
*/
const [isDragging, setIsDragging] = useState(false);
/**
* The selected line content, if exactly one line content is selected.
*/
const selectedLineContent = useMemo((): TLocalContent | null => {
if (selectedGroups.length !== 1) {
return null;
}
const content = editorContents.getContentByFormId(selectedGroups[0].id());
return content?.contentMeta.type === EnvelopeContentType.LINE ? content : null;
// Depends on the contents themselves rather than the getter, which is
// stable, so the anchors follow the line as its coordinates change.
}, [selectedGroups, localPageContents, editorContents.getContentByFormId]);
useEffect(() => {
const layer = pageLayer.current;
if (!layer) {
return;
}
const destroyAnchors = () => {
for (const node of layer.find(`.${LINE_ANCHOR_NODE_NAME}`)) {
node.destroy();
}
};
destroyAnchors();
const contentMeta = selectedLineContent?.contentMeta;
if (!selectedLineContent || contentMeta?.type !== EnvelopeContentType.LINE || !isEditable || isTransforming) {
layer.batchDraw();
return;
}
const geometry = calculateContentLineGeometry(contentMeta, unscaledViewport.width, unscaledViewport.height);
const findLineNodes = () => {
const lineGroup = layer.findOne(`#${selectedLineContent.formId}`);
if (!(lineGroup instanceof Konva.Group)) {
return null;
}
const contentLine = lineGroup.findOne(`.${CONTENT_LINE_NODE_NAME}`);
if (!(contentLine instanceof Konva.Line)) {
return null;
}
return { lineGroup, contentLine };
};
const endpoints = [
{ pointIndex: 0, x: geometry.x + geometry.points[0], y: geometry.y + geometry.points[1] },
{ pointIndex: 2, x: geometry.x + geometry.points[2], y: geometry.y + geometry.points[3] },
];
for (const endpoint of endpoints) {
const anchor = new Konva.Circle({
name: LINE_ANCHOR_NODE_NAME,
x: endpoint.x,
y: endpoint.y,
// Compensate for the stage scale so the anchors keep a constant
// screen size, mirroring the transformer anchors.
radius: LINE_ANCHOR_RADIUS / scale,
fill: '#ffffff',
// Matches the transformer anchors, themed to the brand green.
stroke: getRecipientColorStyles('green').baseRing,
strokeWidth: 1.5,
strokeScaleEnabled: false,
draggable: true,
// Keep the anchor within the page bounds. Positions are in scaled
// stage coordinates.
dragBoundFunc: (pos) => ({
x: Math.max(0, Math.min(scaledViewport.width, pos.x)),
y: Math.max(0, Math.min(scaledViewport.height, pos.y)),
}),
});
anchor.on('dragstart', () => setIsDragging(true));
// Live preview while dragging the anchor.
anchor.on('dragmove', () => {
const lineNodes = findLineNodes();
if (!lineNodes) {
return;
}
const points = [...lineNodes.contentLine.points()];
points[endpoint.pointIndex] = anchor.x() - lineNodes.lineGroup.x();
points[endpoint.pointIndex + 1] = anchor.y() - lineNodes.lineGroup.y();
lineNodes.contentLine.points(points);
layer.batchDraw();
});
// Write the new endpoint positions back into the content meta. The
// reconcile effect re-renders the normalized geometry and recreates
// the anchors.
anchor.on('dragend', () => {
setIsDragging(false);
const lineNodes = findLineNodes();
if (!lineNodes) {
return;
}
editorContents.updateContentByFormId(selectedLineContent.formId, {
contentMeta: resolveLineMetaFromPoints(
contentMeta,
lineNodes.lineGroup.x(),
lineNodes.lineGroup.y(),
lineNodes.contentLine.points(),
unscaledViewport.width,
unscaledViewport.height,
),
});
});
layer.add(anchor);
anchor.moveToTop();
}
layer.batchDraw();
return () => {
destroyAnchors();
};
}, [selectedLineContent, isEditable, isTransforming, localPageContents, scale, scaledViewport]);
return { isDragging };
};
@@ -0,0 +1,201 @@
import { useLatestRef } from '@documenso/lib/client-only/hooks/use-latest-ref';
import { KONVA_SELECTION_FILL_COLOR } from '@documenso/lib/universal/konva/constants';
import Konva from 'konva';
import { useCallback } from 'react';
import { clamp } from 'remeda';
import type { EnvelopeCanvasBox } from './envelope-canvas-types';
type UseEnvelopeCanvasMarqueeOptions = {
/**
* Called when a marquee drag ends, with the selection box in scaled stage
* coordinates. The caller decides what the box selects.
*/
onSelect: (box: EnvelopeCanvasBox) => void;
/**
* Called when an empty area of the stage is clicked without dragging.
*/
onEmptyClick: () => void;
};
/**
* Drag-to-select marquee on an empty area of the stage.
*
* The callbacks are read through refs, so the once-bound stage handlers always
* delegate to the latest render's logic.
*/
export const useEnvelopeCanvasMarquee = ({ onSelect, onEmptyClick }: UseEnvelopeCanvasMarqueeOptions) => {
const onSelectRef = useLatestRef(onSelect);
const onEmptyClickRef = useLatestRef(onEmptyClick);
/**
* Bind the marquee to a stage. Called once when the page canvas is created.
*/
const bind = useCallback((stage: Konva.Stage, layer: Konva.Layer) => {
const selectionRectangle = new Konva.Rect({
name: 'marquee-selection',
fill: KONVA_SELECTION_FILL_COLOR,
visible: false,
});
layer.add(selectionRectangle);
let x1 = 0;
let y1 = 0;
/**
* The pointer in layer coordinates, pinned to the page.
*
* Pointer positions are in scaled stage coordinates, while the rectangle
* lives on the scaled layer, so positions are divided by the stage scale.
*
* The pointer is clamped to the page so the rectangle stops at the page
* edge while the mouse itself is free to leave it, matching how a
* transformer resize behaves.
*/
const getPointer = () => {
const pointerPosition = stage.getPointerPosition();
if (!pointerPosition) {
return null;
}
return {
x: clamp(pointerPosition.x / stage.scaleX(), { min: 0, max: stage.width() / stage.scaleX() }),
y: clamp(pointerPosition.y / stage.scaleY(), { min: 0, max: stage.height() / stage.scaleY() }),
};
};
// The stage only receives pointer events while the pointer is over its
// container, so a drag which leaves the page would freeze the rectangle
// and never finish. Like Konva's own transformer, the move and up events
// are tracked on the window for the duration of a drag instead.
const onWindowPointerMove = (evt: MouseEvent | TouchEvent) => {
// The stage is rebuilt on zoom, which throws away this rectangle.
if (!selectionRectangle.getStage()) {
stopTrackingWindow();
return;
}
// The stage cannot see the pointer once it is outside the container, so
// register its position from the window event.
stage.setPointersPositions(evt);
const pointer = getPointer();
if (!pointer) {
return;
}
selectionRectangle.moveToTop();
selectionRectangle.setAttrs({
x: Math.min(x1, pointer.x),
y: Math.min(y1, pointer.y),
width: Math.abs(pointer.x - x1),
height: Math.abs(pointer.y - y1),
});
};
/**
* Whether the pointer was actually dragged, as opposed to a plain click
* which leaves the rectangle without an area.
*/
const isMarqueeDrawn = () => {
return selectionRectangle.visible() && selectionRectangle.width() > 0 && selectionRectangle.height() > 0;
};
const onWindowPointerUp = () => {
stopTrackingWindow();
if (!selectionRectangle.getStage()) {
return;
}
// Hide in a timeout so the click handler below can still detect that a
// marquee drag just finished.
setTimeout(() => {
selectionRectangle.visible(false);
});
// A plain click is not a selection, the click handler below deals with
// it. Selecting here would also match anything whose bounding box merely
// contains the point (e.g. a diagonal line), and since this runs after
// other window listeners it would override e.g. a content being placed.
if (!isMarqueeDrawn()) {
return;
}
onSelectRef.current(selectionRectangle.getClientRect());
};
const startTrackingWindow = () => {
window.addEventListener('mousemove', onWindowPointerMove);
window.addEventListener('touchmove', onWindowPointerMove);
window.addEventListener('mouseup', onWindowPointerUp);
window.addEventListener('touchend', onWindowPointerUp);
window.addEventListener('touchcancel', onWindowPointerUp);
};
const stopTrackingWindow = () => {
window.removeEventListener('mousemove', onWindowPointerMove);
window.removeEventListener('touchmove', onWindowPointerMove);
window.removeEventListener('mouseup', onWindowPointerUp);
window.removeEventListener('touchend', onWindowPointerUp);
window.removeEventListener('touchcancel', onWindowPointerUp);
};
stage.on('mousedown.marquee touchstart.marquee', (e) => {
// Do nothing if the pointer is down on a shape.
if (e.target !== stage) {
return;
}
const pointer = getPointer();
if (!pointer) {
return;
}
x1 = pointer.x;
y1 = pointer.y;
selectionRectangle.setAttrs({
x: x1,
y: y1,
width: 0,
height: 0,
visible: true,
});
startTrackingWindow();
});
stage.on('click.marquee tap.marquee', (e) => {
// A marquee drag just finished, the selection was handled on mouse up.
if (isMarqueeDrawn()) {
return;
}
if (e.target === stage) {
onEmptyClickRef.current();
}
});
}, []);
return { bind };
};
/**
* Find the groups of a given name which intersect a box, excluding groups
* which are not draggable (i.e. not currently editable).
*/
export const findEnvelopeCanvasGroupsInBox = (stage: Konva.Stage, groupName: string, box: EnvelopeCanvasBox) => {
return stage
.find(`.${groupName}`)
.filter(
(node): node is Konva.Group =>
node instanceof Konva.Group && node.draggable() && Konva.Util.haveIntersection(box, node.getClientRect()),
);
};
@@ -0,0 +1,88 @@
import { KONVA_SELECTION_FILL_COLOR } from '@documenso/lib/universal/konva/constants';
import type { PercentageBox } from '@documenso/lib/utils/geometry';
import { toPercentageBox } from '@documenso/lib/utils/geometry';
import Konva from 'konva';
import { useState } from 'react';
import type { EnvelopeCanvas, EnvelopeCanvasBox } from './envelope-canvas-types';
type UseEnvelopeCanvasPendingCreationOptions = {
canvas: EnvelopeCanvas;
/**
* The Konva name given to the pending rectangle, so it can be found and
* removed from the layer again.
*/
nodeName: string;
};
/**
* The rectangle left on the page after a marquee is drawn over an empty area,
* which is pending a choice of what to create within it.
*
* Shared by the fields and contents layers, which each decide when a marquee
* becomes a pending creation and what gets created from it.
*/
export const useEnvelopeCanvasPendingCreation = ({ canvas, nodeName }: UseEnvelopeCanvasPendingCreationOptions) => {
const { pageLayer, scale, unscaledViewport } = canvas;
const [pendingCreation, setPendingCreation] = useState<Konva.Rect | null>(null);
/**
* Remove any pending creation rectangle from the canvas.
*/
const clearPending = () => {
setPendingCreation(null);
for (const node of pageLayer.current?.find(`.${nodeName}`) ?? []) {
node.destroy();
}
};
/**
* Draw the pending rectangle for a marquee box, given in scaled stage
* coordinates.
*/
const setPendingFromBox = (box: EnvelopeCanvasBox) => {
const layer = pageLayer.current;
if (!layer) {
return;
}
// The rectangle lives on the scaled layer, so the box is unscaled first.
const pendingRect = new Konva.Rect({
name: nodeName,
x: box.x / scale,
y: box.y / scale,
width: box.width / scale,
height: box.height / scale,
fill: KONVA_SELECTION_FILL_COLOR,
});
layer.add(pendingRect);
setPendingCreation(pendingRect);
};
/**
* The pending rectangle as a percentage box of the page, or null when
* nothing is pending.
*/
const getPendingBox = (): PercentageBox | null => {
if (!pendingCreation) {
return null;
}
return toPercentageBox(
{
x: pendingCreation.x(),
y: pendingCreation.y(),
width: pendingCreation.width(),
height: pendingCreation.height(),
},
unscaledViewport,
);
};
return { pendingCreation, clearPending, setPendingFromBox, getPendingBox };
};
@@ -0,0 +1,258 @@
import { useLatestRef } from '@documenso/lib/client-only/hooks/use-latest-ref';
import type { TransformerSelectionConfig } from '@documenso/lib/universal/konva/transformer';
import {
boundTransformerBoxToPage,
DEFAULT_TRANSFORMER_SELECTION_CONFIG,
} from '@documenso/lib/universal/konva/transformer';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import Konva from 'konva';
import type { Transformer } from 'konva/lib/shapes/Transformer';
import { useCallback, useMemo, useRef, useState } from 'react';
import type { EnvelopeCanvasSelection, EnvelopeCanvasSelectionKind } from './envelope-canvas-types';
import { ENVELOPE_CANVAS_GROUP_NAMES } from './envelope-canvas-types';
/**
* A stable empty array so consumers of a kind which is not selected keep a
* stable dependency and don't re-render on every selection change.
*/
const EMPTY_GROUPS: Konva.Group[] = [];
/** How far past a resize handle you can still grab it, in screen pixels. */
const TRANSFORMER_ANCHOR_HIT_STROKE_PX = 24;
type UseEnvelopeCanvasSelectionOptions = {
/**
* Resolve the transformer configuration for a selection.
*/
getTransformerConfig: (kind: EnvelopeCanvasSelectionKind, groups: Konva.Group[]) => TransformerSelectionConfig;
/**
* Called synchronously whenever the selection changes, so the editor
* selection can be kept in sync without a frame of drift.
*/
onChange: (selection: EnvelopeCanvasSelection) => void;
};
/**
* The single canvas selection shared by fields and contents, along with the
* Konva transformer which acts upon it.
*
* The exposed functions are stable and read their options through refs, so
* they are safe to call from Konva handlers bound once at stage creation.
*/
export const useEnvelopeCanvasSelection = ({ getTransformerConfig, onChange }: UseEnvelopeCanvasSelectionOptions) => {
const transformerRef = useRef<Transformer | null>(null);
const [selection, setSelectionState] = useState<EnvelopeCanvasSelection>(null);
/**
* Whether a selected item is being dragged, resized or rotated.
*/
const [isTransforming, setIsTransforming] = useState(false);
const getTransformerConfigRef = useLatestRef(getTransformerConfig);
const onChangeRef = useLatestRef(onChange);
const applyTransformerConfig = useCallback((transformer: Transformer, currentSelection: EnvelopeCanvasSelection) => {
const config = currentSelection
? getTransformerConfigRef.current(currentSelection.kind, currentSelection.groups)
: DEFAULT_TRANSFORMER_SELECTION_CONFIG;
transformer.enabledAnchors(config.enabledAnchors);
transformer.rotateEnabled(config.rotateEnabled);
transformer.keepRatio(config.keepRatio);
transformer.borderEnabled(config.borderEnabled);
}, []);
const selectionRef = useLatestRef(selection);
/**
* Re-sync the transformer after the items change without the selection
* changing: re-resolve its configuration (e.g. an image attached to the
* selected content locks its ratio) and raise it back above the items.
*
* The handles straddle the selected item's edges, so any item stacked above
* the transformer hides part of them and takes their clicks. Rendering
* appends new items on top and the contents stacking raises every content,
* so each reconcile calls this afterwards.
*/
const refreshTransformer = useCallback(() => {
const transformer = transformerRef.current;
if (!transformer) {
return;
}
applyTransformerConfig(transformer, selectionRef.current);
transformer.moveToTop();
transformer.forceUpdate();
}, [applyTransformerConfig]);
const applySelection = useCallback((nextSelection: EnvelopeCanvasSelection) => {
const transformer = transformerRef.current;
if (transformer) {
// Configure the transformer before assigning nodes, since assigning
// nodes triggers an update using the current configuration.
applyTransformerConfig(transformer, nextSelection);
transformer.nodes(nextSelection?.groups ?? []);
}
if (nextSelection?.groups.length === 1) {
nextSelection.groups[0].moveToTop();
}
// Above the item just raised, so its handles stay visible and clickable.
transformer?.moveToTop();
setSelectionState(nextSelection);
onChangeRef.current(nextSelection);
}, []);
const select = useCallback(
(kind: EnvelopeCanvasSelectionKind, nodes: Konva.Node[]) => {
const groupName = ENVELOPE_CANVAS_GROUP_NAMES[kind];
const groups = nodes.filter(
(node): node is Konva.Group =>
node instanceof Konva.Group &&
node.hasName(groupName) &&
Boolean(node.getStage()) &&
Boolean(node.getParent()),
);
applySelection(groups.length > 0 ? { kind, groups } : null);
},
[applySelection],
);
/**
* Toggle a node in or out of the current selection (shift-click semantics).
*
* Nodes of a different kind than the toggled node are dropped, since only
* one kind can be selected at a time.
*/
const toggle = useCallback(
(kind: EnvelopeCanvasSelectionKind, node: Konva.Node) => {
const currentNodes = transformerRef.current?.nodes() ?? [];
const isAlreadySelected = currentNodes.includes(node);
select(kind, isAlreadySelected ? currentNodes.filter((current) => current !== node) : [...currentNodes, node]);
},
[select],
);
const clear = useCallback(() => {
applySelection(null);
}, [applySelection]);
const isSelected = useCallback((node: Konva.Node) => {
return (transformerRef.current?.nodes() ?? []).includes(node);
}, []);
/**
* Re-resolve the selection against the groups currently on the layer.
*
* The stage is destroyed and rebuilt whenever the page is rescaled (e.g.
* zooming), which leaves the selection holding detached nodes: the handles
* disappear, the action bar positions itself from an empty rect, and the
* next reconcile drops the selection altogether. Groups keep their render
* ID across the rebuild, so the equivalent new groups are selected instead.
*/
const reattachSelection = useCallback(
(layer: Konva.Layer) => {
const currentSelection = selectionRef.current;
if (!currentSelection) {
return;
}
const selectedIds = new Set(currentSelection.groups.map((group) => group.id()));
const groups = layer
.find(`.${ENVELOPE_CANVAS_GROUP_NAMES[currentSelection.kind]}`)
.filter((group) => selectedIds.has(group.id()));
select(currentSelection.kind, groups);
},
[select],
);
/**
* Create the transformer on a layer. Called once when the page canvas is
* created, and again whenever the stage is recreated.
*/
const attach = useCallback((layer: Konva.Layer) => {
// Match the brand green used for the first recipient's fields rather than
// Konva's default blue.
const selectionColor = getRecipientColorStyles('green').baseRing;
const transformer = new Konva.Transformer({
...DEFAULT_TRANSFORMER_SELECTION_CONFIG,
rotationSnaps: [0, 45, 90, 135, 180, 225, 270, 315],
rotationSnapTolerance: 5,
keepRatio: false,
borderStroke: selectionColor,
anchorStroke: selectionColor,
ignoreStroke: true,
flipEnabled: false,
anchorStyleFunc: (anchor) => {
// The stage is scaled to the page, so the hit area is divided by that
// scale to stay a constant size on screen.
const stageScale = layer.getStage()?.scaleX() ?? 1;
anchor.hitStrokeWidth(TRANSFORMER_ANCHOR_HIT_STROKE_PX / stageScale);
},
boundBoxFunc: (oldBox, newBox) => {
// Boxes are in absolute stage coordinates and the stage is the page,
// so pin the resize to the stage's own size.
const stage = layer.getStage();
const bounded = stage
? boundTransformerBoxToPage(newBox, { width: stage.width(), height: stage.height() })
: newBox;
// Enforce minimum size
if (bounded.width < 30 || bounded.height < 20) {
return oldBox;
}
return bounded;
},
});
layer.add(transformer);
// Konva fires transform events (resize and rotate) directly on the
// transformer and its nodes without bubbling, so they cannot be observed
// on the stage.
transformer.on('transformstart', () => setIsTransforming(true));
transformer.on('transformend', () => setIsTransforming(false));
transformerRef.current = transformer;
return transformer;
}, []);
const fieldGroups = useMemo(() => (selection?.kind === 'field' ? selection.groups : EMPTY_GROUPS), [selection]);
const contentGroups = useMemo(() => (selection?.kind === 'content' ? selection.groups : EMPTY_GROUPS), [selection]);
return {
selection,
fieldGroups,
contentGroups,
isTransforming,
setIsTransforming,
attach,
reattachSelection,
select,
toggle,
clear,
isSelected,
refreshTransformer,
};
};
export type EnvelopeCanvasSelectionApi = ReturnType<typeof useEnvelopeCanvasSelection>;
@@ -0,0 +1,323 @@
import { decodeLocalContentImage } from '@documenso/lib/client-only/load-content-image';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import {
APP_CONTENT_IMAGE_MIME_TYPES,
APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT,
} from '@documenso/lib/constants/envelope-content';
import { AppError } from '@documenso/lib/errors/app-error';
import { EnvelopeContentType } from '@documenso/lib/types/envelope-content-meta';
import { resolveAttachedImageBox } from '@documenso/lib/universal/content-renderer/content-image-box';
import { nanoid } from '@documenso/lib/universal/id';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { PRESIGNED_DATA_CONTENT_ID_PREFIX } from '@documenso/lib/utils/embed-config';
import type { Size } from '@documenso/lib/utils/geometry';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter } from '@documenso/ui/primitives/dialog';
import { Trans, useLingui } from '@lingui/react/macro';
import { Loader2Icon } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { createCallable } from 'react-call';
import { match } from 'ts-pattern';
/**
* The DOM id of the dialog's file input, so the e2e suite can find it.
*/
export const getContentImageInputId = (formId: string) => `content-image-input-${formId}`;
type ContentImageUploadDialogProps = {
formId: string;
/**
* A file to upload straight away, e.g. one dropped onto the settings
* panel. Without it the file picker is opened first.
*/
file?: File;
};
type UploadStatus = 'picking' | 'uploading' | 'invalid-image' | 'too-large' | 'failed';
/**
* Why the server would reject the file, if it would, so the user finds out
* before it is uploaded. Applies to dropped and picked files alike.
*/
const getRejectionReason = (file: File): 'too-large' | 'invalid-image' | null => {
if (file.size > megabytesToBytes(APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT)) {
return 'too-large';
}
if (!APP_CONTENT_IMAGE_MIME_TYPES.some((accepted) => accepted === file.type)) {
return 'invalid-image';
}
return null;
};
/**
* Attach an image to an image content.
*
* Blocks the editor from the moment it is opened until the upload settles,
* so there is only ever one upload in flight. Pending autosaves are flushed
* before uploading, so the content always has a server id by then.
*
* Invoke with `ContentImageUploadDialog.call({ formId })`; `Root` must be
* mounted inside the envelope editor and render providers.
*/
export const ContentImageUploadDialog = createCallable<ContentImageUploadDialogProps, void>(
({ call, formId, file: droppedFile }) => {
const { t } = useLingui();
const { editorContents, flushAutosave, envelope, isEmbedded } = useCurrentEnvelopeEditor();
const { contentImages, pageSizes } = useCurrentEnvelopeRender();
const { mutateAsync: uploadImage } = trpc.envelope.content.uploadImage.useMutation();
const inputRef = useRef<HTMLInputElement>(null);
const [status, setStatus] = useState<UploadStatus>(() => {
if (!droppedFile) {
return 'picking';
}
return getRejectionReason(droppedFile) ?? 'uploading';
});
/**
* Point the content at its new image, fitting its box to the image.
*
* `data` is the file of an image which has not been uploaded yet.
*/
const attachImage = (dataContentId: string, image: Size, data?: File) => {
// Re-read in case the content moved while the image was on its way.
const latest = editorContents.getContentByFormId(formId);
if (!latest || latest.contentMeta.type !== EnvelopeContentType.IMAGE) {
return;
}
const { contentMeta } = latest;
const page = pageSizes.get(latest.envelopeItemId, contentMeta.page);
const box = page
? resolveAttachedImageBox({
image,
page,
box: {
positionX: contentMeta.positionX,
positionY: contentMeta.positionY,
width: contentMeta.width,
height: contentMeta.height,
},
})
: null;
editorContents.updateContentByFormId(formId, {
dataContentId,
data,
contentMeta: box ? { ...contentMeta, ...box } : contentMeta,
});
};
const uploadToServer = async (file: File) => {
// The content must exist on the server before an image can be
// attached to it.
await flushAutosave();
const content = editorContents.getContentByFormId(formId);
if (!content?.id || content.contentMeta.type !== EnvelopeContentType.IMAGE) {
throw new AppError('CONTENT_NOT_PERSISTED');
}
const formData = new FormData();
formData.append('payload', JSON.stringify({ envelopeId: envelope.id, envelopeContentId: content.id }));
formData.append('file', file);
const { dataContent } = await uploadImage(formData);
// Decode the local file at the size the server normalized it to, so
// the preview matches what the route would serve.
const image = await createImageBitmap(file, {
resizeWidth: dataContent.metadata.width,
resizeHeight: dataContent.metadata.height,
resizeQuality: 'high',
}).catch(() => null);
if (image) {
contentImages.setImage(dataContent.id, image, {
fileSize: dataContent.metadata.fileSize,
});
}
attachImage(dataContent.id, dataContent.metadata);
};
const upload = async (file: File) => {
setStatus('uploading');
try {
if (isEmbedded) {
// Decoded the way the server will store it, which also rejects what
// the server would, since it only sees the image once the envelope is
// saved.
const { image, details } = await decodeLocalContentImage(file);
const dataContentId = `${PRESIGNED_DATA_CONTENT_ID_PREFIX}${nanoid()}`;
contentImages.setImage(dataContentId, image, details);
attachImage(dataContentId, image, file);
} else {
await uploadToServer(file);
}
call.end();
} catch (err) {
const error = AppError.parseError(err);
setStatus(
match(error.code)
.with('INVALID_IMAGE_FILE', (): UploadStatus => 'invalid-image')
.with('FILE_TOO_LARGE', (): UploadStatus => 'too-large')
.otherwise((): UploadStatus => 'failed'),
);
}
};
// Dismissing the picker without choosing closes the dialog too. Browsers
// fire `cancel` on the input for this, but React does not expose it as a
// prop, so it is attached natively.
useEffect(() => {
const input = inputRef.current;
if (!input) {
return;
}
const onCancel = () => call.end();
input.addEventListener('cancel', onCancel);
return () => input.removeEventListener('cancel', onCancel);
}, [call]);
// Start immediately: upload the dropped file, or open the picker so the
// user does not have to click twice. A dropped file the server would
// reject opens on the reason instead.
const hasStartedRef = useRef(false);
useEffect(() => {
if (hasStartedRef.current) {
return;
}
hasStartedRef.current = true;
if (!droppedFile) {
inputRef.current?.click();
return;
}
if (!getRejectionReason(droppedFile)) {
void upload(droppedFile);
}
}, []);
const onFilePicked = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
// Allow the same file to be picked again after a failure.
event.target.value = '';
if (!file) {
return;
}
const rejectionReason = getRejectionReason(file);
if (rejectionReason) {
setStatus(rejectionReason);
return;
}
void upload(file);
};
// Clicked directly rather than via state, since the status may already
// be `picking` and a state change alone would not reopen the picker.
const reopenPicker = () => {
setStatus('picking');
inputRef.current?.click();
};
const isBusy = status === 'uploading';
return (
<>
<input
ref={inputRef}
id={getContentImageInputId(formId)}
type="file"
accept={APP_CONTENT_IMAGE_MIME_TYPES.join(',')}
className="hidden"
onChange={onFilePicked}
/>
<Dialog open={true} onOpenChange={(open) => !open && !isBusy && call.end()}>
<DialogContent
className={cn('max-w-xs', {
hidden: status === 'picking',
})}
hideClose
onPointerDownOutside={(event) => isBusy && event.preventDefault()}
onEscapeKeyDown={(event) => isBusy && event.preventDefault()}
>
{match(status)
.with('picking', () => null) // Only show the overlay since the actual browser uploader should be shown.
.with('uploading', () => (
<div
className="flex flex-col items-center justify-center gap-2 py-4"
data-testid="content-image-uploading"
>
<Loader2Icon className="h-5 w-5 animate-spin text-muted-foreground" />
<DialogDescription>
<Trans>Uploading Image</Trans>
</DialogDescription>
</div>
))
.with('invalid-image', () => (
<DialogDescription className="text-destructive">
<Trans>This image could not be read. Use a PNG, JPEG or WebP file.</Trans>
</DialogDescription>
))
.with('too-large', () => (
<DialogDescription className="text-destructive">
<Trans>This image is larger than {APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT} MB.</Trans>
</DialogDescription>
))
.with('failed', () => (
<DialogDescription className="text-destructive">
<Trans>The image could not be uploaded. Please try again.</Trans>
</DialogDescription>
))
.exhaustive()}
{!isBusy && (
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => call.end()}>
<Trans>Close</Trans>
</Button>
<Button type="button" onClick={reopenPicker}>
{status === 'picking' ? t`Choose image` : t`Try again`}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
</>
);
},
);
@@ -0,0 +1,246 @@
import type { TLocalContent } from '@documenso/lib/client-only/hooks/use-editor-contents';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import {
CONTENT_HIGHLIGHT_META_DEFAULT_VALUES,
CONTENT_LINE_META_DEFAULT_VALUES,
CONTENT_SHAPE_META_DEFAULT_VALUES_BY_SHAPE,
CONTENT_TEXT_META_DEFAULT_VALUES,
EnvelopeContentType,
type TEnvelopeContentMeta,
ZContentHighlightMetaSchema,
ZContentLineMetaSchema,
ZContentShapeMetaSchema,
ZContentTextMetaSchema,
} from '@documenso/lib/types/envelope-content-meta';
import { Form } from '@documenso/ui/primitives/form/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { createContext, useContext, useLayoutEffect, useState } from 'react';
import { type FieldValues, useForm, useFormContext } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
type ContentSettingsFormProviderProps = {
/**
* The content whose settings the form edits, or null when none is selected.
*/
content: TLocalContent | null;
children: React.ReactNode;
};
/**
* Hosts the single settings form for the selected content.
*
* Every surface which edits a content's presentational meta (the sidebar
* settings panel, the canvas action bar) binds to this one form via
* `useContentSettingsForm`, so there is only ever one copy of the values and
* nothing to keep in sync. The form is the sole writer of those keys onto the
* store; geometry is owned by the canvas and is never part of the form.
*
* The form's values are written to the store whenever they change while
* valid, merged onto the content's latest meta.
*
* The provider wraps the whole editor surface (including the PDF viewer), so
* it must not remount when the selection changes. Instead the one form is
* reset to the newly selected content's values.
*/
export const ContentSettingsFormProvider = ({ content, children }: ContentSettingsFormProviderProps) => {
const { editorContents } = useCurrentEnvelopeEditor();
const config = getContentSettingsFormConfig(content?.contentMeta ?? null);
// The resolver is read on every render, so the schema follows the selected
// content's type. The default values are only used for the initial mount;
// later selections are applied via `reset` below.
const form = useForm<FieldValues>({
resolver: zodResolver(config.schema),
mode: 'onChange',
defaultValues: config.defaultValues,
});
const formId = content?.formId ?? null;
/**
* The content the form currently holds the values of.
*
* The reset below runs after the render which changed the selection, so
* for that one render the form still holds the previous content's values.
* Inputs must not mount during it: a Radix select which mounts with one
* value and is immediately given another pushes an empty string back into
* the form (its hidden native select has no options yet), which surfaces
* as a spurious validation error.
*/
const [readyFormId, setReadyFormId] = useState<string | null>(null);
// Reset then subscribe, in that order, so the reset is not observed as an
// edit and selecting a content never writes anything by itself. A layout
// effect so the fields beneath never paint the previous content's values.
useLayoutEffect(() => {
form.reset(config.defaultValues);
setReadyFormId(formId);
if (!formId) {
return;
}
const subscription = form.watch((values) => {
const parsed = config.schema.safeParse(values);
if (!parsed.success) {
return;
}
editorContents.patchContentMeta(formId, parsed.data);
});
return () => subscription.unsubscribe();
// Only the selected content matters; its config is derived from it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form, formId, editorContents.patchContentMeta]);
const isReady = formId !== null && formId === readyFormId;
return (
<ContentSettingsFormContext.Provider value={{ content, isReady }}>
<Form {...form}>{children}</Form>
</ContentSettingsFormContext.Provider>
);
};
/**
* The settings form of the selected content, for the surfaces which edit it.
*
* Typed by the caller since the form's shape depends on the content type,
* which the caller has already narrowed on.
*/
export const useContentSettingsForm = <TForm extends FieldValues>() => {
const context = useContext(ContentSettingsFormContext);
if (!context) {
throw new Error('useContentSettingsForm must be used within a ContentSettingsFormProvider');
}
const form = useFormContext<TForm>();
return {
form,
/**
* The content the form is bound to, so a consumer holding a different
* content (e.g. a stale canvas selection) can tell it is not this one.
*/
content: context.content,
/**
* Whether the form holds the selected content's values yet. Inputs must
* only be mounted once it does, see the provider.
*/
isReady: context.isReady,
};
};
type ContentSettingsFormContextValue = {
content: TLocalContent | null;
isReady: boolean;
};
const ContentSettingsFormContext = createContext<ContentSettingsFormContextValue | null>(null);
type ContentSettingsFormConfig = {
schema: z.ZodType<Partial<TEnvelopeContentMeta>>;
defaultValues: FieldValues;
};
/**
* Only the presentational settings are editable in the form. Geometry (page,
* position, size and endpoints) is managed on the canvas.
*/
export const ZContentTextFormSchema = ZContentTextMetaSchema.pick({
text: true,
fontSize: true,
textAlign: true,
verticalAlign: true,
lineHeight: true,
letterSpacing: true,
color: true,
});
export type TContentTextFormSchema = z.infer<typeof ZContentTextFormSchema>;
export const ZContentLineFormSchema = ZContentLineMetaSchema.pick({
strokeWidth: true,
strokeColor: true,
strokeStyle: true,
});
export type TContentLineFormSchema = z.infer<typeof ZContentLineFormSchema>;
export const ZContentShapeFormSchema = ZContentShapeMetaSchema.pick({
strokeWidth: true,
strokeColor: true,
strokeStyle: true,
fillColor: true,
fillOpacity: true,
});
export type TContentShapeFormSchema = z.infer<typeof ZContentShapeFormSchema>;
export const ZContentHighlightFormSchema = ZContentHighlightMetaSchema.pick({
color: true,
fillOpacity: true,
});
export type TContentHighlightFormSchema = z.infer<typeof ZContentHighlightFormSchema>;
/**
* Contents without settings (images, or no selection) get an empty form so
* the form context always exists for whatever is mounted beneath it.
*/
const ZEmptyFormSchema = z.object({});
/**
* The schema and initial values of the settings form for a content, by type.
*/
const getContentSettingsFormConfig = (meta: TEnvelopeContentMeta | null): ContentSettingsFormConfig => {
if (!meta) {
return { schema: ZEmptyFormSchema, defaultValues: {} };
}
return match(meta)
.with({ type: EnvelopeContentType.TEXT }, (value) =>
createFormConfig(ZContentTextFormSchema, CONTENT_TEXT_META_DEFAULT_VALUES, value),
)
.with({ type: EnvelopeContentType.LINE }, (value) =>
createFormConfig(ZContentLineFormSchema, CONTENT_LINE_META_DEFAULT_VALUES, value),
)
.with({ type: EnvelopeContentType.SHAPE }, (value) =>
createFormConfig(ZContentShapeFormSchema, CONTENT_SHAPE_META_DEFAULT_VALUES_BY_SHAPE[value.shape], value),
)
.with({ type: EnvelopeContentType.HIGHLIGHT }, (value) =>
createFormConfig(ZContentHighlightFormSchema, CONTENT_HIGHLIGHT_META_DEFAULT_VALUES, value),
)
.with({ type: EnvelopeContentType.IMAGE }, () => ({ schema: ZEmptyFormSchema, defaultValues: {} }))
.exhaustive();
};
/**
* The form's initial values are the content's meta laid over the type's
* default meta, narrowed to the form's keys by the schema (which strips the
* rest, e.g. geometry).
*/
const createFormConfig = <TSchema extends z.ZodType<Partial<TEnvelopeContentMeta>>>(
schema: TSchema,
defaults: TEnvelopeContentMeta,
meta: TEnvelopeContentMeta,
): ContentSettingsFormConfig => {
const merged = { ...defaults, ...meta };
const parsed = schema.safeParse(merged);
return {
schema,
// The stored meta was validated on load, so this only falls back if the
// defaults themselves are ever inconsistent with the form schema.
defaultValues: parsed.success ? parsed.data : schema.parse(defaults),
};
};
@@ -0,0 +1,411 @@
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { useLatestRef } from '@documenso/lib/client-only/hooks/use-latest-ref';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import {
CONTENT_HIGHLIGHT_META_DEFAULT_VALUES,
CONTENT_IMAGE_META_DEFAULT_VALUES,
CONTENT_LINE_META_DEFAULT_VALUES,
CONTENT_SHAPE_META_DEFAULT_VALUES_BY_SHAPE,
CONTENT_TEXT_META_DEFAULT_VALUES,
CONTENT_TYPE_DATA_CONTENT_TYPE,
EnvelopeContentShapeType,
EnvelopeContentType,
type TEnvelopeContentMeta,
} from '@documenso/lib/types/envelope-content-meta';
import { CONTENT_IMAGE_DEFAULT_SIZE } from '@documenso/lib/universal/content-renderer/content-image-box';
import { canContentBeChanged } from '@documenso/lib/utils/envelope';
import { resolveEnvelopeContentLimits } from '@documenso/lib/utils/envelope-content';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import type { LucideIcon } from 'lucide-react';
import { HighlighterIcon, ImageIcon, ScanLineIcon, SquareIcon, TextIcon } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { match } from 'ts-pattern';
const MIN_HEIGHT_PX = 12;
const MIN_WIDTH_PX = 36;
const DEFAULT_HEIGHT_PX = MIN_HEIGHT_PX * 2.5;
const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
export type ContentDragDropItem = {
key: string;
type: EnvelopeContentType;
shape?: EnvelopeContentShapeType;
icon: LucideIcon;
name: MessageDescriptor;
};
export const contentButtonList: ContentDragDropItem[] = [
{
key: EnvelopeContentType.TEXT,
type: EnvelopeContentType.TEXT,
icon: TextIcon,
name: msg`Text`,
},
{
key: EnvelopeContentType.LINE,
type: EnvelopeContentType.LINE,
icon: ScanLineIcon,
name: msg`Line`,
},
{
key: `${EnvelopeContentType.SHAPE}:${EnvelopeContentShapeType.RECTANGLE}`,
type: EnvelopeContentType.SHAPE,
shape: EnvelopeContentShapeType.RECTANGLE,
icon: SquareIcon,
name: msg`Rectangle`,
},
{
key: EnvelopeContentType.HIGHLIGHT,
type: EnvelopeContentType.HIGHLIGHT,
icon: HighlighterIcon,
name: msg`Highlight`,
},
{
key: EnvelopeContentType.IMAGE,
type: EnvelopeContentType.IMAGE,
icon: ImageIcon,
name: msg`Image`,
},
];
type EnvelopeEditorContentDragDropProps = {
selectedEnvelopeItemId: string | null;
};
export const EnvelopeEditorContentDragDrop = ({ selectedEnvelopeItemId }: EnvelopeEditorContentDragDropProps) => {
const { envelope, editorContents, setIsPlacingItem } = useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation();
const { t } = useLingui();
const contentLimits = resolveEnvelopeContentLimits(
editorContents.localContents.map((content) => content.contentMeta.type),
organisation.organisationClaim,
);
const [selectedContent, setSelectedContent] = useState<ContentDragDropItem | null>(null);
// Let the canvas know a content is being placed, so it can get its selection
// out of the way of the placement click.
useEffect(() => {
setIsPlacingItem(selectedContent !== null);
return () => {
setIsPlacingItem(false);
};
}, [selectedContent, setIsPlacingItem]);
const { isWithinPageBounds, getPage } = useDocumentElement();
const [isContentWithinBounds, setIsContentWithinBounds] = useState(false);
const [coords, setCoords] = useState({
x: 0,
y: 0,
});
const contentBounds = useRef({
height: 0,
width: 0,
});
const onMouseMove = useCallback(
(event: MouseEvent) => {
setIsContentWithinBounds(
isWithinPageBounds(event, PDF_VIEWER_PAGE_SELECTOR, contentBounds.current.width, contentBounds.current.height),
);
setCoords({
x: event.clientX - contentBounds.current.width / 2,
y: event.clientY - contentBounds.current.height / 2,
});
},
[isWithinPageBounds],
);
const onMouseClick = useCallback(
(event: MouseEvent) => {
if (!selectedContent || !selectedEnvelopeItemId) {
return;
}
const $page = getPage(event, PDF_VIEWER_PAGE_SELECTOR);
if (
!$page ||
!isWithinPageBounds(event, PDF_VIEWER_PAGE_SELECTOR, contentBounds.current.width, contentBounds.current.height)
) {
setSelectedContent(null);
return;
}
const { top, left, height, width } = getBoundingClientRect($page);
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
// Calculate x and y as a percentage of the page width and height
let pageX = ((event.pageX - left) / width) * 100;
let pageY = ((event.pageY - top) / height) * 100;
// Get the bounds as a percentage of the page width and height
const contentPageWidth = (contentBounds.current.width / width) * 100;
const contentPageHeight = (contentBounds.current.height / height) * 100;
// And center it based on the bounds
pageX -= contentPageWidth / 2;
pageY -= contentPageHeight / 2;
editorContents.addContent({
envelopeItemId: selectedEnvelopeItemId,
contentMeta: buildContentMeta({
item: selectedContent,
page: pageNumber,
positionX: pageX,
positionY: pageY,
width: contentPageWidth,
height: contentPageHeight,
}),
});
setIsContentWithinBounds(false);
setSelectedContent(null);
},
[isWithinPageBounds, selectedContent, selectedEnvelopeItemId, getPage, editorContents],
);
const selectedContentRef = useLatestRef(selectedContent);
useEffect(() => {
const observer = new MutationObserver((_mutations) => {
const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
if (!$page) {
return;
}
contentBounds.current = resolveDragBounds(selectedContentRef.current, $page);
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
};
}, []);
useEffect(() => {
if (selectedContent) {
const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
if ($page) {
contentBounds.current = resolveDragBounds(selectedContent, $page);
}
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseClick);
}
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseClick);
};
}, [onMouseClick, onMouseMove, selectedContent]);
if (!canContentBeChanged(envelope)) {
return (
<Alert variant="neutral" className="rounded-lg border border-border">
<AlertDescription className="text-sm">
{match(envelope.status)
.with(DocumentStatus.COMPLETED, () => (
<Trans>This document has been completed, so its content can no longer be changed.</Trans>
))
.with(DocumentStatus.REJECTED, () => (
<Trans>This document has been rejected, so its content can no longer be changed.</Trans>
))
.otherwise(() => (
<Trans>Content cannot be changed because the document has already been sent.</Trans>
))}
</AlertDescription>
</Alert>
);
}
// The organisation's plan caps how much content an envelope may hold. Only
// adding is blocked: existing contents stay editable and removable so an
// envelope which went over its limit can be brought back down.
if (contentLimits.isContentLimitReached || contentLimits.isImageLimitReached) {
return (
<Alert variant="neutral" className="rounded-lg border border-border" data-testid="content-limit-reached-alert">
<AlertDescription className="text-sm">
{contentLimits.isContentLimitReached ? (
<Plural
value={contentLimits.contentLimit}
one="This envelope cannot have more than # content. Remove some, or contact support if you need more."
other="This envelope cannot have more than # contents. Remove some, or contact support if you need more."
/>
) : (
<Plural
value={contentLimits.imageLimit}
one="This envelope cannot have more than # image content. Remove some, or contact support if you need more."
other="This envelope cannot have more than # image contents. Remove some, or contact support if you need more."
/>
)}
</AlertDescription>
</Alert>
);
}
return (
<>
<div className="grid grid-cols-2 gap-x-2 gap-y-2.5">
{contentButtonList.map((content) => (
<button
key={content.key}
type="button"
onClick={() => setSelectedContent(content)}
onMouseDown={() => setSelectedContent(content)}
data-selected={selectedContent?.key === content.key ? true : undefined}
className="group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-border px-4 transition-colors"
>
<p className="flex items-center justify-center gap-x-1.5 font-normal font-noto text-muted-foreground text-sm group-data-[selected]:text-foreground">
{<content.icon className="h-4 w-4" />}
{t(content.name)}
</p>
</button>
))}
</div>
{selectedContent && (
<div
className={cn(
'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] dark:text-muted',
// Match the brand green used for the first recipient's fields and
// the rest of the content editor's selection affordances.
getRecipientColorStyles('green').base,
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isContentWithinBounds,
'dark:text-black/60': isContentWithinBounds,
},
)}
style={{
top: coords.y,
left: coords.x,
height: contentBounds.current.height,
width: contentBounds.current.width,
}}
>
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{t(selectedContent.name)}</span>
</div>
)}
</>
);
};
type BuildContentMetaOptions = {
item: ContentDragDropItem;
page: number;
positionX: number;
positionY: number;
width: number;
height: number;
};
/**
* Build the content meta for a newly dropped content, merging the drop
* geometry into the default values for the given content type.
*
* Geometry is handled per content type since it differs, e.g. lines use
* start/end coordinates instead of a position and size.
*/
/**
* The screen size of the drag preview for a content type.
*
* Image contents preview at their page relative default size so the preview
* matches the box which gets created, the rest use a fixed screen size.
*/
const resolveDragBounds = (item: ContentDragDropItem | null, $page: Element) => {
if (item !== null && CONTENT_TYPE_DATA_CONTENT_TYPE[item.type] !== undefined) {
const { width, height } = getBoundingClientRect($page);
return {
width: (width * CONTENT_IMAGE_DEFAULT_SIZE.width) / 100,
height: (height * CONTENT_IMAGE_DEFAULT_SIZE.height) / 100,
};
}
return {
width: DEFAULT_WIDTH_PX,
height: DEFAULT_HEIGHT_PX,
};
};
const buildContentMeta = ({
item,
page,
positionX,
positionY,
width,
height,
}: BuildContentMetaOptions): TEnvelopeContentMeta => {
const { type } = item;
return match(type)
.with(EnvelopeContentType.LINE, () => ({
...structuredClone(CONTENT_LINE_META_DEFAULT_VALUES),
page,
// Lines are placed horizontally across the drop area, centered vertically.
x1: positionX,
x2: positionX + width,
y1: positionY + height / 2,
y2: positionY + height / 2,
}))
.with(EnvelopeContentType.IMAGE, () => ({
...structuredClone(CONTENT_IMAGE_META_DEFAULT_VALUES),
page,
// Image contents use a page relative default size, so whether the
// author has resized the box can be told when an image is attached.
// Centered on the drop point like the other contents.
positionX: positionX + width / 2 - CONTENT_IMAGE_DEFAULT_SIZE.width / 2,
positionY: positionY + height / 2 - CONTENT_IMAGE_DEFAULT_SIZE.height / 2,
...CONTENT_IMAGE_DEFAULT_SIZE,
}))
.with(EnvelopeContentType.SHAPE, () => ({
...structuredClone(CONTENT_SHAPE_META_DEFAULT_VALUES_BY_SHAPE[item.shape ?? EnvelopeContentShapeType.RECTANGLE]),
page,
positionX,
positionY,
width,
height,
}))
.with(EnvelopeContentType.TEXT, () => ({
...structuredClone(CONTENT_TEXT_META_DEFAULT_VALUES),
text: '',
page,
positionX,
positionY,
width,
height,
}))
.with(EnvelopeContentType.HIGHLIGHT, () => ({
...structuredClone(CONTENT_HIGHLIGHT_META_DEFAULT_VALUES),
page,
positionX,
positionY,
width,
height,
}))
.exhaustive();
};
@@ -94,12 +94,22 @@ export const EnvelopeEditorFieldDragDrop = ({
selectedRecipientId,
selectedEnvelopeItemId,
}: EnvelopeEditorFieldDragDropProps) => {
const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { envelope, editorFields, isTemplate, getRecipientColorKey, setIsPlacingItem } = useCurrentEnvelopeEditor();
const { t } = useLingui();
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
// Let the canvas know a field is being placed, so it can get its selection
// out of the way of the placement click.
useEffect(() => {
setIsPlacingItem(selectedField !== null);
return () => {
setIsPlacingItem(false);
};
}, [selectedField, setIsPlacingItem]);
const { isWithinPageBounds, getPage } = useDocumentElement();
const isFieldsDisabled = useMemo(() => {
@@ -1,9 +1,10 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import type { EnvelopeEditorTab } 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 { 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 { EnvelopeContentType } from '@documenso/lib/types/envelope-content-meta';
import {
FIELD_META_DEFAULT_VALUES,
type TCheckboxFieldMeta,
@@ -26,22 +27,33 @@ import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Tabs, TabsContent } from '@documenso/ui/primitives/tabs';
import type { MessageDescriptor } from '@lingui/core';
import { msg, plural } from '@lingui/core/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { TabsList, TabsTrigger } from '@radix-ui/react-tabs';
import {
AlertTriangleIcon,
FileTextIcon,
LayoutGridIcon,
MousePointerIcon,
PencilIcon,
SparklesIcon,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
import { AiFieldDetectionDialog } from '~/components/dialogs/ai-field-detection-dialog';
import { EnvelopeItemEditDialog } from '~/components/dialogs/envelope-item-edit-dialog';
import { EditorContentHighlightForm } from '~/components/forms/editor/editor-content-highlight-form';
import { EditorContentImageSettings } from '~/components/forms/editor/editor-content-image-settings';
import { EditorContentLineForm } from '~/components/forms/editor/editor-content-line-form';
import { EditorContentShapeForm } from '~/components/forms/editor/editor-content-shape-form';
import { EditorContentTextForm } from '~/components/forms/editor/editor-content-text-form';
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
import { EditorFieldDropdownForm } from '~/components/forms/editor/editor-field-dropdown-form';
@@ -54,7 +66,9 @@ import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
import { useCurrentTeam } from '~/providers/team';
import { ContentImageUploadDialog } from './content-image-upload-dialog';
import { ContentSettingsFormProvider } from './content-settings-form-provider';
import { EnvelopeEditorContentDragDrop } from './envelope-editor-content-drag-drop';
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
import { EnvelopeEditorFieldsPageRenderer } from './envelope-editor-fields-page-renderer';
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
@@ -75,6 +89,14 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
[FieldType.DROPDOWN]: msg`Dropdown Settings`,
};
const ContentSettingsTypeTranslations: Record<EnvelopeContentType, MessageDescriptor> = {
[EnvelopeContentType.TEXT]: msg`Text Settings`,
[EnvelopeContentType.LINE]: msg`Line Settings`,
[EnvelopeContentType.SHAPE]: msg`Shape Settings`,
[EnvelopeContentType.HIGHLIGHT]: msg`Highlight Settings`,
[EnvelopeContentType.IMAGE]: msg`Image Settings`,
};
export const EnvelopeEditorFieldsPage = () => {
const [searchParams] = useSearchParams();
@@ -82,12 +104,32 @@ export const EnvelopeEditorFieldsPage = () => {
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
const {
envelope,
editorFields,
editorContents,
navigateToStep,
editorConfig,
selectedEditorTab,
setSelectedEditorTab,
} = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const { currentEnvelopeItem, setCurrentEnvelopeItem, viewerControls } = useCurrentEnvelopeRender();
const { _ } = useLingui();
const { toast } = useToast();
/**
* Switching tabs resets which items are shown to the default for that tab:
* both on the fields tab, and only contents on the contents tab so the page
* reads as the document itself. This drives the same toggles as the viewer
* toolbar, so the user can still override them for the current tab, but any
* override is discarded on the next switch.
*/
const onEditorTabChange = (tab: EnvelopeEditorTab) => {
setSelectedEditorTab(tab);
viewerControls.setFieldsVisibility(tab === 'contents' ? 'hidden' : 'visible');
viewerControls.setContentsVisibility('visible');
};
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
@@ -100,39 +142,10 @@ export const EnvelopeEditorFieldsPage = () => {
const selectedField = useMemo(() => structuredClone(editorFields.selectedField), [editorFields.selectedField]);
const [copiedFields, setCopiedFields] = useState<TLocalField[]>([]);
useHotkeys(['ctrl+c', 'meta+c'], (event) => {
// Keep the native copy when the user has selected text.
if (editorFields.selectedFields.length === 0 || window.getSelection()?.toString()) {
return;
}
event.preventDefault();
setCopiedFields(structuredClone(editorFields.selectedFields));
toast({
title: _(
msg({
message: plural(editorFields.selectedFields.length, {
one: 'Copied field',
other: 'Copied # fields',
}),
}),
),
});
});
useHotkeys(['ctrl+v', 'meta+v'], (event) => {
if (copiedFields.length === 0) {
return;
}
event.preventDefault();
// Paste the next copies offset from the last ones, so repeated pastes do not stack.
setCopiedFields(copiedFields.map((field) => editorFields.duplicateField(field)));
});
const selectedContent = useMemo(
() => structuredClone(editorContents.selectedContent),
[editorContents.selectedContent],
);
/**
* Debounce the fields used for overlap detection so we don't recompute on every
@@ -224,6 +237,21 @@ export const EnvelopeEditorFieldsPage = () => {
editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null);
}, []);
/**
* Deselect a field or content which is not on the current envelope item
* when the item changes, otherwise its settings stay open in the sidebar for
* something which is no longer on screen.
*/
useEffect(() => {
if (editorFields.selectedField && editorFields.selectedField.envelopeItemId !== currentEnvelopeItem?.id) {
editorFields.setSelectedField(null);
}
if (editorContents.selectedContent && editorContents.selectedContent.envelopeItemId !== currentEnvelopeItem?.id) {
editorContents.setSelectedContent(null);
}
}, [currentEnvelopeItem?.id]);
const onDetectClick = () => {
if (!team.preferences.aiFeaturesEnabled) {
setIsAiEnableDialogOpen(true);
@@ -240,319 +268,405 @@ export const EnvelopeEditorFieldsPage = () => {
});
};
if (!editorConfig.general?.allowAddFieldsStep && !editorConfig.general?.allowAddContentsStep) {
return null;
}
return (
<div className="relative flex h-full">
<div className="flex h-full w-full flex-col overflow-y-auto px-2" ref={scrollableContainerRef}>
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector
className="px-0"
fields={editorFields.localFields}
renderItemAction={
editorConfig.envelopeItems !== null &&
editorConfig.envelopeItems.allowReplace &&
envelopeItemPermissions.canFileBeChanged
? (item) => (
<div className="relative flex h-5 w-5 flex-shrink-0 items-center justify-center">
<div
className={cn('h-2 w-2 rounded-full transition-opacity duration-150 group-hover:opacity-0', {
'bg-green-500': currentEnvelopeItem?.id === item.id,
})}
/>
<EnvelopeItemEditDialog
envelopeItem={item}
allowConfigureTitle={editorConfig.envelopeItems?.allowConfigureTitle ?? false}
trigger={
<span
className="absolute inset-0 flex cursor-pointer items-center justify-center opacity-0 transition-opacity duration-150 group-hover:opacity-100"
onClick={(e) => e.stopPropagation()}
data-testid={`envelope-item-edit-button-${item.id}`}
>
<PencilIcon className="h-3.5 w-3.5" />
</span>
}
/>
</div>
)
: undefined
}
/>
<EnvelopeEditorInvalidDirectTemplateAlert />
{/* Document View */}
<div className="mt-4 flex h-full flex-col items-center justify-center">
{envelope.recipients.length === 0 && (
<Alert
variant="neutral"
className="mb-4 flex max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm border border-border bg-background"
>
<div className="flex flex-col gap-1">
<AlertTitle>
<Trans>Missing Recipients</Trans>
</AlertTitle>
<AlertDescription>
<Trans>You need at least one recipient to add fields</Trans>
</AlertDescription>
</div>
<Button variant="outline" onClick={() => void navigateToStep('upload')}>
<Trans>Add Recipients</Trans>
</Button>
</Alert>
)}
{overlappingFieldPairs.length > 0 && (
<Alert
variant="warning"
className="mt-20 mb-4 flex w-full max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm"
>
<div className="flex flex-row items-start gap-3">
<AlertTriangleIcon className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="flex flex-col gap-1">
<AlertTitle>
<Trans>Overlapping fields detected</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Some fields are placed on top of each other. This may complicate the signing process or cause
fields to not work as expected.
</Trans>
</AlertDescription>
</div>
</div>
</Alert>
)}
{currentEnvelopeItem !== null ? (
<EnvelopePdfViewer
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
<>
{/*
The settings form of the selected content is shared by the sidebar and
the canvas action bar, so it wraps both. Never keyed: remounting it
would remount the PDF viewer beneath it.
*/}
<ContentSettingsFormProvider content={selectedContent ?? null}>
<div className="relative flex h-full">
<div
className="flex h-full w-full flex-col overflow-x-auto overflow-y-auto px-2"
ref={scrollableContainerRef}
>
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector
className="px-0"
fields={editorFields.localFields}
renderItemAction={
editorConfig.envelopeItems !== null &&
editorConfig.envelopeItems.allowReplace &&
envelopeItemPermissions.canFileBeChanged
? (item) => (
<div className="relative flex h-5 w-5 flex-shrink-0 items-center justify-center">
<div
className={cn('h-2 w-2 rounded-full transition-opacity duration-150 group-hover:opacity-0', {
'bg-green-500': currentEnvelopeItem?.id === item.id,
})}
/>
<EnvelopeItemEditDialog
envelopeItem={item}
allowConfigureTitle={editorConfig.envelopeItems?.allowConfigureTitle ?? false}
trigger={
<span
className="absolute inset-0 flex cursor-pointer items-center justify-center opacity-0 transition-opacity duration-150 group-hover:opacity-100"
onClick={(e) => e.stopPropagation()}
data-testid={`envelope-item-edit-button-${item.id}`}
>
<PencilIcon className="h-3.5 w-3.5" />
</span>
}
/>
</div>
)
: undefined
}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="h-10 w-10 text-muted-foreground" />
<p className="mt-1 text-foreground text-sm">
<Trans>No documents found</Trans>
</p>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Please upload a document to continue</Trans>
</p>
<EnvelopeEditorInvalidDirectTemplateAlert />
{/* Document View */}
<div className="mt-4 flex h-full flex-col items-center justify-center">
{envelope.recipients.length === 0 && (
<Alert
variant="neutral"
className="mb-4 flex max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm border border-border bg-background"
>
<div className="flex flex-col gap-1">
<AlertTitle>
<Trans>Missing Recipients</Trans>
</AlertTitle>
<AlertDescription>
<Trans>You need at least one recipient to add fields</Trans>
</AlertDescription>
</div>
<Button variant="outline" onClick={() => void navigateToStep('upload')}>
<Trans>Add Recipients</Trans>
</Button>
</Alert>
)}
{overlappingFieldPairs.length > 0 && (
<Alert
variant="warning"
className="mt-20 mb-4 flex w-full max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm"
>
<div className="flex flex-row items-start gap-3">
<AlertTriangleIcon className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="flex flex-col gap-1">
<AlertTitle>
<Trans>Overlapping fields detected</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Some fields are placed on top of each other. This may complicate the signing process or cause
fields to not work as expected.
</Trans>
</AlertDescription>
</div>
</div>
</Alert>
)}
{currentEnvelopeItem !== null ? (
<EnvelopePdfViewer
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
toolbar={['zoom', 'fields', 'contents']}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="h-10 w-10 text-muted-foreground" />
<p className="mt-1 text-foreground text-sm">
<Trans>No documents found</Trans>
</p>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Please upload a document to continue</Trans>
</p>
</div>
)}
</div>
</div>
{/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && envelope.recipients.length > 0 && (
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-border border-l bg-background py-2">
<Tabs value={selectedEditorTab} onValueChange={(value) => onEditorTabChange(value as EnvelopeEditorTab)}>
{editorConfig.general?.allowAddFieldsStep && editorConfig.general?.allowAddContentsStep && (
<TabsList className="-mt-4 flex w-full flex-row border-b pt-2 text-muted-foreground text-sm">
<TabsTrigger
className="group flex min-h-12 w-1/2 items-center justify-center px-2 text-center hover:text-muted-foreground/80 data-[state=active]:shadow-[inset_0_-2px_0_0_hsl(var(--primary))]"
value="fields"
>
<MousePointerIcon className="mr-2 -ml-1 h-3.5 w-3.5 group-data-[state=active]:text-primary" />
<Trans>Fields</Trans>
</TabsTrigger>
<TabsTrigger
className="group flex min-h-12 w-1/2 items-center justify-center px-2 text-center hover:text-muted-foreground/80 data-[state=active]:shadow-[inset_0_-2px_0_0_hsl(var(--primary))]"
value="contents"
>
<LayoutGridIcon className="mr-2 -ml-1 h-3.5 w-3.5 group-data-[state=active]:text-primary" />
<Trans>Contents</Trans>
</TabsTrigger>
</TabsList>
)}
{/* Fields tab */}
<TabsContent value="fields">
{/* Recipient selector section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Selected Recipient</Trans>
</h3>
<EnvelopeRecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) => editorFields.setSelectedRecipient(recipient.id)}
recipients={envelope.recipients}
fields={envelope.fields}
className="w-full"
align="end"
/>
{editorFields.selectedRecipient &&
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
<Alert className="mt-4" variant="warning">
<AlertDescription>
<Trans>
This recipient can no longer be modified as they have signed a field, or completed the
document.
</Trans>
</AlertDescription>
</Alert>
)}
</section>
<Separator className="my-4" />
{/* Add fields section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Add Fields</Trans>
</h3>
<EnvelopeEditorFieldDragDrop
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
{editorConfig.fields?.allowAIDetection && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="mr-2 -ml-1 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</>
)}
</section>
{/* Field details section. */}
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
{selectedField && (
<section>
<Separator className="my-4" />
{searchParams.get('devmode') && (
<>
<div className="px-4">
<h3 className="mb-3 font-semibold text-foreground text-sm">
<Trans>Developer Mode</Trans>
</h3>
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-foreground text-sm">
{selectedField.id && (
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Field ID:</Trans>
</span>{' '}
{selectedField.id}
</p>
)}
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Recipient ID:</Trans>
</span>{' '}
{selectedField.recipientId}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos X:</Trans>
</span>{' '}
{selectedField.positionX.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos Y:</Trans>
</span>{' '}
{selectedField.positionY.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Width:</Trans>
</span>{' '}
{selectedField.width.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Height:</Trans>
</span>{' '}
{selectedField.height.toFixed(2)}
</p>
</div>
</div>
<Separator className="my-4" />
</>
)}
<div className="px-4 [&_label]:text-foreground/70 [&_label]:text-xs">
<h3 className="font-semibold text-sm">
{_(FieldSettingsTypeTranslations[selectedField.type])}
</h3>
{match(selectedField.type)
.with(FieldType.SIGNATURE, () => (
<EditorFieldSignatureForm
value={selectedField?.fieldMeta as TSignatureFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.CHECKBOX, () => (
<EditorFieldCheckboxForm
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DATE, () => (
<EditorFieldDateForm
value={selectedField?.fieldMeta as TDateFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DROPDOWN, () => (
<EditorFieldDropdownForm
value={selectedField?.fieldMeta as TDropdownFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.EMAIL, () => (
<EditorFieldEmailForm
value={selectedField?.fieldMeta as TEmailFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.INITIALS, () => (
<EditorFieldInitialsForm
value={selectedField?.fieldMeta as TInitialsFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NAME, () => (
<EditorFieldNameForm
value={selectedField?.fieldMeta as TNameFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NUMBER, () => (
<EditorFieldNumberForm
value={selectedField?.fieldMeta as TNumberFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.RADIO, () => (
<EditorFieldRadioForm
value={selectedField?.fieldMeta as TRadioFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.TEXT, () => (
<EditorFieldTextForm
value={selectedField?.fieldMeta as TTextFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.otherwise(() => null)}
</div>
</section>
)}
</AnimateGenericFadeInOut>
</TabsContent>
{/* Contents tab */}
<TabsContent value="contents">
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Add Content</Trans>
</h3>
<EnvelopeEditorContentDragDrop selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null} />
</section>
{/* Content details section. */}
<AnimateGenericFadeInOut key={editorContents.selectedContent?.formId}>
{selectedContent && (
<section>
<Separator className="my-4" />
<div className="px-4 [&_label]:text-foreground/70 [&_label]:text-xs">
<h3 className="font-semibold text-sm">
{_(ContentSettingsTypeTranslations[selectedContent.contentMeta.type])}
</h3>
{match(selectedContent.contentMeta.type)
.with(EnvelopeContentType.TEXT, () => <EditorContentTextForm />)
.with(EnvelopeContentType.LINE, () => <EditorContentLineForm />)
.with(EnvelopeContentType.SHAPE, () => <EditorContentShapeForm />)
.with(EnvelopeContentType.HIGHLIGHT, () => <EditorContentHighlightForm />)
.with(EnvelopeContentType.IMAGE, () => (
<EditorContentImageSettings
formId={selectedContent.formId}
dataContentId={selectedContent.dataContentId ?? null}
/>
))
.exhaustive()}
</div>
</section>
)}
</AnimateGenericFadeInOut>
</TabsContent>
</Tabs>
</div>
)}
</div>
</div>
</ContentSettingsFormProvider>
{/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && envelope.recipients.length > 0 && (
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-border border-l bg-background py-4">
{/* Recipient selector section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Selected Recipient</Trans>
</h3>
<EnvelopeRecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) => editorFields.setSelectedRecipient(recipient.id)}
recipients={envelope.recipients}
fields={envelope.fields}
className="w-full"
align="end"
/>
{editorFields.selectedRecipient &&
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
<Alert className="mt-4" variant="warning">
<AlertDescription>
<Trans>
This recipient can no longer be modified as they have signed a field, or completed the document.
</Trans>
</AlertDescription>
</Alert>
)}
</section>
<Separator className="my-4" />
{/* Add fields section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Add Fields</Trans>
</h3>
<EnvelopeEditorFieldDragDrop
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
{editorConfig.fields?.allowAIDetection && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="mr-2 -ml-1 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</>
)}
</section>
{/* Field details section. */}
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
{selectedField && (
<section>
<Separator className="my-4" />
{searchParams.get('devmode') && (
<>
<div className="px-4">
<h3 className="mb-3 font-semibold text-foreground text-sm">
<Trans>Developer Mode</Trans>
</h3>
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-foreground text-sm">
{selectedField.id && (
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Field ID:</Trans>
</span>{' '}
{selectedField.id}
</p>
)}
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Recipient ID:</Trans>
</span>{' '}
{selectedField.recipientId}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos X:</Trans>
</span>{' '}
{selectedField.positionX.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos Y:</Trans>
</span>{' '}
{selectedField.positionY.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Width:</Trans>
</span>{' '}
{selectedField.width.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Height:</Trans>
</span>{' '}
{selectedField.height.toFixed(2)}
</p>
</div>
</div>
<Separator className="my-4" />
</>
)}
<div className="px-4 [&_label]:text-foreground/70 [&_label]:text-xs">
<h3 className="font-semibold text-sm">{_(FieldSettingsTypeTranslations[selectedField.type])}</h3>
{match(selectedField.type)
.with(FieldType.SIGNATURE, () => (
<EditorFieldSignatureForm
value={selectedField?.fieldMeta as TSignatureFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.CHECKBOX, () => (
<EditorFieldCheckboxForm
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DATE, () => (
<EditorFieldDateForm
value={selectedField?.fieldMeta as TDateFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DROPDOWN, () => (
<EditorFieldDropdownForm
value={selectedField?.fieldMeta as TDropdownFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.EMAIL, () => (
<EditorFieldEmailForm
value={selectedField?.fieldMeta as TEmailFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.INITIALS, () => (
<EditorFieldInitialsForm
value={selectedField?.fieldMeta as TInitialsFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NAME, () => (
<EditorFieldNameForm
value={selectedField?.fieldMeta as TNameFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NUMBER, () => (
<EditorFieldNumberForm
value={selectedField?.fieldMeta as TNumberFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.RADIO, () => (
<EditorFieldRadioForm
value={selectedField?.fieldMeta as TRadioFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.TEXT, () => (
<EditorFieldTextForm
value={selectedField?.fieldMeta as TTextFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.otherwise(() => null)}
</div>
</section>
)}
</AnimateGenericFadeInOut>
</div>
)}
</div>
<ContentImageUploadDialog.Root />
</>
);
};
@@ -1,5 +1,5 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { getEnvelopeItemPermissions, mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
@@ -222,7 +222,6 @@ export default function EnvelopeEditorHeader() {
.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}
@@ -215,6 +215,7 @@ export const EnvelopeEditorPreviewPage = () => {
envelopeItems={envelope.envelopeItems}
token={undefined}
fields={fieldsWithPlaceholders}
contents={envelope.contents}
recipients={envelope.recipients.map((recipient) => ({
...recipient,
signingStatus: SigningStatus.SIGNED,
@@ -225,7 +226,7 @@ export const EnvelopeEditorPreviewPage = () => {
}}
>
<div className="relative flex h-full">
<div className="flex h-full w-full flex-col overflow-y-auto px-2" ref={scrollableContainerRef}>
<div className="flex h-full w-full flex-col overflow-x-auto overflow-y-auto px-2" ref={scrollableContainerRef}>
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector className="px-0" fields={editorFields.localFields} />
@@ -247,6 +248,7 @@ export const EnvelopeEditorPreviewPage = () => {
customPageRenderer={EnvelopeGenericPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
toolbar={['zoom']}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
@@ -1,42 +1,30 @@
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import {
updateEditorSigners,
ZEditorRecipientsFormSchema,
} from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import {
isAssistantLastSigner,
isCcRecipient,
normalizeRecipientSigningOrders,
canRecipientBeModified as utilCanRecipientBeModified,
} from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
import {
RecipientAutoCompleteInput,
type RecipientAutoCompleteOption,
} from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { normalizeGroupedSigningOrders } from '@documenso/lib/utils/recipient-groups';
import { canEditorRecipientBeModified } from '@documenso/lib/utils/recipients';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { SigningOrderConfirmation } from '@documenso/ui/primitives/document-flow/signing-order-confirmation';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DragDropContext, Draggable, Droppable, type DropResult, type SensorAPI } from '@hello-pangea/dnd';
import { plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react';
import { Trans } from '@lingui/react/macro';
import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client';
import { HelpCircleIcon, PlusIcon, SparklesIcon } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFieldArray, useWatch } from 'react-hook-form';
import { useRevalidator, useSearchParams } from 'react-router';
@@ -45,7 +33,8 @@ import { isDeepEqual } from 'remeda';
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
import { useCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
import { RecipientStepList } from './recipient-step-list';
export const EnvelopeEditorRecipientForm = () => {
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients, isEmbedded, editorConfig } =
@@ -53,9 +42,7 @@ export const EnvelopeEditorRecipientForm = () => {
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
const cspNonce = useCspNonce();
const { t } = useLingui();
const { toast } = useToast();
const { remaining } = useLimits();
const { sessionData } = useOptionalSession();
@@ -63,7 +50,6 @@ export const EnvelopeEditorRecipientForm = () => {
const user = sessionData?.user;
const [searchParams, setSearchParams] = useSearchParams();
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
// AI recipient detection dialog state
@@ -109,23 +95,8 @@ export const EnvelopeEditorRecipientForm = () => {
});
};
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
const $sensorApi = useRef<SensorAPI | null>(null);
const isFirstRender = useRef(true);
const { recipients, fields } = envelope;
const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery(
{
query: debouncedRecipientSearchQuery,
},
{
enabled: debouncedRecipientSearchQuery.length > 1 && !isEmbedded,
retry: false,
},
);
const recipientSuggestions = recipientSuggestionsData?.results || [];
const { recipients } = envelope;
const { form } = editorRecipients;
@@ -163,15 +134,16 @@ export const EnvelopeEditorRecipientForm = () => {
}, [watchedSigners]);
const normalizeSigningOrders = (signers: typeof watchedSigners) => {
return normalizeRecipientSigningOrders(signers, (signer) => canRecipientBeModified(signer.id));
return normalizeGroupedSigningOrders(signers, (signer) => canRecipientBeModified(signer.id));
};
const activeRecipientCount = watchedSigners.filter((signer) => !isCcRecipient(signer)).length;
const { fields: signers, remove: removeSigner } = useFieldArray({
// Keep a mounted field array for `signers` so react-hook-form reconciles
// whole-array `setValue` calls atomically. Without it, reordering the array
// leaves stale partial entries in watched values (missing email/name/role),
// which breaks validation and the autosave sync.
useFieldArray({
control,
name: 'signers',
keyName: 'nativeId',
});
const emptySignerIndex = watchedSigners.findIndex(
@@ -185,39 +157,22 @@ export const EnvelopeEditorRecipientForm = () => {
const hasCurrentEditorInfo = Boolean(currentEditorEmail || currentEditorName);
// Note: Watched signer entries can be transiently partial while react-hook-form
// re-registers reordered array fields, so guard optional access here.
const isUserAlreadyARecipient = watchedSigners.some(
(signer) => signer.email.toLowerCase() === currentEditorEmail?.toLowerCase(),
(signer) => Boolean(currentEditorEmail) && signer.email?.toLowerCase() === currentEditorEmail?.toLowerCase(),
);
const hasDocumentBeenSent = recipients.some(
(recipient) => recipient.role !== RecipientRole.CC && recipient.sendStatus === SendStatus.SENT,
);
const canRecipientBeModified = (recipientId?: number) => {
if (envelope.type === EnvelopeType.TEMPLATE) {
return true;
}
if (recipientId === undefined) {
return true;
}
const recipient = recipients.find((recipient) => recipient.id === recipientId);
if (!recipient) {
return false;
}
return utilCanRecipientBeModified(recipient, fields);
};
const canRecipientBeModified = (recipientId?: number) => canEditorRecipientBeModified(envelope, recipientId);
const appendNormalizedSigner = (signer: (typeof watchedSigners)[number], shouldFocus = false) => {
const updatedSigners = normalizeSigningOrders([...form.getValues('signers'), signer]);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
updateEditorSigners(form, updatedSigners);
if (shouldFocus) {
const signerIndex = updatedSigners.findIndex((updatedSigner) => updatedSigner.formId === signer.formId);
@@ -235,20 +190,17 @@ export const EnvelopeEditorRecipientForm = () => {
email: '',
role: RecipientRole.SIGNER,
actionAuth: [],
signingOrder: activeRecipientCount + 1,
signingOrder: undefined,
});
};
const onAiDetectionComplete = (detectedRecipients: TDetectedRecipientSchema[]) => {
const currentSigners = form.getValues('signers');
let nextSigningOrder =
currentSigners.length > 0 ? Math.max(...currentSigners.map((s) => s.signingOrder ?? 0)) + 1 : 1;
// If the only signer is the default empty signer lets just replace it with the detected recipients
if (currentSigners.length === 1 && !currentSigners[0].name && !currentSigners[0].email) {
form.setValue(
'signers',
updateEditorSigners(
form,
detectedRecipients.map((recipient, index) => ({
formId: nanoid(12),
name: recipient.name,
@@ -257,10 +209,6 @@ export const EnvelopeEditorRecipientForm = () => {
actionAuth: [],
signingOrder: index + 1,
})),
{
shouldValidate: true,
shouldDirty: true,
},
);
return;
@@ -281,16 +229,11 @@ export const EnvelopeEditorRecipientForm = () => {
email: recipient.email,
role: recipient.role,
actionAuth: [],
signingOrder: nextSigningOrder,
signingOrder: undefined,
});
nextSigningOrder += 1;
}
form.setValue('signers', normalizeSigningOrders(currentSigners), {
shouldValidate: true,
shouldDirty: true,
});
updateEditorSigners(form, normalizeSigningOrders(currentSigners));
toast({
title: plural(detectedRecipients.length, {
@@ -304,32 +247,6 @@ export const EnvelopeEditorRecipientForm = () => {
});
};
const onRemoveSigner = (index: number) => {
const signer = signers[index];
if (!canRecipientBeModified(signer.id)) {
toast({
title: t`Cannot remove signer`,
description: t`This signer has already signed the document.`,
variant: 'destructive',
});
return;
}
const formStateIndex = form.getValues('signers').findIndex((s) => s.formId === signer.formId);
if (formStateIndex !== -1) {
removeSigner(formStateIndex);
const updatedSigners = form.getValues('signers').filter((s) => s.formId !== signer.formId);
form.setValue('signers', normalizeSigningOrders(updatedSigners), {
shouldValidate: true,
shouldDirty: true,
});
}
};
const onAddSelfSigner = () => {
if (emptySignerIndex !== -1) {
setValue(`signers.${emptySignerIndex}.name`, currentEditorName ?? '', {
@@ -350,7 +267,7 @@ export const EnvelopeEditorRecipientForm = () => {
email: currentEditorEmail ?? '',
role: RecipientRole.SIGNER,
actionAuth: [],
signingOrder: activeRecipientCount + 1,
signingOrder: undefined,
},
true,
);
@@ -359,142 +276,6 @@ export const EnvelopeEditorRecipientForm = () => {
}
};
const handleRecipientAutoCompleteSelect = (index: number, suggestion: RecipientAutoCompleteOption) => {
setValue(`signers.${index}.email`, suggestion.email, {
shouldValidate: true,
shouldDirty: true,
});
setValue(`signers.${index}.name`, suggestion.name || '', {
shouldValidate: true,
shouldDirty: true,
});
};
const onDragEnd = useCallback(
async (result: DropResult) => {
if (!result.destination) {
return;
}
const items = Array.from(watchedSigners);
const [reorderedSigner] = items.splice(result.source.index, 1);
// Find next valid position
let insertIndex = result.destination.index;
while (insertIndex < items.length && !canRecipientBeModified(items[insertIndex].id)) {
insertIndex++;
}
items.splice(insertIndex, 0, reorderedSigner);
const updatedSigners = normalizeSigningOrders(items);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (isAssistantLastSigner(updatedSigners)) {
toast({
title: t`Warning: Assistant as last signer`,
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
});
}
await form.trigger('signers');
},
[form, canRecipientBeModified, watchedSigners, toast],
);
const handleRoleChange = useCallback(
(index: number, role: RecipientRole) => {
const currentSigners = form.getValues('signers');
const signingOrder = form.getValues('signingOrder');
// Handle parallel to sequential conversion for assistants
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, {
shouldValidate: true,
shouldDirty: true,
});
toast({
title: t`Signing order is enabled.`,
description: t`You cannot add assistants when signing order is disabled.`,
variant: 'destructive',
});
return;
}
const updatedSigners = normalizeSigningOrders(
currentSigners.map((signer, idx) => ({
...signer,
role: idx === index ? role : signer.role,
})),
);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) {
toast({
title: t`Warning: Assistant as last signer`,
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
});
}
},
[form, toast, canRecipientBeModified],
);
const handleSigningOrderChange = useCallback(
(index: number, newOrderString: string) => {
const trimmedOrderString = newOrderString.trim();
if (!trimmedOrderString) {
return;
}
const newOrder = Number(trimmedOrderString);
if (!Number.isInteger(newOrder) || newOrder < 1) {
return;
}
const currentSigners = form.getValues('signers');
const signer = currentSigners[index];
if (isCcRecipient(signer)) {
return;
}
const nonCcSigners = currentSigners.filter((s) => !isCcRecipient(s));
const ccSigners = currentSigners.filter((s) => isCcRecipient(s));
const currentSigningOrderIndex = nonCcSigners.findIndex((s) => s.formId === signer.formId);
if (currentSigningOrderIndex === -1) {
return;
}
const [reorderedSigner] = nonCcSigners.splice(currentSigningOrderIndex, 1);
const newPosition = Math.min(Math.max(0, newOrder - 1), nonCcSigners.length);
nonCcSigners.splice(newPosition, 0, reorderedSigner);
const updatedSigners = normalizeSigningOrders([...nonCcSigners, ...ccSigners]);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (signer.role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) {
toast({
title: t`Warning: Assistant as last signer`,
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
});
}
},
[form, canRecipientBeModified, toast],
);
const handleSigningOrderDisable = useCallback(() => {
setShowSigningOrderConfirmation(false);
@@ -506,10 +287,8 @@ export const EnvelopeEditorRecipientForm = () => {
})),
);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
updateEditorSigners(form, updatedSigners);
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, {
shouldValidate: true,
shouldDirty: true,
@@ -537,14 +316,17 @@ export const EnvelopeEditorRecipientForm = () => {
const { data } = validatedFormValues;
// Weird edge case where the whole envelope is created via API
// with no signing order. If they come to this page it will show an error
// since they aren't equal and the recipient is no longer editable.
// Locked recipients hold persisted values the server refuses to rewrite,
// e.g. an envelope created via API with no signing order where a recipient
// has already signed. Restore their PERSISTED order so form normalization
// drift never submits a "changed" locked recipient the server rejects.
const envelopeRecipients = data.signers.map((recipient) => {
if (!canRecipientBeModified(recipient.id)) {
const persistedRecipient = recipients.find((envelopeRecipient) => envelopeRecipient.id === recipient.id);
return {
...recipient,
signingOrder: recipient.signingOrder,
signingOrder: persistedRecipient?.signingOrder ?? undefined,
};
}
return recipient;
@@ -570,7 +352,7 @@ export const EnvelopeEditorRecipientForm = () => {
signer.email !== recipient.email ||
signer.name !== recipient.name ||
signer.role !== recipient.role ||
signer.signingOrder !== recipient.signingOrder ||
(signer.signingOrder ?? null) !== (recipient.signingOrder ?? null) ||
!isDeepEqual(signerActionAuth, recipientActionAuth)
);
});
@@ -590,7 +372,7 @@ export const EnvelopeEditorRecipientForm = () => {
}, [formValues]);
const recipientCountLimit = organisation.organisationClaim.recipientCount;
const isOverRecipientLimit = recipientCountLimit > 0 && signers.length > recipientCountLimit;
const isOverRecipientLimit = recipientCountLimit > 0 && watchedSigners.length > recipientCountLimit;
return (
<Card backdropBlur={false} className="border">
@@ -646,7 +428,7 @@ export const EnvelopeEditorRecipientForm = () => {
type="button"
className="flex-1"
size="sm"
disabled={isSubmitting || signers.length >= remaining.recipients}
disabled={isSubmitting || watchedSigners.length >= remaining.recipients}
onClick={() => onAddSigner()}
>
<PlusIcon className="mr-1 -ml-1 h-5 w-5" />
@@ -796,288 +578,7 @@ export const EnvelopeEditorRecipientForm = () => {
)}
</div>
<DragDropContext
nonce={cspNonce}
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
$sensorApi.current = api;
},
]}
>
<Droppable droppableId="signers">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="flex w-full flex-col gap-y-2">
{signers.map((signer, index) => {
const isDirectRecipient =
envelope.type === EnvelopeType.TEMPLATE &&
envelope.directLink !== null &&
signer.id === envelope.directLink.directTemplateRecipientId;
return (
<Draggable
key={`${signer.nativeId}-${signer.signingOrder}`}
draggableId={signer['nativeId']}
index={index}
isDragDisabled={
!isSigningOrderSequential ||
isSubmitting ||
isCcRecipient(signer) ||
!canRecipientBeModified(signer.id) ||
!signer.signingOrder
}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={cn('py-1', {
'pointer-events-none rounded-md bg-widget-foreground pt-2': snapshot.isDragging,
})}
>
<motion.fieldset
data-native-id={signer.id}
disabled={isSubmitting || !canRecipientBeModified(signer.id)}
className={cn('pb-2', {
'border-b pb-4': showAdvancedSettings && index !== signers.length - 1,
'pt-2': showAdvancedSettings && index === 0,
'pr-3': isSigningOrderSequential,
})}
>
<div className="flex flex-row items-center gap-x-2">
{isSigningOrderSequential && isCcRecipient(signer) && (
<div className="mt-auto h-10 w-[4.25rem] flex-shrink-0" />
)}
{isSigningOrderSequential && !isCcRecipient(signer) && (
<FormField
control={form.control}
name={`signers.${index}.signingOrder`}
render={({ field }) => (
<FormItem
className={cn('mt-auto flex items-center gap-x-1 space-y-0', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.signingOrder,
})}
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
<FormControl>
<Input
type="number"
max={activeRecipientCount}
data-testid="signing-order-input"
className={cn(
'w-10 text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
)}
{...field}
onChange={(e) => {
field.onChange(e);
handleSigningOrderChange(index, e.target.value);
}}
onBlur={(e) => {
field.onBlur();
handleSigningOrderChange(index, e.target.value);
}}
disabled={
snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('relative w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.email,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={field.value}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
isDirectRecipient
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn('w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.name,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Recipient ${index + 1}`}
{...field}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
isDirectRecipient
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto w-fit', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.role,
})}
>
<FormControl>
<RecipientRoleSelect
{...field}
hideAssistantRole={!editorConfig.recipients?.allowAssistantRole}
hideCCerRole={!editorConfig.recipients?.allowCCerRole}
hideViewerRole={!editorConfig.recipients?.allowViewerRole}
hideApproverRole={!editorConfig.recipients?.allowApproverRole}
isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole);
}}
disabled={
snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': form.formState.errors.signers?.[index],
})}
data-testid="remove-signer-button"
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
signers.length === 1 ||
isDirectRecipient
}
onClick={() => onRemoveSigner(index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
{showAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('mt-2 w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.actionAuth,
'pl-6': isSigningOrderSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={
snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</motion.fieldset>
</div>
)}
</Draggable>
);
})}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
<RecipientStepList showAdvancedSettings={showAdvancedSettings} />
<FormErrorMessage
className="mt-2"
@@ -18,6 +18,7 @@ export const EnvelopeEditorRenderProviderWrapper = ({
envelope={envelope}
envelopeItems={envelope.envelopeItems}
fields={envelope.fields}
contents={envelope.contents}
recipients={envelope.recipients}
token={token}
presignToken={presignedToken}
@@ -54,6 +54,7 @@ export const EnvelopeEditorUploadPage = () => {
envelope,
setLocalEnvelope,
editorFields,
editorContents,
editorConfig,
isEmbedded,
navigateToStep,
@@ -139,7 +140,7 @@ export const EnvelopeEditorUploadPage = () => {
);
const { mutateAsync: replaceEnvelopeItemPdf } = trpc.envelope.item.replacePdf.useMutation({
onSuccess: ({ data, fields }) => {
onSuccess: ({ data, fields, contents }) => {
// Update the envelope item with the new documentDataId.
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.map((item) =>
@@ -153,6 +154,12 @@ export const EnvelopeEditorUploadPage = () => {
setLocalEnvelope({ fields });
editorFields.resetForm(fields);
}
// Same for contents.
if (contents) {
setLocalEnvelope({ contents });
editorContents.resetForm(contents);
}
},
});
@@ -181,13 +188,18 @@ export const EnvelopeEditorUploadPage = () => {
// Directly commit the files for embedded documents since those are not uploaded
// until the end of the embedded flow.
if (isEmbedded) {
// New items go after the existing ones, in the order they were added.
// Deleting an item does not renumber the rest, so this continues from
// the highest order rather than the item count.
const highestOrder = envelope.envelopeItems.reduce((highest, item) => Math.max(highest, item.order), 0);
setLocalEnvelope({
envelopeItems: [
...envelope.envelopeItems,
...newUploadingFiles.map((file) => ({
...newUploadingFiles.map((file, index) => ({
id: file.envelopeItemId!,
title: file.title,
order: envelope.envelopeItems.length + 1,
order: highestOrder + index + 1,
envelopeId: envelope.id,
data: file.data!,
documentDataId: '',
@@ -273,12 +285,20 @@ export const EnvelopeEditorUploadPage = () => {
(field) => field.envelopeItemId !== envelopeItemId || field.page <= newPageCount,
);
// Contents are pruned by the same rule, matching what the server does
// for non embedded replacements.
const remainingContents = envelope.contents.filter(
(content) => content.envelopeItemId !== envelopeItemId || content.contentMeta.page <= newPageCount,
);
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.map((item) => (item.id === envelopeItemId ? { ...item, data } : item)),
fields: remainingFields,
contents: remainingContents,
});
editorFields.resetForm(remainingFields);
editorContents.resetForm(remainingContents);
return;
}
@@ -326,14 +346,17 @@ export const EnvelopeEditorUploadPage = () => {
setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.envelopeItemId !== envelopeItemId));
const fieldsWithoutDeletedItem = envelope.fields.filter((field) => field.envelopeItemId !== envelopeItemId);
const contentsWithoutDeletedItem = envelope.contents.filter((content) => content.envelopeItemId !== envelopeItemId);
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.filter((item) => item.id !== envelopeItemId),
fields: envelope.fields.filter((field) => field.envelopeItemId !== envelopeItemId),
fields: fieldsWithoutDeletedItem,
contents: contentsWithoutDeletedItem,
});
// Reset editor fields.
// Reset editor fields and contents.
editorFields.resetForm(fieldsWithoutDeletedItem);
editorContents.resetForm(contentsWithoutDeletedItem);
};
/**
@@ -90,7 +90,13 @@ export const EnvelopeEditor = () => {
const [searchParams, setSearchParams] = useSearchParams();
const {
general: { minimizeLeftSidebar, allowUploadAndRecipientStep, allowAddFieldsStep, allowPreviewStep },
general: {
minimizeLeftSidebar,
allowUploadAndRecipientStep,
allowAddFieldsStep,
allowAddContentsStep,
allowPreviewStep,
},
actions: {
allowDistributing,
allowDirectLink,
@@ -108,7 +114,7 @@ export const EnvelopeEditor = () => {
steps.push(UPLOAD_STEP);
}
if (allowAddFieldsStep) {
if (allowAddFieldsStep || allowAddContentsStep) {
steps.push(ADD_FIELDS_STEP);
}
@@ -524,11 +530,16 @@ export const EnvelopeEditor = () => {
pageToRender,
allowUploadAndRecipientStep,
allowAddFieldsStep,
allowAddContentsStep,
allowPreviewStep,
})
.with({ pageToRender: 'loading' }, () => <SpinnerBox className="py-32" />)
.with({ pageToRender: 'upload', allowUploadAndRecipientStep: true }, () => <EnvelopeEditorUploadPage />)
.with({ pageToRender: 'addFields', allowAddFieldsStep: true }, () => <EnvelopeEditorFieldsPage />)
.with(
{ pageToRender: 'addFields', allowAddFieldsStep: true },
{ pageToRender: 'addFields', allowAddContentsStep: true },
() => <EnvelopeEditorFieldsPage />,
)
.with({ pageToRender: 'preview', allowPreviewStep: true }, () => <EnvelopeEditorPreviewPage />)
.otherwise(() => null)}
</div>
@@ -5,7 +5,9 @@ import {
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { renderStaticContents } from '@documenso/lib/universal/content-renderer/render-static-contents';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { areContentsImprinted } from '@documenso/lib/utils/envelope';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
import { useLingui } from '@lingui/react/macro';
@@ -22,9 +24,12 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
const analytics = useAnalytics();
const {
version,
envelopeStatus,
currentEnvelopeItem,
fields,
contents,
contentImages,
signatures,
recipients,
getRecipientColorKey,
@@ -32,16 +37,40 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
overrideSettings,
} = useCurrentEnvelopeRender();
/**
* Once an envelope is sent its contents are inserted into the current PDF, so
* rendering them again would double them up.
*/
const shouldRenderContents = !(version === 'current' && areContentsImprinted(envelopeStatus));
const signaturesByFieldId = useMemo(() => {
return new Map(signatures.map((signature) => [signature.fieldId, signature]));
}, [signatures]);
const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer(({ stage, pageLayer }) => {
const {
stage,
pageLayer,
konvaContainer,
unscaledViewport,
fieldsVisibility,
applyPageItemsVisibility,
hasFailedImage,
} = usePageRenderer(({ stage, pageLayer }) => {
createPageCanvas(stage, pageLayer);
}, pageData);
const { scale, pageNumber } = pageData;
/**
* A content image which failed to load would render the page differently
* to how it was authored, so it is treated like any other render failure.
*/
useEffect(() => {
if (shouldRenderContents && hasFailedImage) {
setRenderError(true);
}
}, [shouldRenderContents, hasFailedImage]);
const localPageFields = useMemo((): GenericLocalField[] => {
if (envelopeStatus === DocumentStatus.COMPLETED) {
return [];
@@ -105,7 +134,8 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
translations: fieldTranslations,
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
color: getRecipientColorKey(field.recipientId),
// Muted fields are rendered with the read-only styling.
color: fieldsVisibility === 'muted' ? 'readOnly' : getRecipientColorKey(field.recipientId),
editable: false,
mode: overrideSettings?.mode ?? 'edit',
});
@@ -131,6 +161,12 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
* Initialize the Konva page canvas and all fields and interactions.
*/
const createPageCanvas = (_currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
// Render the contents beneath the fields. Contents are static, so they
// only need to be rendered when the canvas is created.
if (shouldRenderContents) {
renderContents(currentPageLayer);
}
// Render the fields.
for (const field of localPageFields) {
renderFieldOnLayer(field);
@@ -139,6 +175,28 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
currentPageLayer.batchDraw();
};
/**
* Render the authored contents of the page beneath the fields.
*/
const renderContents = (currentPageLayer: Konva.Layer) => {
try {
renderStaticContents({
contents: contents.filter(
(content) => content.contentMeta.page === pageNumber && content.envelopeItemId === currentEnvelopeItem?.id,
),
pageLayer: currentPageLayer,
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
scale,
mode: overrideSettings?.mode ?? 'edit',
images: contentImages.images,
});
} catch (err) {
console.error(err);
setRenderError(true);
}
};
/**
* Render fields when they are added or removed
*/
@@ -159,8 +217,10 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
renderFieldOnLayer(field);
});
applyPageItemsVisibility();
pageLayer.current.batchDraw();
}, [localPageFields, signaturesByFieldId]);
}, [localPageFields, signaturesByFieldId, fieldsVisibility]);
if (!currentEnvelopeItem) {
return null;
@@ -170,6 +230,7 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
<>
{overrideSettings?.showRecipientTooltip &&
pageData.imageLoadingState === 'loaded' &&
fieldsVisibility === 'visible' &&
localPageFields.map((field) => (
<EnvelopeRecipientFieldTooltip
key={field.id}
@@ -0,0 +1,235 @@
import type { TEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { isCcRecipient } from '@documenso/lib/utils/recipients';
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
import {
RecipientAutoCompleteInput,
type RecipientAutoCompleteOption,
} from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form';
import type { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd';
import { useLingui } from '@lingui/react/macro';
import { EnvelopeType, type RecipientRole } from '@prisma/client';
import { GripVerticalIcon, TrashIcon } from 'lucide-react';
import { memo } from 'react';
import { useFormContext } from 'react-hook-form';
type TEditorSigner = TEditorRecipientsFormSchema['signers'][number];
export type RecipientRowProps = {
signerIndex: number;
signer: TEditorSigner;
isSequential: boolean;
isInputDisabled: boolean;
canBeModified: boolean;
isRemoveDisabled: boolean;
showAdvancedSettings: boolean;
dragHandleProps?: DraggableProvidedDragHandleProps | null;
recipientSuggestions: RecipientAutoCompleteOption[];
isLoadingSuggestions: boolean;
onRoleChange: (signerIndex: number, role: RecipientRole) => void;
onRemove: (signerIndex: number) => void;
onAutoCompleteSelect: (signerIndex: number, suggestion: RecipientAutoCompleteOption) => void;
onSearchQueryChange: (query: string) => void;
};
const RecipientRowInner = ({
signerIndex,
signer,
isSequential,
isInputDisabled,
canBeModified,
isRemoveDisabled,
showAdvancedSettings,
dragHandleProps,
recipientSuggestions,
isLoadingSuggestions,
onRoleChange,
onRemove,
onAutoCompleteSelect,
onSearchQueryChange,
}: RecipientRowProps) => {
const { t } = useLingui();
const { envelope, editorConfig } = useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation();
const form = useFormContext<TEditorRecipientsFormSchema>();
const { isSubmitting } = form.formState;
const isDirectRecipient =
envelope.type === EnvelopeType.TEMPLATE &&
envelope.directLink !== null &&
signer.id === envelope.directLink.directTemplateRecipientId;
const isFieldDisabled = isInputDisabled || isSubmitting || !canBeModified;
const rowErrors = form.formState.errors.signers?.[signerIndex];
return (
<fieldset data-native-id={signer.id} disabled={isSubmitting || !canBeModified} className="py-1">
<div className="flex flex-row items-center gap-x-2">
{isSequential && !isCcRecipient(signer) && (
<span
{...(dragHandleProps ?? {})}
data-testid="recipient-row-drag-handle"
className={cn(
'mt-auto -ml-1.5 flex h-10 w-8 flex-shrink-0 cursor-grab items-center justify-center rounded-md hover:bg-foreground/5 active:cursor-grabbing',
{
'mb-6': rowErrors,
'cursor-default hover:bg-transparent': !dragHandleProps,
},
)}
>
<GripVerticalIcon
className={cn('h-5 w-5 flex-shrink-0 opacity-40', {
'opacity-10': !dragHandleProps,
})}
/>
</span>
)}
<FormField
control={form.control}
name={`signers.${signerIndex}.email`}
render={({ field }) => (
<FormItem
className={cn('relative w-full', {
'mb-6': rowErrors && !rowErrors.email,
})}
>
<FormControl>
<RecipientAutoCompleteInput
type="email"
aria-label={t`Email`}
placeholder={t`Email`}
value={field.value}
disabled={isFieldDisabled || isDirectRecipient}
options={recipientSuggestions}
onSelect={(suggestion) => onAutoCompleteSelect(signerIndex, suggestion)}
onSearchQueryChange={(query) => {
field.onChange(query);
onSearchQueryChange(query);
}}
loading={isLoadingSuggestions}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${signerIndex}.name`}
render={({ field }) => (
<FormItem
className={cn('w-full', {
'mb-6': rowErrors && !rowErrors.name,
})}
>
<FormControl>
<RecipientAutoCompleteInput
type="text"
aria-label={t`Name`}
placeholder={t`Recipient ${signerIndex + 1}`}
{...field}
disabled={isFieldDisabled || isDirectRecipient}
options={recipientSuggestions}
onSelect={(suggestion) => onAutoCompleteSelect(signerIndex, suggestion)}
onSearchQueryChange={(query) => {
field.onChange(query);
onSearchQueryChange(query);
}}
loading={isLoadingSuggestions}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${signerIndex}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto w-fit', {
'mb-6': rowErrors && !rowErrors.role,
})}
>
<FormControl>
<RecipientRoleSelect
{...field}
hideAssistantRole={!editorConfig.recipients?.allowAssistantRole}
hideCCerRole={!editorConfig.recipients?.allowCCerRole}
hideViewerRole={!editorConfig.recipients?.allowViewerRole}
hideApproverRole={!editorConfig.recipients?.allowApproverRole}
isAssistantEnabled={isSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
onRoleChange(signerIndex, value as RecipientRole);
}}
disabled={isFieldDisabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': rowErrors,
})}
data-testid="remove-signer-button"
disabled={isFieldDisabled || isRemoveDisabled || isDirectRecipient}
onClick={() => onRemove(signerIndex)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
{showAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${signerIndex}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('mt-2 w-full', {
'mb-6': rowErrors && !rowErrors.actionAuth,
'pl-6': isSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect {...field} onValueChange={field.onChange} disabled={isFieldDisabled} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</fieldset>
);
};
/**
* Memoized: rows contain heavy inputs (autocomplete, role select) and would
* otherwise re-render on every drag state change, making drags feel sluggish.
* All callback props are stable (useCallback in the list) and `signer` object
* identities only change when form values actually change.
*/
export const RecipientRow = memo(RecipientRowInner);
@@ -0,0 +1,261 @@
import type { TEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import type { RecipientStep } from '@documenso/lib/utils/recipient-groups';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import type { DraggableProvided, DraggableStateSnapshot } from '@hello-pangea/dnd';
import { Draggable, Droppable } from '@hello-pangea/dnd';
import { Plural, Trans } from '@lingui/react/macro';
import { GripVerticalIcon, Users2Icon } from 'lucide-react';
import { RecipientRow, type RecipientRowProps } from './recipient-row';
type TEditorSigner = TEditorRecipientsFormSchema['signers'][number];
export type DraggingType = 'STEP' | 'RECIPIENT' | null;
/**
* Skips the drop animation. The post-drop state update re-sorts and renumbers
* the groups anyway, so gliding to the predicted slot first makes every drop
* feel like it settles twice — snapping hands control to the real re-render
* immediately instead.
*/
const getDraggableStyle = (provided: DraggableProvided, snapshot: DraggableStateSnapshot) => {
if (!snapshot.isDropAnimating) {
return provided.draggableProps.style;
}
return {
...provided.draggableProps.style,
transitionDuration: '0.001s',
};
};
export type RecipientStepCardSharedRowProps = Pick<
RecipientRowProps,
| 'showAdvancedSettings'
| 'recipientSuggestions'
| 'isLoadingSuggestions'
| 'onRoleChange'
| 'onRemove'
| 'onAutoCompleteSelect'
| 'onSearchQueryChange'
>;
export type RecipientStepCardProps = {
stepIndex: number;
step: RecipientStep<TEditorSigner>;
isLastStep: boolean;
draggableProvided: DraggableProvided;
draggableSnapshot: DraggableStateSnapshot;
draggingType: DraggingType;
/**
* Whether recipients may be combined into signing groups. False on CSC
* (AES/QES) instances, where every signing recipient must hold a distinct
* step. Constant for the session, so disabling the drop-zone with it does
* not violate the "never toggle `isDropDisabled` mid-drag" constraint.
*/
isGroupingEnabled: boolean;
isStepLocked: boolean;
isRemoveDisabled: boolean;
flatIndexByFormId: Map<string, number>;
canSignerBeModified: (signer: TEditorSigner) => boolean;
isSubmitting: boolean;
onUngroup: (stepIndex: number) => void;
rowProps: RecipientStepCardSharedRowProps;
};
/**
* The drop-zone strip rendered above each group card (and below the last one)
* that receives recipient-row drops. Invisible until a dragged row hovers it,
* then it shows a full-width green line marking the insertion point.
*
* Notes:
* - It lives INSIDE the step's Draggable so it shifts together with the card
* while groups are being reordered — a static strip between draggables
* would stay behind while the cards around it are displaced, making group
* drags look broken.
* - Its `droppableId` must stay STABLE while mounted (anchored to a formId,
* never a positional index): @hello-pangea/dnd does not support changing
* ids on mounted droppables/draggables, which silently breaks them.
* - `type="RECIPIENT"` already scopes it to recipient-row drags, and
* `isDropDisabled` must not be toggled based on the active drag, as
* @hello-pangea/dnd snapshots it at drag start (before state updates land).
* - It must keep a CONSTANT size: droppable geometry is captured when a drag
* starts, so resizing during the drag would leave the visible strip and the
* actual hit area in different places. Only colors may change mid-drag.
*/
const RecipientStepGap = ({ droppableId }: { droppableId: string }) => (
<Droppable droppableId={droppableId} type="RECIPIENT">
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
data-testid="recipient-step-gap"
className={cn('flex h-6 items-center', {
'gap-active': snapshot.isDraggingOver,
})}
>
<div
className={cn('h-[3px] w-full rounded-full bg-primary opacity-0 transition-opacity duration-100', {
'opacity-100': snapshot.isDraggingOver,
})}
/>
{provided.placeholder}
</div>
)}
</Droppable>
);
export const RecipientStepCard = ({
stepIndex,
step,
isLastStep,
draggableProvided,
draggableSnapshot,
draggingType,
isGroupingEnabled,
isStepLocked,
isRemoveDisabled,
flatIndexByFormId,
canSignerBeModified,
isSubmitting,
onUngroup,
rowProps,
}: RecipientStepCardProps) => {
const isGroup = step.members.length > 1;
const isCombineTarget = draggingType === 'STEP' && Boolean(draggableSnapshot.combineTargetFor);
const stepLabel = step.order ?? stepIndex + 1;
// All droppable ids are anchored to the first member's formId (never a
// positional index) so they stay stable while cards are reordered —
// @hello-pangea/dnd does not support changing ids on mounted elements.
const stepAnchor = step.members[0].formId;
return (
<div
ref={draggableProvided.innerRef}
{...draggableProvided.draggableProps}
style={getDraggableStyle(draggableProvided, draggableSnapshot)}
className={cn({
'pointer-events-none': draggableSnapshot.isDragging,
})}
>
<RecipientStepGap droppableId={`gap-${stepAnchor}`} />
<Droppable droppableId={`step-members-${stepAnchor}`} type="RECIPIENT" isDropDisabled={!isGroupingEnabled}>
{(droppableProvided, droppableSnapshot) => {
const isJoinTarget = draggingType === 'RECIPIENT' && droppableSnapshot.isDraggingOver;
const isHighlighted = isCombineTarget || isJoinTarget;
return (
<div
ref={droppableProvided.innerRef}
{...droppableProvided.droppableProps}
data-testid="recipient-step-card"
className={cn('relative rounded-lg border bg-background px-3 pt-2 pb-1 transition-shadow', {
'border-primary/60 bg-primary/5': isGroup,
'bg-widget-foreground shadow-lg': draggableSnapshot.isDragging,
'border-primary ring-1 ring-primary': isHighlighted,
})}
>
{isHighlighted && (
<Badge
variant="default"
size="small"
className="absolute -top-3 right-4 z-10 flex items-center gap-x-1 shadow-sm"
>
<Users2Icon className="h-3 w-3" />
<Trans>Release to group</Trans>
</Badge>
)}
<div className="flex flex-row items-center gap-x-1">
<span
{...(draggableProvided.dragHandleProps ?? {})}
data-testid="step-drag-handle"
className={cn(
'-my-1 -ml-1.5 flex h-8 w-8 flex-shrink-0 cursor-grab items-center justify-center rounded-md hover:bg-foreground/5 active:cursor-grabbing',
{ 'pointer-events-none opacity-30': isStepLocked },
)}
>
<GripVerticalIcon className="h-4 w-4 opacity-60" />
</span>
<Badge variant={isGroup ? 'default' : 'neutral'} size="small">
<Trans>Group {stepLabel}</Trans>
</Badge>
{isGroup && (
<>
<span className="ml-1 flex items-center gap-x-1.5 text-green-700 text-xs dark:text-green-400">
<Users2Icon className="h-3.5 w-3.5" />
<Plural
value={step.members.length}
one="# recipient · any order"
other="# recipients · any order"
/>
</span>
<Button
type="button"
variant="link"
size="sm"
data-testid="ungroup-step-button"
className="ml-auto h-auto p-0 text-xs"
disabled={isStepLocked || isSubmitting}
onClick={() => onUngroup(stepIndex)}
>
<Trans>Ungroup</Trans>
</Button>
</>
)}
</div>
{step.members.map((member, memberIndex) => {
const signerIndex = flatIndexByFormId.get(member.formId) ?? -1;
const canBeModified = canSignerBeModified(member);
return (
<Draggable
key={member.formId}
draggableId={`recipient-${member.formId}`}
index={memberIndex}
isDragDisabled={isSubmitting || isStepLocked}
>
{(memberProvided, memberSnapshot) => (
<div
ref={memberProvided.innerRef}
{...memberProvided.draggableProps}
style={getDraggableStyle(memberProvided, memberSnapshot)}
className={cn({
'rounded-md bg-widget-foreground shadow-lg': memberSnapshot.isDragging,
})}
>
<RecipientRow
signerIndex={signerIndex}
signer={member}
isSequential={true}
isInputDisabled={memberSnapshot.isDragging || draggableSnapshot.isDragging}
canBeModified={canBeModified}
isRemoveDisabled={isRemoveDisabled}
dragHandleProps={memberProvided.dragHandleProps}
{...rowProps}
/>
</div>
)}
</Draggable>
);
})}
{droppableProvided.placeholder}
</div>
);
}}
</Droppable>
{isLastStep && <RecipientStepGap droppableId="gap-end" />}
</div>
);
};
@@ -0,0 +1,398 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import {
type TEditorRecipientsFormSchema,
updateEditorSigners,
} from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import {
extractRecipientToNewStep,
getLastLockedStepIndex,
groupRecipientsBySigningOrder,
isSigningOrderFrozen,
mergeSteps,
moveRecipientToStep,
normalizeGroupedSigningOrders,
reorderStep,
ungroupStep,
} from '@documenso/lib/utils/recipient-groups';
import { canEditorRecipientBeModified, isAssistantLastSigner } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import type { RecipientAutoCompleteOption } from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { Badge } from '@documenso/ui/primitives/badge';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { BeforeCapture, DropResult } from '@hello-pangea/dnd';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { useCallback, useMemo, useState } from 'react';
import { useCspNonce } from '~/utils/nonce';
import { RecipientRow } from './recipient-row';
import { type DraggingType, RecipientStepCard } from './recipient-step-card';
type TEditorSigner = TEditorRecipientsFormSchema['signers'][number];
export type RecipientStepListProps = {
showAdvancedSettings: boolean;
};
export const RecipientStepList = ({ showAdvancedSettings }: RecipientStepListProps) => {
const { t } = useLingui();
const { toast } = useToast();
const cspNonce = useCspNonce();
const { envelope, editorRecipients, isEmbedded, isCscMode } = useCurrentEnvelopeEditor();
const { form } = editorRecipients;
// Signing groups are an SES feature: TSP (AES/QES) signatures must be
// strictly sequential, so on CSC instances the group affordances (card
// combine, row-to-card join) are disabled while step reordering and
// ungrouping of invalid API-created state stay available.
const isGroupingEnabled = !isCscMode;
const [draggingType, setDraggingType] = useState<DraggingType>(null);
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery(
{
query: debouncedRecipientSearchQuery,
},
{
enabled: debouncedRecipientSearchQuery.length > 1 && !isEmbedded,
retry: false,
},
);
const recipientSuggestions = recipientSuggestionsData?.results || [];
const watchedSigners = form.watch('signers');
const isSequential = form.watch('signingOrder') === DocumentSigningOrder.SEQUENTIAL;
const { isSubmitting } = form.formState;
const { steps, ccRecipients } = useMemo(() => groupRecipientsBySigningOrder(watchedSigners), [watchedSigners]);
// Signing is sequential, so anyone who has already acted is at or before the
// current step. Those steps hold persisted orders that cannot be rewritten,
// so ordering is locked up to and including the last of them; everything
// after can still be rearranged freely.
const lastLockedStepIndex = useMemo(
() => getLastLockedStepIndex(steps, (signer) => canEditorRecipientBeModified(envelope, signer.id)),
[steps, envelope],
);
const isOrderingFrozen = useMemo(
() => isSigningOrderFrozen(steps, (signer) => canEditorRecipientBeModified(envelope, signer.id)),
[steps, envelope],
);
const isRemoveDisabled = watchedSigners.length === 1;
const flatIndexByFormId = useMemo(
() => new Map(watchedSigners.map((signer, index) => [signer.formId, index])),
[watchedSigners],
);
const canSignerBeModified = useCallback(
(signer: TEditorSigner) => canEditorRecipientBeModified(envelope, signer.id),
[envelope],
);
const applySigners = useCallback(
(updatedSigners: TEditorSigner[], options: { warnWhenAssistantLast?: boolean } = {}) => {
const { warnWhenAssistantLast = true } = options;
updateEditorSigners(form, updatedSigners);
if (warnWhenAssistantLast && isAssistantLastSigner(updatedSigners)) {
toast({
title: t`Warning: Assistant as last signer`,
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
});
}
void form.trigger('signers');
},
[form, t, toast],
);
const handleRoleChange = useCallback(
(signerIndex: number, role: RecipientRole) => {
const currentSigners = form.getValues('signers');
const signingOrder = form.getValues('signingOrder');
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, {
shouldValidate: true,
shouldDirty: true,
});
toast({
title: t`Signing order is enabled.`,
description: t`You cannot add assistants when signing order is disabled.`,
variant: 'destructive',
});
return;
}
const updatedSigners = normalizeGroupedSigningOrders(
currentSigners.map((signer, index) => ({
...signer,
role: index === signerIndex ? role : signer.role,
})),
canSignerBeModified,
);
applySigners(updatedSigners, { warnWhenAssistantLast: role === RecipientRole.ASSISTANT });
},
[form, toast, t, canSignerBeModified, applySigners],
);
const handleRemove = useCallback(
(signerIndex: number) => {
const signer = form.getValues('signers')[signerIndex];
if (!signer) {
return;
}
if (!canSignerBeModified(signer)) {
toast({
title: t`Cannot remove signer`,
description: t`This signer has already signed the document.`,
variant: 'destructive',
});
return;
}
const updatedSigners = normalizeGroupedSigningOrders(
form.getValues('signers').filter((s) => s.formId !== signer.formId),
canSignerBeModified,
);
applySigners(updatedSigners, { warnWhenAssistantLast: false });
},
[form, toast, t, canSignerBeModified, applySigners],
);
const handleUngroup = useCallback(
(stepIndex: number) => {
applySigners(ungroupStep(form.getValues('signers'), stepIndex, canSignerBeModified));
},
[form, canSignerBeModified, applySigners],
);
const handleAutoCompleteSelect = useCallback(
(signerIndex: number, suggestion: RecipientAutoCompleteOption) => {
form.setValue(`signers.${signerIndex}.email`, suggestion.email, {
shouldValidate: true,
shouldDirty: true,
});
form.setValue(`signers.${signerIndex}.name`, suggestion.name || '', {
shouldValidate: true,
shouldDirty: true,
});
},
[form],
);
const onBeforeCapture = useCallback((before: BeforeCapture) => {
setDraggingType(before.draggableId.startsWith('step-') ? 'STEP' : 'RECIPIENT');
}, []);
const onDragEnd = useCallback(
(result: DropResult) => {
setDraggingType(null);
const currentSigners = form.getValues('signers');
// Drag-and-drop ids are anchored to the first member's formId so they
// stay stable across reorders; resolve them back to step indexes here.
const { steps: currentSteps } = groupRecipientsBySigningOrder(currentSigners);
const findStepIndexByAnchor = (anchorFormId: string) =>
currentSteps.findIndex((step) => step.members[0]?.formId === anchorFormId);
if (result.type === 'STEP') {
if (result.combine) {
// Unreachable while combining is disabled, but kept as a guard so a
// stray combine result can never form a group on a CSC envelope.
if (!isGroupingEnabled) {
return;
}
const targetStepIndex = findStepIndexByAnchor(result.combine.draggableId.slice('step-'.length));
if (targetStepIndex === -1) {
return;
}
applySigners(mergeSteps(currentSigners, result.source.index, targetStepIndex, canSignerBeModified));
return;
}
if (result.destination) {
applySigners(reorderStep(currentSigners, result.source.index, result.destination.index, canSignerBeModified));
}
return;
}
if (result.type === 'RECIPIENT' && result.destination) {
const formId = result.draggableId.slice('recipient-'.length);
const { droppableId } = result.destination;
if (droppableId === 'gap-end') {
applySigners(extractRecipientToNewStep(currentSigners, formId, currentSteps.length, canSignerBeModified));
return;
}
if (droppableId.startsWith('gap-')) {
const insertStepIndex = findStepIndexByAnchor(droppableId.slice('gap-'.length));
if (insertStepIndex === -1) {
return;
}
applySigners(extractRecipientToNewStep(currentSigners, formId, insertStepIndex, canSignerBeModified));
return;
}
if (droppableId.startsWith('step-members-')) {
// Unreachable while the card drop-zones are disabled, but kept as a
// guard so a stray drop can never form a group on a CSC envelope.
if (!isGroupingEnabled) {
return;
}
const targetStepIndex = findStepIndexByAnchor(droppableId.slice('step-members-'.length));
if (targetStepIndex === -1) {
return;
}
applySigners(moveRecipientToStep(currentSigners, formId, targetStepIndex, canSignerBeModified));
}
}
},
[form, canSignerBeModified, applySigners, isGroupingEnabled],
);
const sharedRowProps = {
showAdvancedSettings,
recipientSuggestions,
isLoadingSuggestions: isLoading,
onRoleChange: handleRoleChange,
onRemove: handleRemove,
onAutoCompleteSelect: handleAutoCompleteSelect,
onSearchQueryChange: setRecipientSearchQuery,
};
return (
<div>
{!showAdvancedSettings && !isSequential && (
<div className="mb-1 flex flex-row gap-x-2 text-sm">
<span className="w-full">
<Trans>Email</Trans>
</span>
<span className="w-full">
<Trans>Name</Trans>
</span>
<span className="w-[7.5rem] flex-shrink-0" />
</div>
)}
{!isSequential ? (
<div className="flex w-full flex-col">
{watchedSigners.map((signer, index) => (
<RecipientRow
key={signer.formId}
signerIndex={index}
signer={signer}
isSequential={false}
isInputDisabled={false}
canBeModified={canSignerBeModified(signer)}
isRemoveDisabled={isRemoveDisabled}
dragHandleProps={null}
{...sharedRowProps}
/>
))}
</div>
) : (
<>
<DragDropContext nonce={cspNonce} onBeforeCapture={onBeforeCapture} onDragEnd={onDragEnd}>
<Droppable droppableId="recipient-steps" type="STEP" isCombineEnabled={isGroupingEnabled}>
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="flex w-full flex-col">
{steps.map((step, stepIndex) => {
const isStepLocked = isOrderingFrozen || stepIndex <= lastLockedStepIndex;
return (
<Draggable
key={`step-${step.members[0].formId}`}
draggableId={`step-${step.members[0].formId}`}
index={stepIndex}
isDragDisabled={isSubmitting || isStepLocked}
>
{(draggableProvided, draggableSnapshot) => (
<RecipientStepCard
stepIndex={stepIndex}
step={step}
isLastStep={stepIndex === steps.length - 1}
draggableProvided={draggableProvided}
draggableSnapshot={draggableSnapshot}
draggingType={draggingType}
isGroupingEnabled={isGroupingEnabled}
isStepLocked={isStepLocked}
isRemoveDisabled={isRemoveDisabled}
flatIndexByFormId={flatIndexByFormId}
canSignerBeModified={canSignerBeModified}
isSubmitting={isSubmitting}
onUngroup={handleUngroup}
rowProps={sharedRowProps}
/>
)}
</Draggable>
);
})}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
{ccRecipients.length > 0 && (
<div className="my-1 rounded-lg border px-3 py-1.5">
<Badge variant="neutral" size="small">
<Trans>Receives Copy</Trans>
</Badge>
{ccRecipients.map((signer) => (
<div key={signer.formId} className="my-1">
<RecipientRow
signerIndex={flatIndexByFormId.get(signer.formId) ?? -1}
signer={signer}
isSequential={true}
isInputDisabled={false}
canBeModified={canSignerBeModified(signer)}
isRemoveDisabled={isRemoveDisabled}
dragHandleProps={null}
{...sharedRowProps}
/>
</div>
))}
</div>
)}
</>
)}
</div>
);
};
@@ -10,6 +10,7 @@ import { isBase64Image } from '@documenso/lib/constants/signatures';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { ZFullFieldSchema } from '@documenso/lib/types/field';
import { renderStaticContents } from '@documenso/lib/universal/content-renderer/render-static-contents';
import {
createFieldCanvasStyleCache,
type FieldCanvasStyleCache,
@@ -17,6 +18,7 @@ import {
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { areContentsImprinted } from '@documenso/lib/utils/envelope';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
@@ -49,7 +51,7 @@ type GenericLocalField = TEnvelope['fields'][number] & {
export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
const { t, i18n } = useLingui();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
const { currentEnvelopeItem, contentImages, setRenderError } = useCurrentEnvelopeRender();
const { sessionData } = useOptionalSession();
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
@@ -535,10 +537,39 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
}
};
/**
* Render the contents when required.
*
* Since contents are imprinted on sent, we still need to render them for direct templates.
*/
const renderContents = () => {
if (!pageLayer.current || areContentsImprinted(envelope.status)) {
return;
}
try {
renderStaticContents({
contents: envelope.contents.filter(
(content) => content.contentMeta.page === pageNumber && content.envelopeItemId === currentEnvelopeItem?.id,
),
pageLayer: pageLayer.current,
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
scale,
mode: 'sign',
images: contentImages.images,
});
} catch (err) {
console.error(err);
setRenderError(true);
}
};
/**
* Initialize the Konva page canvas and all fields and interactions.
*/
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
renderContents();
renderFields();
currentPageLayer.batchDraw();
};
@@ -577,6 +608,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
pageLayer.current.destroyChildren();
cachedRenderFields.current.clear();
renderContents();
renderFields();
pageLayer.current.batchDraw();
@@ -8,20 +8,40 @@ import { useRef } from 'react';
import type { PDFViewerProps } from './pdf-viewer';
import PDFViewerLazy from './pdf-viewer-lazy';
import type { EnvelopePdfViewerToolbarControl } from './pdf-viewer-toolbar';
import { EnvelopePdfViewerToolbar } from './pdf-viewer-toolbar';
export type EnvelopePdfViewerProps = {
/**
* The error message to render when there is an error.
*/
errorMessage: { title: MessageDescriptor; description: MessageDescriptor } | null;
/**
* The controls to display in the floating viewer toolbar.
*
* When omitted no toolbar is rendered.
*/
toolbar?: EnvelopePdfViewerToolbarControl[];
/**
* Additional class names for the floating viewer toolbar.
*/
toolbarClassName?: string;
} & Omit<PDFViewerProps, 'data'>;
export const EnvelopePdfViewer = ({ errorMessage, className, ...props }: EnvelopePdfViewerProps) => {
export const EnvelopePdfViewer = ({
errorMessage,
toolbar,
toolbarClassName,
className,
...props
}: EnvelopePdfViewerProps) => {
const { t } = useLingui();
const $el = useRef<HTMLDivElement>(null);
const { currentEnvelopeItem, renderError } = useCurrentEnvelopeRender();
const { currentEnvelopeItem, renderError, viewerControls } = useCurrentEnvelopeRender();
if (renderError || !currentEnvelopeItem) {
return (
@@ -45,12 +65,24 @@ export const EnvelopePdfViewer = ({ errorMessage, className, ...props }: Envelop
}
return (
<PDFViewerLazy
key={`${currentEnvelopeItem.envelopeId}-${currentEnvelopeItem.id}`}
{...props}
className={cn('h-full w-full max-w-[800px]', className)}
data={currentEnvelopeItem.data}
/>
<>
<PDFViewerLazy
key={`${currentEnvelopeItem.envelopeId}-${currentEnvelopeItem.id}`}
{...props}
className={cn('h-full w-full', className)}
data={currentEnvelopeItem.data}
zoom={viewerControls.zoom}
maxPageWidth={800}
/>
{toolbar && toolbar.length > 0 && (
<EnvelopePdfViewerToolbar
controls={toolbar}
scrollParentRef={props.scrollParentRef}
className={toolbarClassName}
/>
)}
</>
);
};
@@ -5,15 +5,26 @@ import { Trans } from '@lingui/react/macro';
type PdfViewerPageImageProps = {
imageLoadingState: ImageLoadingState;
/**
* Whether the page and everything drawn on it are ready to be shown.
*/
isPageReady: boolean;
imageProps: React.ImgHTMLAttributes<HTMLImageElement> & Record<string, unknown> & { alt: '' };
};
export const PdfViewerPageImage = ({ imageLoadingState, imageProps }: PdfViewerPageImageProps) => {
export const PdfViewerPageImage = ({ imageLoadingState, isPageReady, imageProps }: PdfViewerPageImageProps) => {
const isLoading = !isPageReady && imageLoadingState !== 'error';
return (
<>
{/* Loading State */}
{imageLoadingState === 'loading' && (
<div className="absolute inset-0 z-10 flex items-center justify-center text-muted-foreground opacity-20">
{isLoading && (
<div
className="absolute inset-0 z-10 flex items-center justify-center text-muted-foreground opacity-20"
data-testid="page-loader"
>
<Spinner />
</div>
)}
@@ -28,7 +39,12 @@ export const PdfViewerPageImage = ({ imageLoadingState, imageProps }: PdfViewerP
{/* The PDF image. */}
{imageProps.src && (
<img {...imageProps} className={cn(imageProps.className, 'select-none')} draggable={false} alt="" />
<img
{...imageProps}
className={cn(imageProps.className, 'select-none', !isPageReady && 'invisible')}
draggable={false}
alt=""
/>
)}
</>
);
@@ -0,0 +1,222 @@
import {
ENVELOPE_VIEWER_MAX_ZOOM,
ENVELOPE_VIEWER_MIN_ZOOM,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { cn } from '@documenso/ui/lib/utils';
import { Trans, useLingui } from '@lingui/react/macro';
import { EyeIcon, EyeOffIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react';
import { useLayoutEffect, useState } from 'react';
import type { ScrollTarget } from '../virtual-list/use-virtual-list';
/**
* The gap between the toolbar and the bottom of the visible viewer area.
*/
const TOOLBAR_BOTTOM_OFFSET = 24;
export type EnvelopePdfViewerToolbarControl = 'zoom' | 'fields' | 'contents';
export type EnvelopePdfViewerToolbarProps = {
/**
* The controls to display in the toolbar.
*/
controls?: EnvelopePdfViewerToolbarControl[];
/**
* The scroll container the toolbar is aligned against.
*
* The toolbar is fixed to the viewport, horizontally centered over the
* scroll container and pinned to the bottom of its visible area.
*/
scrollParentRef: ScrollTarget;
className?: string;
};
/**
* A floating toolbar for the envelope PDF viewer providing zoom and
* fields/contents visibility controls.
*
* Rendered with fixed positioning aligned to the viewer's scroll container,
* so it can be mounted anywhere within the viewer tree, including inside the
* scroll container itself.
*/
export const EnvelopePdfViewerToolbar = ({
controls = ['zoom'],
scrollParentRef,
className,
}: EnvelopePdfViewerToolbarProps) => {
const { t } = useLingui();
const { viewerControls } = useCurrentEnvelopeRender();
const {
zoom,
setZoom,
zoomIn,
zoomOut,
fieldsVisibility,
setFieldsVisibility,
contentsVisibility,
setContentsVisibility,
} = viewerControls;
const [position, setPosition] = useState<{ left: number; bottom: number } | null>(null);
/**
* Keep the toolbar aligned with the visible area of the scroll container.
*/
useLayoutEffect(() => {
// The toolbar renders inside the scroll container, so the container's ref
// is not attached yet when this first runs. It is therefore observed as
// soon as measuring finds it, which also covers it being replaced.
let observedScrollEl: HTMLElement | null = null;
function measure() {
if (scrollParentRef === 'window') {
setPosition({
left: window.innerWidth / 2,
bottom: TOOLBAR_BOTTOM_OFFSET,
});
return;
}
const scrollEl = scrollParentRef.current;
if (!scrollEl) {
setPosition(null);
return;
}
if (observedScrollEl !== scrollEl) {
if (observedScrollEl) {
resizeObserver.unobserve(observedScrollEl);
}
resizeObserver.observe(scrollEl);
observedScrollEl = scrollEl;
}
const rect = scrollEl.getBoundingClientRect();
const visibleLeft = Math.max(0, rect.left);
const visibleRight = Math.min(window.innerWidth, rect.right);
const visibleBottom = Math.min(window.innerHeight, rect.bottom);
setPosition({
left: visibleLeft + (visibleRight - visibleLeft) / 2,
bottom: window.innerHeight - visibleBottom + TOOLBAR_BOTTOM_OFFSET,
});
}
const resizeObserver = new ResizeObserver(() => measure());
measure();
window.addEventListener('resize', measure);
// Covers viewport and page level layout changes, and delivers an initial
// notification which picks up the scroll container once it is attached.
resizeObserver.observe(document.body);
return () => {
window.removeEventListener('resize', measure);
resizeObserver.disconnect();
};
}, [scrollParentRef]);
const showZoom = controls.includes('zoom');
const showFieldsToggle = controls.includes('fields');
const showContentsToggle = controls.includes('contents');
const isFieldsHidden = fieldsVisibility === 'hidden';
const isContentsHidden = contentsVisibility === 'hidden';
if (!position) {
return null;
}
return (
<div
className={cn(
'fixed z-40 flex w-fit -translate-x-1/2 items-center gap-x-0.5 rounded-xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-black/10 dark:ring-white/10',
className,
)}
style={{
left: position.left,
bottom: position.bottom,
}}
>
{showZoom && (
<>
<button
type="button"
title={t`Zoom out`}
disabled={zoom <= ENVELOPE_VIEWER_MIN_ZOOM}
onClick={zoomOut}
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
>
<ZoomOutIcon className="h-4 w-4" />
</button>
<button
type="button"
title={t`Reset zoom`}
onClick={() => setZoom(1)}
className="min-w-12 rounded-md px-1 py-1.5 text-center text-muted-foreground text-xs tabular-nums transition-colors hover:bg-muted hover:text-foreground"
>
{Math.round(zoom * 100)}%
</button>
<button
type="button"
title={t`Zoom in`}
disabled={zoom >= ENVELOPE_VIEWER_MAX_ZOOM}
onClick={zoomIn}
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
>
<ZoomInIcon className="h-4 w-4" />
</button>
</>
)}
{showZoom && (showFieldsToggle || showContentsToggle) && <div className="mx-1 h-5 w-px bg-border" />}
{showFieldsToggle && (
<button
type="button"
title={isFieldsHidden ? t`Show fields` : t`Hide fields`}
onClick={() => setFieldsVisibility(isFieldsHidden ? 'visible' : 'hidden')}
className={cn(
'flex items-center gap-x-1.5 rounded-md px-2 py-1.5 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground',
{
'text-muted-foreground/60': isFieldsHidden,
},
)}
>
{isFieldsHidden ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
<Trans>Fields</Trans>
</button>
)}
{showContentsToggle && (
<button
type="button"
title={isContentsHidden ? t`Show contents` : t`Hide contents`}
onClick={() => setContentsVisibility(isContentsHidden ? 'visible' : 'hidden')}
className={cn(
'flex items-center gap-x-1.5 rounded-md px-2 py-1.5 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground',
{
'text-muted-foreground/60': isContentsHidden,
},
)}
>
{isContentsHidden ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
<Trans>Contents</Trans>
</button>
)}
</div>
);
};
@@ -5,10 +5,10 @@ import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import pMap from 'p-map';
import * as pdfjsLib from 'pdfjs-dist';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?url';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import pdfjsWorker from 'pdfjs-dist/legacy/build/pdf.worker.mjs?url';
import type React from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { ScrollTarget } from '../virtual-list/use-virtual-list';
import { useVirtualList } from '../virtual-list/use-virtual-list';
@@ -29,6 +29,16 @@ const LOW_RENDER_RESOLUTION = 1;
const HIGH_RENDER_RESOLUTION = 2;
const IDLE_RENDER_DELAY = 200;
/**
* The additional height of each virtual page item on top of the scaled page
* height.
*
* 32px for the page number text and margins (my-2 = 8px * 2 + text height ~16px)
* plus 2px so the page outline ring (drawn outside the page box) does not touch
* the neighbouring items.
*/
const PAGE_ITEM_EXTRA_HEIGHT = 34;
export type PDFViewerProps = {
className?: string;
@@ -52,6 +62,23 @@ export type PDFViewerProps = {
onDocumentLoad?: () => void;
/**
* The zoom factor to render the pages at.
*
* The rendered page width is `min(containerWidth, maxPageWidth) * zoom`,
* derived in the same render pass as the layout so zoom changes apply in a
* single paint without any intermediate layout shift.
*
* Values above 1 can overflow the container horizontally, so the scroll
* parent should allow horizontal scrolling.
*/
zoom?: number;
/**
* The maximum base width of a page before the zoom is applied.
*/
maxPageWidth?: number;
/**
* Additional component to render next to the image, such as a Konva canvas
* for rendering fields.
@@ -64,6 +91,8 @@ export default function PDFViewer({
data,
scrollParentRef,
onDocumentLoad,
zoom = 1,
maxPageWidth,
customPageRenderer,
...props
}: PDFViewerProps) {
@@ -213,6 +242,8 @@ export default function PDFViewer({
numPages={pages.length}
pages={pages}
pdf={pdfRef.current}
zoom={zoom}
maxPageWidth={maxPageWidth}
customPageRenderer={customPageRenderer}
/>
)}
@@ -226,6 +257,8 @@ type VirtualizedPageListProps = {
pages: PageMeta[];
numPages: number;
pdf: pdfjsLib.PDFDocumentProxy;
zoom: number;
maxPageWidth?: number;
customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>;
};
@@ -235,6 +268,8 @@ const VirtualizedPageList = ({
pages,
numPages,
pdf,
zoom,
maxPageWidth,
customPageRenderer,
}: VirtualizedPageListProps) => {
const contentRef = useRef<HTMLDivElement>(null);
@@ -244,22 +279,81 @@ const VirtualizedPageList = ({
constraintRef,
contentRef,
itemCount: numPages,
itemSize: (index, width) => {
const pageMeta = pages[index];
// Calculate height based on aspect ratio and available width
const aspectRatio = pageMeta.height / pageMeta.width;
const scaledHeight = width * aspectRatio;
// Add 32px for the page number text and margins (my-2 = 8px * 2 + text height ~16px)
// Add additional 2px for the top and bottom borders.
return scaledHeight + 32 + 2;
},
itemSize: (index, width) => getPageItemSize(pages[index], getDisplayWidth(width, zoom, maxPageWidth)),
overscan: 5,
});
/**
* The width the pages are rendered at.
*
* Derived from the measured available width in the same render pass as the
* zoom, so zoom changes update the page layout and page sizes within a
* single commit, avoiding intermediate layout shifts.
*/
const displayWidth = getDisplayWidth(constraintWidth, zoom, maxPageWidth);
useScrollToPage(contentRef, scrollToItem);
const previousDisplayWidthRef = useRef(displayWidth);
/**
* Anchor the scroll position to the bottom center when the rendered page width
* changes (zoom or container resize) so zooming feels centered on the middle of the visible
* area instead of the top left of the content.
*/
useLayoutEffect(() => {
const previousDisplayWidth = previousDisplayWidthRef.current;
previousDisplayWidthRef.current = displayWidth;
if (previousDisplayWidth === displayWidth || previousDisplayWidth === 0 || displayWidth === 0) {
return;
}
const contentEl = contentRef.current;
const scrollEl = scrollParentRef === 'window' ? document.scrollingElement : scrollParentRef.current;
if (!contentEl || !scrollEl) {
return;
}
const viewportHeight = scrollParentRef === 'window' ? window.innerHeight : scrollEl.clientHeight;
// The offset of the content element from the top of the scrollable content.
// Only depends on the content above the page list, which is unaffected by zoom.
const contentOffsetTop =
contentEl.getBoundingClientRect().top -
(scrollParentRef === 'window' ? 0 : scrollEl.getBoundingClientRect().top) +
scrollEl.scrollTop;
const oldMetrics = computePageMetrics(pages, previousDisplayWidth);
const newMetrics = computePageMetrics(pages, displayWidth);
// The content-space Y coordinate currently at the vertical center of the viewport.
const oldCenterY = Math.min(
Math.max(0, scrollEl.scrollTop + viewportHeight / 2 - contentOffsetTop),
oldMetrics.totalSize,
);
// Locate which page the center point is on, and how far through it.
let pageIndex = 0;
for (let i = 0; i < pages.length; i += 1) {
if (oldMetrics.offsets[i] <= oldCenterY) {
pageIndex = i;
} else {
break;
}
}
const pageFraction =
oldMetrics.sizes[pageIndex] > 0 ? (oldCenterY - oldMetrics.offsets[pageIndex]) / oldMetrics.sizes[pageIndex] : 0;
const newCenterY = newMetrics.offsets[pageIndex] + pageFraction * newMetrics.sizes[pageIndex];
scrollEl.scrollTop = Math.max(0, newCenterY + contentOffsetTop - viewportHeight / 2);
scrollEl.scrollLeft = Math.max(0, (scrollEl.scrollWidth - scrollEl.clientWidth) / 2);
}, [displayWidth, pages, scrollParentRef]);
return (
<div
ref={contentRef}
@@ -268,7 +362,11 @@ const VirtualizedPageList = ({
data-page-count={numPages}
style={{
height: `${totalSize}px`,
width: '100%',
width: displayWidth > 0 ? `${displayWidth}px` : '100%',
// Center the pages when they fit within the container, while safely
// falling back to a start alignment when they overflow so the scroll
// container can reach all of the content.
margin: '0 auto',
position: 'relative',
}}
>
@@ -277,11 +375,11 @@ const VirtualizedPageList = ({
const pageMeta = pages[index];
const pageNumber = index + 1;
// Calculate scale based on constraint width
const scale = constraintWidth / pageMeta.width;
// Calculate scale based on the rendered page width.
const scale = displayWidth / pageMeta.width;
const scaledWidth = Math.floor(pageMeta.width * scale);
const scaledHeight = Math.floor(pageMeta.height * scale);
const scaledWidth = displayWidth;
const scaledHeight = Math.round(pageMeta.height * scale);
return (
<div
@@ -290,7 +388,7 @@ const VirtualizedPageList = ({
position: 'absolute',
top: 0,
left: 0,
width: constraintWidth,
width: displayWidth,
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
@@ -306,7 +404,12 @@ const VirtualizedPageList = ({
customPageRenderer={customPageRenderer}
/>
<p className="my-2 text-center text-[11px] text-muted-foreground/80">
<p
className={cn('my-2 text-center text-[11px] text-muted-foreground/80', {
// Allocate room for the floating viewer toolbar.
'pb-20': index === numPages - 1,
})}
>
<Trans>
Page {pageNumber} of {numPages}
</Trans>
@@ -318,6 +421,54 @@ const VirtualizedPageList = ({
);
};
/**
* The width pages are rendered at for a given available width, zoom and
* maximum base page width.
*/
const getDisplayWidth = (constraintWidth: number, zoom: number, maxPageWidth?: number) => {
if (constraintWidth === 0) {
return 0;
}
const baseWidth = maxPageWidth !== undefined ? Math.min(constraintWidth, maxPageWidth) : constraintWidth;
return Math.floor(baseWidth * zoom);
};
/**
* The height of a single virtual list item for a page rendered at the given
* width: the page height scaled to that width plus the page number footer.
*
* Single source of truth for both the virtual list `itemSize` and
* `computePageMetrics`, so the two can never drift apart.
*/
const getPageItemSize = (pageMeta: PageMeta, displayWidth: number) => {
const aspectRatio = pageMeta.height / pageMeta.width;
return displayWidth * aspectRatio + PAGE_ITEM_EXTRA_HEIGHT;
};
/**
* Compute the virtual list offsets and sizes of every page for a given
* rendered page width.
*/
const computePageMetrics = (pages: PageMeta[], displayWidth: number) => {
const offsets: number[] = [];
const sizes: number[] = [];
let totalSize = 0;
for (const pageMeta of pages) {
const size = getPageItemSize(pageMeta, displayWidth);
offsets.push(totalSize);
sizes.push(size);
totalSize += size;
}
return { offsets, sizes, totalSize };
};
type PdfViewerPageProps = {
pageNumber: number;
pdf: pdfjsLib.PDFDocumentProxy;
@@ -349,8 +500,19 @@ const PdfViewerPage = ({
scale,
});
/**
* A custom page renderer may have to load things of its own before the page
* can be drawn, which it can only begin once the page image is ready. The
* page image is held back until then so both appear at once.
*/
const [isCustomRendererReady, setIsCustomRendererReady] = useState(false);
const isPageReady = imageLoadingState === 'loaded' && (!CustomPageRenderer || isCustomRendererReady);
return (
<div className="relative w-full rounded border border-border" style={{ width: scaledWidth, height: scaledHeight }}>
// Must use ring instead of border since borders take up space inside the box,
// which shrinks the page image (constrained to the box by `max-width: 100%`)
<div className="relative w-full rounded ring-1 ring-border" style={{ width: scaledWidth, height: scaledHeight }}>
{CustomPageRenderer && imageLoadingState === 'loaded' && (
<CustomPageRenderer
pageData={{
@@ -360,11 +522,12 @@ const PdfViewerPage = ({
pageWidth: unscaledWidth,
pageHeight: unscaledHeight,
imageLoadingState,
onReadyChange: setIsCustomRendererReady,
}}
/>
)}
<PdfViewerPageImage imageLoadingState={imageLoadingState} imageProps={imageProps} />
<PdfViewerPageImage imageLoadingState={imageLoadingState} isPageReady={isPageReady} imageProps={imageProps} />
</div>
);
};
@@ -502,8 +665,16 @@ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }:
const imageProps = useMemo(
(): React.ImgHTMLAttributes<HTMLImageElement> & Record<string, unknown> & { alt: '' } => ({
className: PDF_VIEWER_PAGE_CLASSNAME,
width: Math.floor(scaledWidth),
height: Math.floor(scaledHeight),
width: scaledWidth,
height: scaledHeight,
// Pin the rendered size to the page size. Tailwind's preflight applies
// `max-width: 100%; height: auto` to images, which would otherwise let
// the container clamp the image out of alignment with the page overlay.
style: {
width: scaledWidth,
height: scaledHeight,
maxWidth: 'none',
},
alt: '',
onLoad: () => setImageLoadingState('loaded'),
onError: () => setImageLoadingState('error'),
@@ -209,7 +209,6 @@ export const TemplatesTable = ({
<div className="flex items-center gap-x-4">
<TemplateUseDialog
envelopeId={row.original.envelopeId}
templateId={row.original.id}
templateSigningOrder={row.original.templateMeta?.signingOrder}
documentDistributionMethod={row.original.templateMeta?.distributionMethod}
recipients={row.original.recipients}
+1 -1
View File
@@ -149,7 +149,7 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
<style
nonce={nonce(cspNonce)}
dangerouslySetInnerHTML={{
__html: `*, *::before, *::after { animation: none !important; transition: none !important; }`,
__html: `*, *::before, *::after { animation: none !important; transition-duration: 0.001s !important; transition-delay: 0s !important; }`,
}}
/>
)}
@@ -595,6 +595,8 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
memberCount: organisation.organisationClaim.memberCount,
envelopeItemCount: organisation.organisationClaim.envelopeItemCount,
recipientCount: organisation.organisationClaim.recipientCount,
envelopeContentCount: organisation.organisationClaim.envelopeContentCount,
envelopeContentImageCount: organisation.organisationClaim.envelopeContentImageCount,
flags: organisation.organisationClaim.flags,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
documentRateLimits: organisation.organisationClaim.documentRateLimits as NonNullable<
@@ -820,6 +822,54 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.envelopeContentCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Content Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of contents per envelope allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.envelopeContentImageCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Content Image Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of image contents per envelope allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div>
@@ -30,6 +30,13 @@ export default function Layout() {
(organisation.subscription && organisation.subscription.status === SubscriptionStatus.INACTIVE) ||
isOrganisationPendingPayment(organisation);
// The content allowances come straight from the organisation's claim, as
// they are already known on the client.
const contentLimits = {
maximumEnvelopeContentCount: organisation.organisationClaim.envelopeContentCount,
maximumEnvelopeContentImageCount: organisation.organisationClaim.envelopeContentImageCount,
};
if (isRestricted) {
return {
quota: {
@@ -43,6 +50,7 @@ export default function Layout() {
directTemplates: 0,
},
maximumEnvelopeItemCount: 0,
...contentLimits,
};
}
@@ -50,6 +58,7 @@ export default function Layout() {
quota: PAID_PLAN_LIMITS,
remaining: PAID_PLAN_LIMITS,
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
...contentLimits,
};
}, [organisation]);
@@ -160,6 +160,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
envelopeItems={envelope.envelopeItems}
token={undefined}
fields={envelope.fields}
contents={envelope.contents}
signatures={fieldSignatures}
recipients={envelope.recipients}
overrideSettings={{
@@ -43,6 +43,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw new Response('Not Found', { status: 404 });
}
if (document.internalVersion !== 1) {
throw redirect(`${documentRootPath}/${document.envelopeId}/edit`);
}
const documentVisibility = document.visibility;
const currentTeamMemberRole = team.currentTeamRole;
const isRecipient = document.recipients.find((recipient) => recipient.email === user.email);
@@ -176,6 +176,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
envelopeItems={envelope.envelopeItems}
token={undefined}
fields={envelope.fields}
contents={envelope.contents}
recipients={envelope.recipients}
overrideSettings={{
showRecipientTooltip: true,
@@ -255,7 +256,6 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
<div className="mt-4 border-t px-4 pt-4">
<TemplateUseDialog
envelopeId={envelope.id}
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
templateSigningOrder={envelope.documentMeta?.signingOrder}
recipients={envelope.recipients}
documentRootPath={documentRootPath}
@@ -42,6 +42,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw redirect(templateRootPath);
}
if (template.internalVersion !== 1) {
throw redirect(`${templateRootPath}/${template.envelopeId}/edit`);
}
return superLoaderJson({
template: {
...template,
@@ -15,7 +15,10 @@ import { redirect } from 'react-router';
import { match } from 'ts-pattern';
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { DirectTemplateInvalidPageView } from '~/components/general/direct-template/direct-template-invalid-page';
import {
DirectTemplateInvalidPageView,
type DirectTemplateInvalidReason,
} from '~/components/general/direct-template/direct-template-invalid-page';
import { DirectTemplatePageView } from '~/components/general/direct-template/direct-template-page';
import { DirectTemplateAuthPageView } from '~/components/general/direct-template/direct-template-signing-auth-page';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
@@ -77,13 +80,13 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
if (recipientsWithMissingFields.length > 0) {
return {
isAccessAuthValid: true,
isTemplateMissingSignatures: true,
invalidReason: 'MISSING_SIGNATURE_FIELD',
} as const;
}
return {
isAccessAuthValid: true,
isTemplateMissingSignatures: false,
invalidReason: null,
template: {
...template,
folder: null,
@@ -108,7 +111,7 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
.then((envelopeForSigning) => {
return {
isDocumentAccessValid: true,
isTemplateMissingSignatures: false,
invalidReason: null,
envelopeForSigning,
} as const;
})
@@ -121,10 +124,18 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
} as const;
}
if (error.code === AppErrorCode.MISSING_SIGNATURE_FIELD) {
// Reasons the template renders but cannot be submitted, reported up
// front rather than once the signer has filled everything in.
const invalidReason = match(error.code)
.returnType<DirectTemplateInvalidReason | null>()
.with(AppErrorCode.MISSING_SIGNATURE_FIELD, () => 'MISSING_SIGNATURE_FIELD')
.with('MISSING_CONTENT_IMAGE', () => 'MISSING_CONTENT_IMAGE')
.otherwise(() => null);
if (invalidReason) {
return {
isDocumentAccessValid: true,
isTemplateMissingSignatures: true,
invalidReason,
} as const;
}
@@ -201,8 +212,8 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
return <DirectTemplateAuthPageView />;
}
if (data.isTemplateMissingSignatures) {
return <DirectTemplateInvalidPageView />;
if (data.invalidReason) {
return <DirectTemplateInvalidPageView reason={data.invalidReason} />;
}
const { template, directTemplateRecipient } = data;
@@ -259,8 +270,8 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
return <DocumentSigningAuthPageView email={''} emailHasAccount={true} />;
}
if (data.isTemplateMissingSignatures) {
return <DirectTemplateInvalidPageView />;
if (data.invalidReason) {
return <DirectTemplateInvalidPageView reason={data.invalidReason} />;
}
const { envelope, recipient } = data.envelopeForSigning;
@@ -283,6 +294,7 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
contents={envelope.contents}
token={recipient.token}
>
<DocumentSigningPageViewV2 />
@@ -93,22 +93,23 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
})
: [recipient];
if (
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL &&
recipient.role !== RecipientRole.ASSISTANT
) {
const nextPendingRecipient = await getNextPendingRecipient({
documentId: document.id,
currentRecipientId: recipient.id,
});
// Dictation eligibility must be decided here, over the FULL recipient list
// — the same computation the completion route enforces. `allRecipients` is
// role-scoped (assistants only see strictly later steps, not their own
// group peers), so deriving it client-side from that list would offer
// dictation the server then silently ignores.
const nextPendingRecipient =
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL
? await getNextPendingRecipient({
documentId: document.id,
currentRecipientId: recipient.id,
})
: null;
if (nextPendingRecipient) {
allRecipients.push({
...nextPendingRecipient,
fields: [],
});
}
}
// Only the identity is needed client-side (dictation flag + prefill).
const nextRecipient = nextPendingRecipient
? { name: nextPendingRecipient.name, email: nextPendingRecipient.email }
: null;
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
@@ -170,6 +171,7 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
recipient,
recipientWithFields,
allRecipients,
nextRecipient,
completedFields,
recipientSignature,
isRecipientsTurn,
@@ -414,6 +416,7 @@ const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loade
recipientSignature,
isRecipientsTurn,
allRecipients,
nextRecipient,
includeSenderDetails,
branding,
recipientWithFields,
@@ -486,6 +489,7 @@ const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loade
completedFields={completedFields}
isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients}
nextRecipient={nextRecipient ?? undefined}
includeSenderDetails={includeSenderDetails}
branding={branding}
/>
@@ -577,6 +581,7 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
contents={envelope.contents}
token={recipient.token}
>
<DocumentSigningPageViewV2 />
@@ -358,6 +358,7 @@ const EmbedDirectTemplatePageV2 = ({ data }: { data: Awaited<ReturnType<typeof h
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
contents={envelope.contents}
token={recipient.token}
>
<EmbedSignDocumentV2ClientPage
@@ -19,7 +19,7 @@ import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { isRecipientExpired } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { RecipientRole } from '@prisma/client';
import { RecipientRole, SigningStatus } from '@prisma/client';
import { data } from 'react-router';
import { match } from 'ts-pattern';
@@ -80,7 +80,11 @@ async function handleV1Loader({ params, request }: Route.LoaderArgs) {
);
}
if (isRecipientExpired(recipient)) {
const isCompleted = recipient.signingStatus === SigningStatus.SIGNED || isDocumentCompleted(document.status);
const isRejected = recipient.signingStatus === SigningStatus.REJECTED;
const hasRecipientActioned = isCompleted || isRejected;
if (!hasRecipientActioned && isRecipientExpired(recipient)) {
throw data(
{
type: 'embed-recipient-expired',
@@ -115,7 +119,7 @@ async function handleV1Loader({ params, request }: Route.LoaderArgs) {
);
}
const isRecipientsTurnToSign = await getIsRecipientsTurnToSign({ token });
const isRecipientsTurnToSign = hasRecipientActioned || (await getIsRecipientsTurnToSign({ token }));
if (!isRecipientsTurnToSign) {
throw data(
@@ -173,6 +177,8 @@ async function handleV1Loader({ params, request }: Route.LoaderArgs) {
recipient,
fields,
completedFields,
isCompleted,
isRejected,
hidePoweredBy,
allowEmbedSigningWhitelabel,
};
@@ -392,6 +398,8 @@ const EmbedSignDocumentPageV1 = ({ data }: { data: Awaited<ReturnType<typeof han
recipient,
fields,
completedFields,
isCompleted,
isRejected,
hidePoweredBy,
allowEmbedSigningWhitelabel,
} = data;
@@ -415,7 +423,8 @@ const EmbedSignDocumentPageV1 = ({ data }: { data: Awaited<ReturnType<typeof han
fields={fields}
completedFields={completedFields}
metadata={document.documentMeta}
isCompleted={isDocumentCompleted(document.status)}
isCompleted={isCompleted}
isRejected={isRejected}
hidePoweredBy={hidePoweredBy}
allowWhitelabelling={allowEmbedSigningWhitelabel}
allRecipients={allRecipients}
@@ -442,6 +451,7 @@ const EmbedSignDocumentPageV2 = ({ data }: { data: Awaited<ReturnType<typeof han
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
contents={envelope.contents}
token={token}
>
<EmbedSignDocumentV2ClientPage
@@ -50,6 +50,7 @@ export default function EmbedPlaygroundPage() {
allowConfigureEnvelopeTitle: true,
allowUploadAndRecipientStep: true,
allowAddFieldsStep: true,
allowAddContentsStep: true,
allowPreviewStep: true,
minimizeLeftSidebar: true,
});
@@ -149,6 +149,8 @@ export default function AuthoringLayout() {
quota: PAID_PLAN_LIMITS,
remaining: PAID_PLAN_LIMITS,
maximumEnvelopeItemCount: organisationClaim.envelopeItemCount,
maximumEnvelopeContentCount: organisationClaim.envelopeContentCount,
maximumEnvelopeContentImageCount: organisationClaim.envelopeContentImageCount,
}}
teamId={team.id}
>
@@ -13,7 +13,11 @@ import {
import type { TEnvelopeFieldAndMeta } from '@documenso/lib/types/field-meta';
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
import { extractDerivedDocumentMeta } from '@documenso/lib/utils/document';
import { buildEmbeddedEditorOptions, buildEmbeddedFeatures } from '@documenso/lib/utils/embed-config';
import {
buildEmbeddedEditorOptions,
buildEmbeddedFeatures,
getPendingEmbedImagesToUpload,
} from '@documenso/lib/utils/embed-config';
import { prisma } from '@documenso/prisma';
import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
@@ -172,7 +176,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
const buildCreateEnvelopeRequest = (
envelope: Omit<TEditorEnvelope, 'id'>,
): { payload: TCreateEnvelopePayload; files: File[] } => {
): { payload: TCreateEnvelopePayload; files: File[]; contentImages: File[] } => {
const sortedItems = [...envelope.envelopeItems].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const itemIdToIndex = new Map<string, number>();
@@ -224,6 +228,26 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
};
});
const imagesToUpload = getPendingEmbedImagesToUpload(envelope.contents);
const contents = envelope.contents.map((content) => {
const image = imagesToUpload.find((imageToUpload) => imageToUpload.id === content.dataContentId);
// Nothing is uploaded before the envelope exists, so every image must
// go along with the request.
if (content.dataContentId && !image) {
throw new Error(`Content "${content.id}" has no image data`);
}
return {
identifier: itemIdToIndex.get(content.envelopeItemId),
contentMeta: content.contentMeta,
imageIndex: image?.index,
};
});
const contentImages = imagesToUpload.map((image) => image.file);
const payload: TCreateEnvelopePayload = {
title: envelope.title,
type: envelope.type,
@@ -237,6 +261,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
: undefined,
folderId: envelope.folderId ?? undefined,
recipients,
contents,
attachments: envelope.attachments,
meta: {
...envelope.documentMeta,
@@ -255,7 +280,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
},
};
return { payload, files };
return { payload, files, contentImages };
};
const createEmbeddedEnvelope = async (envelopeWithoutId: Omit<TEditorEnvelope, 'id'>) => {
@@ -266,7 +291,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
setIsCreatingEnvelope(true);
try {
const { payload, files } = buildCreateEnvelopeRequest(envelopeWithoutId);
const { payload, files, contentImages } = buildCreateEnvelopeRequest(envelopeWithoutId);
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
@@ -275,6 +300,10 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
formData.append('files', file);
}
for (const contentImage of contentImages) {
formData.append('contentImages', contentImage);
}
const { id } = await createEmbeddingEnvelope(formData);
// Send a message to the parent window with the document details
@@ -377,6 +406,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
},
recipients,
fields: [],
contents: [],
envelopeItems: [],
directLink: null,
team: {
@@ -397,7 +427,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
return (
<div className="relative min-h-screen min-w-screen">
{isCreatingEnvelope && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-background">
<Spinner />
<p className="mt-2 text-muted-foreground text-sm">
@@ -411,7 +441,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
)}
{createdEnvelope && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
<div className="absolute inset-0 z-50 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" />
@@ -11,7 +11,11 @@ import {
ZEmbedEditEnvelopeAuthoringSchema,
} from '@documenso/lib/types/envelope-editor';
import type { TEnvelopeFieldAndMeta } from '@documenso/lib/types/field-meta';
import { buildEmbeddedEditorOptions, PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
import {
buildEmbeddedEditorOptions,
getPendingEmbedImagesToUpload,
PRESIGNED_ENVELOPE_ITEM_ID_PREFIX,
} from '@documenso/lib/utils/embed-config';
import { prisma } from '@documenso/prisma';
import { trpc } from '@documenso/trpc/react';
import type { TUpdateEmbeddingEnvelopePayload } from '@documenso/trpc/server/embedding-router/update-embedding-envelope.types';
@@ -173,7 +177,7 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
const buildUpdateEnvelopeRequest = (
envelope: TEditorEnvelope,
): { payload: TUpdateEmbeddingEnvelopePayload; files: File[] } => {
): { payload: TUpdateEmbeddingEnvelopePayload; files: File[]; contentImages: File[] } => {
const files: File[] = [];
const envelopeItems = envelope.envelopeItems.map((item) => {
@@ -227,6 +231,24 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
};
});
// Removes duplicate content images, and gets the files to upload.
const imagesToUpload = getPendingEmbedImagesToUpload(envelope.contents);
const contentImages = imagesToUpload.map((image) => image.file);
const contents = envelope.contents.map((content) => {
const image = imagesToUpload.find((imageToUpload) => imageToUpload.id === content.dataContentId);
// A new image is referenced by its index, an uploaded one by its ID.
return {
id: content.id,
envelopeItemId: content.envelopeItemId,
contentMeta: content.contentMeta,
dataContentId: image ? null : content.dataContentId,
imageIndex: image?.index,
};
});
const payload: TUpdateEmbeddingEnvelopePayload = {
envelopeId: envelope.id,
data: {
@@ -239,6 +261,7 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
recipients,
envelopeItems,
attachments: envelope.attachments,
contents,
},
meta: {
...envelope.documentMeta,
@@ -257,7 +280,7 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
},
};
return { payload, files };
return { payload, files, contentImages };
};
const updateEmbeddedEnvelope = async (envelope: TEditorEnvelope) => {
@@ -268,7 +291,7 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
setIsUpdatingEnvelope(true);
try {
const { payload, files } = buildUpdateEnvelopeRequest(envelope);
const { payload, files, contentImages } = buildUpdateEnvelopeRequest(envelope);
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
@@ -277,6 +300,10 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
formData.append('files', file);
}
for (const contentImage of contentImages) {
formData.append('contentImages', contentImage);
}
await updateEmbeddingEnvelope(formData);
// Send a message to the parent window with the document details
@@ -332,7 +359,7 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
return (
<div className="relative min-h-screen min-w-screen">
{isUpdatingEnvelope && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-background">
<Spinner />
<p className="mt-2 text-muted-foreground text-sm">
@@ -346,7 +373,7 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
)}
{updatedEnvelope && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
<div className="absolute inset-0 z-50 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" />
+26 -1
View File
@@ -18,10 +18,28 @@ export const FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE = {
description: msg`Your organisation has reached its plan's fair use limit. Please contact your organisation administrator or support to continue.`,
};
const MISSING_CONTENT_IMAGE_ERROR_MESSAGE: ToastMessageDescriptor = {
title: msg`Missing images`,
description: msg`One or more image contents have no image. Upload an image for each of them, or remove them.`,
};
const ENVELOPE_CONTENT_LIMIT_EXCEEDED_ERROR_MESSAGE: ToastMessageDescriptor = {
title: msg`Envelope content limit exceeded`,
description: msg`This envelope has too many content items. Please remove some, or contact support if you need more.`,
};
const ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED_ERROR_MESSAGE: ToastMessageDescriptor = {
title: msg`Envelope image content limit exceeded`,
description: msg`This envelope has too many image content items. Please remove some, or contact support if you need more.`,
};
export const getDistributeErrorMessage = (code: string): ToastMessageDescriptor => {
return match(code)
.with('RECIPIENT_LIMIT_EXCEEDED', () => RECIPIENT_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with('MISSING_CONTENT_IMAGE', () => MISSING_CONTENT_IMAGE_ERROR_MESSAGE)
.with('ENVELOPE_CONTENT_LIMIT_EXCEEDED', () => ENVELOPE_CONTENT_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with('ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED', () => ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED_ERROR_MESSAGE)
.otherwise(() => ({
title: msg`Something went wrong`,
description: msg`An error occurred while distributing the document.`,
@@ -36,6 +54,9 @@ export const getDirectTemplateErrorMessage = (code: string): ToastMessageDescrip
title: msg`Missing signature fields`,
description: msg`This direct link template cannot be used because one or more signers do not have a signature field assigned.`,
}))
.with('MISSING_CONTENT_IMAGE', () => MISSING_CONTENT_IMAGE_ERROR_MESSAGE)
.with('ENVELOPE_CONTENT_LIMIT_EXCEEDED', () => ENVELOPE_CONTENT_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with('ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED', () => ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED_ERROR_MESSAGE)
.otherwise(() => ({
title: msg`Something went wrong`,
description: msg`We were unable to submit this document at this time. Please try again later.`,
@@ -92,7 +113,7 @@ export const getUploadErrorMessage = (code: string): ToastMessageDescriptor => {
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with('INVALID_DOCUMENT_FILE', () => ({
title: msg`Error`,
description: msg`You cannot upload encrypted PDFs.`,
description: msg`The file is not a valid PDF or is password protected.`,
}))
.with(AppErrorCode.LIMIT_EXCEEDED, () => ({
title: msg`Error`,
@@ -130,6 +151,10 @@ export const getTemplateUseErrorMessage = (code: string): ToastMessageDescriptor
title: msg`Missing signature fields`,
description: msg`The document could not be sent because some signers do not have a signature field. Please edit the template and add a signature field for each signer.`,
}))
.with('MISSING_CONTENT_IMAGE', () => ({
title: msg`Missing images`,
description: msg`The document could not be sent because one or more image contents have no image. Please edit the template and upload an image for each of them, or remove them.`,
}))
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => ({
title: msg`Error`,
description: msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`,
+1 -1
View File
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.18.0"
"version": "2.19.0"
}
@@ -234,9 +234,16 @@ const handlePendingFileRequest = async ({
return c.json({ error: 'File not found' }, 404);
}
const contents = await prisma.envelopeContent.findMany({
where: {
envelopeItemId,
},
});
const pdf = await generatePartialSignedPdf({
pdfData: file,
fields,
contents,
});
c.get('logger').info({
+6
View File
@@ -18,6 +18,8 @@ import {
ZGetEnvelopeItemFileTokenRequestParamsSchema,
ZUploadPdfRequestSchema,
} from './files.types';
import getDataContentImageRoute from './routes/get-data-content-image';
import getDataContentImageByTokenRoute from './routes/get-data-content-image-by-token';
import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf';
import getEnvelopeItemPdfByTokenRoute from './routes/get-envelope-item-pdf-by-token';
@@ -342,3 +344,7 @@ export const filesRoute = new Hono<HonoEnv>()
// Is different to the other file endpoints since it uses documentDataId for hard caching.
filesRoute.route('/', getEnvelopeItemPdfRoute);
filesRoute.route('/', getEnvelopeItemPdfByTokenRoute);
// Content image routes for both tokens and auth based, hard cached by dataContentId.
filesRoute.route('/', getDataContentImageRoute);
filesRoute.route('/', getDataContentImageByTokenRoute);
@@ -0,0 +1,74 @@
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import type { Prisma } from '@prisma/client';
import { Hono } from 'hono';
import { z } from 'zod';
import type { HonoEnv } from '../../../router';
import { handleDataContentImageRequest } from './get-data-content-image';
const route = new Hono<HonoEnv>();
const ZGetDataContentImageByTokenParamsSchema = z.object({
token: z.string().min(1),
envelopeId: z.string().min(1),
dataContentId: z.string().min(1),
});
/**
* Returns the image of a data content attached to a content of the envelope,
* using a recipient or QR token.
*/
route.get(
'/token/:token/envelope/:envelopeId/dataContent/:dataContentId/image',
sValidator('param', ZGetDataContentImageByTokenParamsSchema),
async (c) => {
const { token, envelopeId, dataContentId } = c.req.valid('param');
if (!token) {
return c.json({ error: 'Not found' }, 404);
}
// Recipient token based query.
let envelopeWhereQuery: Prisma.EnvelopeWhereInput = {
id: envelopeId,
recipients: {
some: {
token,
},
},
};
// QR token based query.
if (token.startsWith('qr_')) {
envelopeWhereQuery = {
id: envelopeId,
qrToken: token,
};
}
// Validate envelope access via a content the data content is attached to.
const envelopeContent = await prisma.envelopeContent.findFirst({
where: {
envelopeId,
dataContentId,
envelope: envelopeWhereQuery,
},
include: {
dataContent: true,
},
});
if (!envelopeContent?.dataContent) {
return c.json({ error: 'Not found' }, 404);
}
return await handleDataContentImageRequest({
c,
dataContent: envelopeContent.dataContent,
cacheStrategy: 'private',
});
},
);
export default route;
@@ -0,0 +1,156 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { DataContentType } from '@documenso/lib/types/data-content-meta';
import { sha256 } from '@documenso/lib/universal/crypto';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import type { DataContent } from '@prisma/client';
import { type Context, Hono } from 'hono';
import { z } from 'zod';
import type { HonoEnv } from '../../../router';
import { checkEnvelopeFileAccess } from '../files.helpers';
const route = new Hono<HonoEnv>();
const ZGetDataContentImageRequestParamsSchema = z.object({
envelopeId: z.string().min(1),
dataContentId: z.string().min(1),
});
const ZGetDataContentImageRequestQuerySchema = z.object({
presignToken: z.string().optional(),
});
/**
* Returns the image of a data content attached to a content of the envelope.
*/
route.get(
'/envelope/:envelopeId/dataContent/:dataContentId/image',
sValidator('param', ZGetDataContentImageRequestParamsSchema),
sValidator('query', ZGetDataContentImageRequestQuerySchema),
async (c) => {
const { envelopeId, dataContentId } = 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 envelopeContent = await prisma.envelopeContent.findFirst({
where: {
envelopeId,
dataContentId,
},
include: {
dataContent: true,
envelope: {
select: {
teamId: true,
type: true,
templateType: true,
},
},
},
});
if (!envelopeContent?.dataContent) {
return c.json({ error: 'Not found' }, 404);
}
const { dataContent, envelope } = envelopeContent;
// Check whether the user has access to this content via the envelope.
const hasAccess = await checkEnvelopeFileAccess({
userId,
teamId: envelope.teamId,
envelopeType: envelope.type,
templateType: envelope.templateType,
});
if (!hasAccess) {
return c.json({ error: 'Not found' }, 404);
}
return await handleDataContentImageRequest({
c,
dataContent,
cacheStrategy: 'private',
});
},
);
type HandleDataContentImageRequestOptions = {
c: Context<HonoEnv>;
dataContent: DataContent;
/**
* 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';
};
/**
* Serve the bytes of an image data content.
*
* Data contents are immutable, so the response is hard cached like envelope
* item PDFs. The content type comes from the stored metadata and is never
* sniffed from the bytes.
*/
export const handleDataContentImageRequest = async ({
c,
dataContent,
cacheStrategy,
}: HandleDataContentImageRequestOptions) => {
if (dataContent.metadata.type !== DataContentType.IMAGE) {
return c.json({ error: 'Not found' }, 404);
}
const etag = Buffer.from(sha256(dataContent.data)).toString('hex');
if (c.req.header('If-None-Match') === etag) {
return c.body(null, 304);
}
const file = await getFileServerSide({
type: dataContent.type,
data: dataContent.data,
}).catch((error) => {
console.error(error);
return null;
});
if (!file) {
return c.json({ error: 'Not found' }, 404);
}
// Note: Only set these headers on success.
c.header('Content-Type', dataContent.metadata.mimeType);
c.header('X-Content-Type-Options', 'nosniff');
c.header('ETag', etag);
c.header('Cache-Control', `${cacheStrategy}, max-age=31536000, immutable`);
return c.body(file);
};
export default route;
Binary file not shown.
+7 -7
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "2.18.0",
"version": "2.19.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "2.18.0",
"version": "2.19.0",
"hasInstallScript": true,
"workspaces": [
"apps/*",
@@ -15,7 +15,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "5.0.48",
"@documenso/prisma": "*",
"@libpdf/core": "^0.4.2",
"@libpdf/core": "^0.5.1",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@marsidev/react-turnstile": "^1.5.0",
@@ -193,7 +193,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "2.18.0",
"version": "2.19.0",
"dependencies": {
"@cantoo/pdf-lib": "^2.5.3",
"@documenso/api": "*",
@@ -4480,9 +4480,9 @@
"license": "MIT"
},
"node_modules/@libpdf/core": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.2.tgz",
"integrity": "sha512-lbkIqLDZCCxjLpiC+8/Xvaru/ME7iVVoihl9tLqbp/CDUWZNF0q3u7s2tBJB9wRW/SzUWID6YPFvBWws770hrQ==",
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.5.1.tgz",
"integrity": "sha512-q+y4AEk9ngqyC1pdX/hNScnfh0ROr4vSiqX4C9CmuBzhtuVukbfTesXCaGvHZ3pksi8AJYDPGQ/0HzSeHZfi3A==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^2.2.0",
+2 -2
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.18.0",
"version": "2.19.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -106,7 +106,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "5.0.48",
"@documenso/prisma": "*",
"@libpdf/core": "^0.4.2",
"@libpdf/core": "^0.5.1",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@prisma/extension-read-replicas": "^0.4.1",
+5 -4
View File
@@ -11,6 +11,7 @@ import {
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { ZRecipientSigningOrderSchema } from '@documenso/lib/types/recipient';
import { zEmail } from '@documenso/lib/utils/zod';
import {
DocumentDataType,
@@ -145,7 +146,7 @@ export const ZCreateDocumentMutationSchema = z.object({
name: z.string().min(1),
email: zEmail().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
signingOrder: z.number().nullish(),
signingOrder: ZRecipientSigningOrderSchema.nullish(),
}),
),
meta: z
@@ -235,7 +236,7 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
name: z.string().min(1),
email: zEmail().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
signingOrder: z.number().nullish(),
signingOrder: ZRecipientSigningOrderSchema.nullish(),
}),
),
meta: z
@@ -315,7 +316,7 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
id: z.number(),
email: zEmail(),
name: z.string().optional(),
signingOrder: z.number().optional(),
signingOrder: ZRecipientSigningOrderSchema.optional(),
}),
)
.refine(
@@ -389,7 +390,7 @@ export const ZCreateRecipientMutationSchema = z.object({
name: z.string().min(1),
email: zEmail().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
signingOrder: z.number().nullish(),
signingOrder: ZRecipientSigningOrderSchema.nullish(),
authOptions: z
.object({
actionAuth: z
@@ -0,0 +1,376 @@
import fs from 'node:fs';
import path from 'node:path';
import {
EnvelopeContentShapeType,
EnvelopeContentType,
type TContentHighlightMeta,
type TContentImageMeta,
type TContentShapeMeta,
type TContentTextMeta,
type TEnvelopeContentMetaInput,
} from '@documenso/lib/types/envelope-content-meta';
/**
* A content to seed for the visual regression test, laid out on the labelled
* grid of `assets/content-alignment.pdf` so placement can be read against the
* printed boxes. Each content type has its own page.
*
* The PDF is generated by `scripts/generate-content-alignment-test-pdf.mjs`.
* Keep `CONTENT_ALIGNMENT_GRID` and `CONTENT_ALIGNMENT_PAGES` in sync with
* the grid and page order there.
*/
export type ContentTestData = {
page: number;
contentMeta: TEnvelopeContentMetaInput;
/**
* An image to attach, for image contents.
*/
image?: 'logo';
};
export const CONTENT_ALIGNMENT_PDF = fs.readFileSync(path.join(__dirname, '../../../assets/content-alignment.pdf'));
/**
* The Documenso wordmark, wide (about 6.8:1) so fitting, centering and
* rotation are all visible.
*/
export const CONTENT_ALIGNMENT_LOGO_PNG = fs.readFileSync(path.join(__dirname, '../../../packages/assets/logo.png'));
export const CONTENT_ALIGNMENT_PAGES = {
text: 1,
line: 2,
shape: 3,
highlight: 4,
image: 5,
} as const;
/**
* The page is A4, so a square in page units needs the width scaled by this.
*/
const A4_ASPECT = 210 / 297;
/**
* The grid printed on every page of the PDF, as percentages of the page.
*/
export const CONTENT_ALIGNMENT_GRID = {
startX: 8,
startY: 12,
columnWidth: 28,
columnGap: 1,
rowHeight: 7,
rowGap: 1,
};
const cell = (row: number, column: number, { rows = 1, columns = 1 }: { rows?: number; columns?: number } = {}) => {
const { startX, startY, columnWidth, columnGap, rowHeight, rowGap } = CONTENT_ALIGNMENT_GRID;
return {
positionX: startX + column * (columnWidth + columnGap),
positionY: startY + row * (rowHeight + rowGap),
width: columns * columnWidth + (columns - 1) * columnGap,
height: rows * rowHeight + (rows - 1) * rowGap,
};
};
type Box = ReturnType<typeof cell>;
/**
* A box half the size of a cell, placed at the cell's center.
*
* Contents rotate about their top left corner (see `ZContentRotationSchema`),
* so a rotated box placed at a cell's top left would sweep outside the cell.
* Starting from the center keeps every rotation within the cell.
*/
const halfCell = (row: number, column: number, options: { rows?: number; columns?: number } = {}) => {
const box = cell(row, column, options);
return {
positionX: box.positionX + box.width / 2,
positionY: box.positionY + box.height / 2,
width: box.width / 2,
height: box.height / 2,
};
};
/**
* A square box in page units, as tall as the given number of rows.
*/
const square = (row: number, column: number, rows: number) => {
const box = cell(row, column, { rows });
return { ...box, width: box.height * A4_ASPECT };
};
// --- Builders ---
type Overrides<T> = Partial<Omit<T, 'type'>>;
const text = (box: Box, meta: Overrides<TContentTextMeta> & { text: string }): ContentTestData => ({
page: CONTENT_ALIGNMENT_PAGES.text,
contentMeta: { type: EnvelopeContentType.TEXT, page: CONTENT_ALIGNMENT_PAGES.text, rotation: 0, ...box, ...meta },
});
type LinePoints = { x1: number; y1: number; x2: number; y2: number };
const horizontal = (box: Box): LinePoints => ({
x1: box.positionX,
x2: box.positionX + box.width,
y1: box.positionY + box.height / 2,
y2: box.positionY + box.height / 2,
});
const line = (
points: LinePoints,
meta: { strokeWidth: number; strokeColor?: string; strokeStyle?: 'solid' | 'dashed' | 'dotted' },
): ContentTestData => ({
page: CONTENT_ALIGNMENT_PAGES.line,
contentMeta: {
type: EnvelopeContentType.LINE,
page: CONTENT_ALIGNMENT_PAGES.line,
strokeColor: '#000000',
strokeStyle: 'solid',
...points,
...meta,
},
});
const rectangle = (box: Box, meta: Overrides<TContentShapeMeta> = {}): ContentTestData => ({
page: CONTENT_ALIGNMENT_PAGES.shape,
contentMeta: {
type: EnvelopeContentType.SHAPE,
shape: EnvelopeContentShapeType.RECTANGLE,
page: CONTENT_ALIGNMENT_PAGES.shape,
rotation: 0,
strokeWidth: 1,
strokeColor: '#000000',
strokeStyle: 'solid',
...box,
...meta,
},
});
const highlight = (box: Box, meta: Overrides<TContentHighlightMeta> = {}): ContentTestData => ({
page: CONTENT_ALIGNMENT_PAGES.highlight,
contentMeta: {
type: EnvelopeContentType.HIGHLIGHT,
page: CONTENT_ALIGNMENT_PAGES.highlight,
rotation: 0,
color: '#ffe600',
fillOpacity: 0.4,
...box,
...meta,
},
});
const image = (box: Box, meta: Overrides<TContentImageMeta> = {}): ContentTestData => ({
page: CONTENT_ALIGNMENT_PAGES.image,
image: 'logo',
contentMeta: { type: EnvelopeContentType.IMAGE, page: CONTENT_ALIGNMENT_PAGES.image, rotation: 0, ...box, ...meta },
});
// --- Page 1: Text ---
const TEXT_CONTENTS: ContentTestData[] = [
text(cell(0, 0), { text: 'Left aligned', textAlign: 'left' }),
text(cell(0, 1), { text: 'Center aligned', textAlign: 'center' }),
text(cell(0, 2), { text: 'Right aligned', textAlign: 'right' }),
text(cell(1, 0, { rows: 2 }), { text: 'Top', verticalAlign: 'top' }),
text(cell(1, 1, { rows: 2 }), { text: 'Middle', verticalAlign: 'middle' }),
text(cell(1, 2, { rows: 2 }), { text: 'Bottom', verticalAlign: 'bottom' }),
text(cell(3, 0), { text: 'Size 8', fontSize: 8 }),
text(cell(3, 1), { text: 'Size 14', fontSize: 14, color: '#d00000' }),
text(cell(3, 2), { text: 'Size 24 cropped to the box', fontSize: 24 }),
text(cell(4, 0), { text: 'Two lines of text wrap here', lineHeight: 1.6 }),
text(cell(4, 1), { text: 'Spaced', letterSpacing: 3 }),
text(cell(4, 2, { rows: 2 }), {
text: 'A longer paragraph of text which wraps onto several lines within its box, centered both ways.',
textAlign: 'center',
verticalAlign: 'middle',
}),
// Rotation pivots on the box's top left corner, so a 90 degree rotation
// hangs down the left edge of its cell. Inset it so it stays inside.
text(cell(6, 0, { rows: 2 }), { text: 'Rotated 15', rotation: 15 }),
text({ ...cell(6, 1, { rows: 2 }), positionX: cell(6, 1).positionX + 8 }, { text: 'Rotated 90', rotation: 90 }),
];
// --- Page 2: Lines ---
const LINE_CONTENTS: ContentTestData[] = [
line(horizontal(cell(0, 0)), { strokeWidth: 1, strokeStyle: 'solid' }),
line(horizontal(cell(0, 1)), { strokeWidth: 2, strokeStyle: 'dashed' }),
line(horizontal(cell(0, 2)), { strokeWidth: 3, strokeStyle: 'dotted' }),
line(horizontal(cell(1, 0)), { strokeWidth: 0.5 }),
line(horizontal(cell(1, 1)), { strokeWidth: 5 }),
line(horizontal(cell(1, 2)), { strokeWidth: 10 }),
// Diagonals across the full width, corner to corner.
...(() => {
const down = cell(2, 0, { columns: 3, rows: 2 });
const up = cell(4, 0, { columns: 3, rows: 2 });
return [
line(
{
x1: down.positionX,
y1: down.positionY,
x2: down.positionX + down.width,
y2: down.positionY + down.height,
},
{ strokeWidth: 2, strokeColor: '#0055aa' },
),
line(
{
x1: up.positionX,
y1: up.positionY + up.height,
x2: up.positionX + up.width,
y2: up.positionY,
},
{ strokeWidth: 2, strokeColor: '#0055aa', strokeStyle: 'dashed' },
),
];
})(),
// Vertical lines: through the center, along the left edge, and colored.
...(() => {
const center = cell(6, 0, { rows: 2 });
const edge = cell(6, 1, { rows: 2 });
const red = cell(6, 2, { rows: 2 });
return [
line(
{
x1: center.positionX + center.width / 2,
y1: center.positionY,
x2: center.positionX + center.width / 2,
y2: center.positionY + center.height,
},
{ strokeWidth: 2 },
),
line(
{
x1: edge.positionX,
y1: edge.positionY,
x2: edge.positionX,
y2: edge.positionY + edge.height,
},
{ strokeWidth: 4 },
),
line(
{
x1: red.positionX + red.width / 2,
y1: red.positionY,
x2: red.positionX + red.width / 2,
y2: red.positionY + red.height,
},
{ strokeWidth: 3, strokeColor: '#d00000', strokeStyle: 'dotted' },
),
];
})(),
];
/**
* Two overlapping filled rectangles within a cell, for stacking tests. The
* second is offset down and right of the first so both are partly visible
* and the overlap shows which is on top.
*/
const stackedPair = (
row: number,
column: number,
{ first, second }: { first: { fillColor: string; zIndex: number }; second: { fillColor: string; zIndex: number } },
): ContentTestData[] => {
const box = cell(row, column, { rows: 2 });
const size = { width: box.width * 0.6, height: box.height * 0.6 };
return [
rectangle(
{ positionX: box.positionX, positionY: box.positionY, ...size },
{ fillColor: first.fillColor, fillOpacity: 1, strokeWidth: 1, zIndex: first.zIndex },
),
rectangle(
{ positionX: box.positionX + box.width * 0.4, positionY: box.positionY + box.height * 0.4, ...size },
{ fillColor: second.fillColor, fillOpacity: 1, strokeWidth: 1, zIndex: second.zIndex },
),
];
};
// --- Page 3: Shapes ---
const SHAPE_CONTENTS: ContentTestData[] = [
rectangle(cell(0, 0), { strokeWidth: 2, strokeColor: '#d00000' }),
rectangle(cell(0, 1), { strokeWidth: 1, strokeStyle: 'dashed' }),
rectangle(cell(0, 2), { strokeWidth: 4, strokeStyle: 'dotted' }),
rectangle(cell(1, 0), { fillColor: '#ffcc00', fillOpacity: 1 }),
rectangle(cell(1, 1), { fillColor: '#00aa55', fillOpacity: 0.4 }),
rectangle(cell(1, 2), { fillColor: '#0055aa', fillOpacity: 1, strokeWidth: 0.5, strokeColor: '#0055aa' }),
rectangle(square(2, 0, 2), { strokeWidth: 2 }),
rectangle(cell(2, 1, { columns: 2 }), { strokeWidth: 1, fillColor: '#e5e7eb', fillOpacity: 1 }),
// Rotation pivots on the box's top left corner. Half sized boxes placed
// towards the right of their cells keep the swept shape within the cell.
rectangle(halfCell(4, 0, { rows: 2 }), { strokeWidth: 2, strokeColor: '#d00000', rotation: 15 }),
rectangle(halfCell(4, 1, { rows: 2 }), { strokeWidth: 2, strokeColor: '#00aa55', rotation: 45 }),
rectangle(halfCell(4, 2, { rows: 2 }), { strokeWidth: 2, strokeColor: '#0055aa', rotation: 90 }),
// Stacking. Each cell holds two overlapping filled rectangles: the first
// (top left) and the second (offset to the bottom right). The rectangles
// are listed, and therefore created, in that order, and the seed hands out
// ids in that same order so equal zIndexes stack the second on top.
//
// Expected on top: red, blue, green.
...stackedPair(6, 0, { first: { fillColor: '#d00000', zIndex: 2 }, second: { fillColor: '#ffcc00', zIndex: 1 } }),
...stackedPair(6, 1, { first: { fillColor: '#ffcc00', zIndex: 0 }, second: { fillColor: '#0055aa', zIndex: 5 } }),
...stackedPair(6, 2, { first: { fillColor: '#d00000', zIndex: 3 }, second: { fillColor: '#00aa55', zIndex: 3 } }),
];
// --- Page 4: Highlights ---
const HIGHLIGHT_CONTENTS: ContentTestData[] = [
highlight(cell(0, 0), { color: '#ffe600' }),
highlight(cell(0, 1), { color: '#00aa55' }),
highlight(cell(0, 2), { color: '#ff66cc' }),
highlight(cell(1, 0), { fillOpacity: 0.1 }),
highlight(cell(1, 1), { fillOpacity: 0.6 }),
highlight(cell(1, 2), { fillOpacity: 1 }),
highlight(cell(2, 0, { columns: 3 })),
highlight(halfCell(3, 0, { rows: 2 }), { rotation: 15 }),
highlight(halfCell(3, 1, { rows: 2 }), { rotation: 45 }),
];
// --- Page 5: Images ---
const IMAGE_CONTENTS: ContentTestData[] = [
// Fit: the wide logo fills a wide box, letterboxes in a square, centers in a tall one.
image(cell(0, 0, { rows: 2 })),
image(square(0, 1, 2)),
image({ ...cell(0, 2, { rows: 3 }), width: cell(0, 2).width / 2 }),
image(cell(3, 0, { columns: 3, rows: 2 })),
// Rotation pivots on the box's top left corner. Half sized boxes placed
// towards the right of their cells keep the swept image within the cell.
image(halfCell(5, 0, { rows: 2 }), { rotation: 15 }),
image(halfCell(5, 1, { rows: 2 }), { rotation: 45 }),
image(halfCell(5, 2, { rows: 2 }), { rotation: 90 }),
image(halfCell(7, 0, { rows: 2 }), { rotation: 180 }),
image(halfCell(7, 1, { rows: 2 }), { rotation: 270 }),
];
export const CONTENT_ALIGNMENT_TEST_CONTENTS: ContentTestData[] = [
...TEXT_CONTENTS,
...LINE_CONTENTS,
...SHAPE_CONTENTS,
...HIGHLIGHT_CONTENTS,
...IMAGE_CONTENTS,
];
@@ -48,6 +48,8 @@ const seedSubscriptionClaim = (name: string) =>
memberCount: 1,
envelopeItemCount: 10,
recipientCount: 10,
envelopeContentCount: 0,
envelopeContentImageCount: 0,
flags: {},
documentRateLimits: [],
emailRateLimits: [],
@@ -0,0 +1,54 @@
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../../fixtures/authentication';
import { expectToastTextToBeVisible } from '../../fixtures/generic';
test('[ADMIN]: update envelope content claims for an organisation', async ({ page }) => {
const { user: adminUser } = await seedUser({
isAdmin: true,
});
const { organisation } = await seedUser({
isPersonalOrganisation: false,
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
await expect(page.getByText('Manage organization')).toBeVisible();
const contentCountInput = page.getByLabel('Envelope Content Count', { exact: true });
const imageCountInput = page.getByLabel('Envelope Content Image Count', { exact: true });
// Everything is unlimited by default.
await expect(contentCountInput).toHaveValue('0');
await expect(imageCountInput).toHaveValue('0');
await contentCountInput.fill('25');
await imageCountInput.fill('5');
// "Update role" buttons also match a non-exact name.
await page.getByRole('button', { name: 'Update', exact: true }).last().click();
await expectToastTextToBeVisible(page, 'Organization has been updated successfully');
await expect(async () => {
const claim = await prisma.organisationClaim.findFirstOrThrow({
where: { organisation: { id: organisation.id } },
});
expect(claim.envelopeContentCount).toBe(25);
expect(claim.envelopeContentImageCount).toBe(5);
}).toPass({ timeout: 10000 });
// The saved values are shown when the page is loaded again.
await page.reload();
await expect(page.getByLabel('Envelope Content Count', { exact: true })).toHaveValue('25');
await expect(page.getByLabel('Envelope Content Image Count', { exact: true })).toHaveValue('5');
});
@@ -0,0 +1,110 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
/**
* Field insertion must respect the recipient's signing window: an expired
* recipient can no longer act on the envelope at all. The V1 endpoints assert
* this; the V2 `envelope.field.sign` route historically did not.
*/
const callSignEnvelopeField = async (page: Page, input: { token: string; fieldId: number; value: string }) => {
return await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.field.sign`, {
headers: { 'content-type': 'application/json' },
data: JSON.stringify({
json: {
token: input.token,
fieldId: input.fieldId,
fieldValue: {
type: FieldType.TEXT,
value: input.value,
},
},
}),
});
};
const seedV2PendingDocumentWithTextField = async () => {
const { user, team } = await seedUser();
const { user: signer } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [signer],
fields: [FieldType.TEXT],
updateDocumentOptions: {
internalVersion: 2,
},
});
const recipient = recipients[0];
const textField = recipient.fields.find((field) => field.type === FieldType.TEXT);
if (!textField) {
throw new Error('Seeded text field not found');
}
return { recipient, textField };
};
test('[ENVELOPE_FIELD_SIGN]: rejects field insertion for an expired recipient', async ({ page }) => {
const { recipient, textField } = await seedV2PendingDocumentWithTextField();
await prisma.recipient.update({
where: { id: recipient.id },
data: {
// Expired one hour ago.
expiresAt: new Date(Date.now() - 60 * 60 * 1000),
},
});
// The seed pre-populates customText with a placeholder value.
const fieldBefore = await prisma.field.findUniqueOrThrow({ where: { id: textField.id } });
const response = await callSignEnvelopeField(page, {
token: recipient.token,
fieldId: textField.id,
value: 'TEXT',
});
expect(response.ok()).toBeFalsy();
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: textField.id } });
expect(fieldAfter.inserted).toBe(false);
expect(fieldAfter.customText).toBe(fieldBefore.customText);
});
test('[ENVELOPE_FIELD_SIGN]: accepts field insertion for a recipient within their signing window', async ({ page }) => {
// Positive control: proves the request format reaches the route, so the
// expired-recipient rejection above cannot pass vacuously.
const { recipient, textField } = await seedV2PendingDocumentWithTextField();
await prisma.recipient.update({
where: { id: recipient.id },
data: {
// Expires an hour from now.
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
const response = await callSignEnvelopeField(page, {
token: recipient.token,
fieldId: textField.id,
value: 'TEXT',
});
expect(response.ok()).toBeTruthy();
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: textField.id } });
expect(fieldAfter.inserted).toBe(true);
expect(fieldAfter.customText).toBe('TEXT');
});
@@ -0,0 +1,296 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { EnvelopeContentType, type TEnvelopeContentMetaInput } from '@documenso/lib/types/envelope-content-meta';
import { prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
/**
* Contents are burned into the signed PDF, so authoring them is recorded the
* same way authoring a field is.
*
* The audit log type names are written out rather than imported because the
* module which declares them carries lingui macros the test runner cannot
* transform.
*/
const textMeta = (overrides: Partial<TEnvelopeContentMetaInput> = {}): TEnvelopeContentMetaInput => ({
type: EnvelopeContentType.TEXT,
page: 1,
rotation: 0,
positionX: 10,
positionY: 10,
width: 20,
height: 6,
text: 'Approved',
...overrides,
});
const setContents = async (
page: Page,
teamId: number,
envelopeId: string,
contents: {
id?: string;
envelopeItemId: string;
contentMeta: TEnvelopeContentMetaInput;
dataContentId?: string | null;
}[],
) =>
await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.content.set`, {
headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
data: JSON.stringify({
json: { envelopeId, contents: contents.map((content) => ({ dataContentId: null, ...content })) },
}),
});
const getContentAuditLogs = async (envelopeId: string) =>
await prisma.documentAuditLog.findMany({
where: { envelopeId, type: { startsWith: 'CONTENT_' } },
orderBy: { createdAt: 'asc' },
});
const setupEnvelope = async (page: Page, type: 'document' | 'template' = 'document') => {
const { user, team } = await seedUser();
const envelopeRecord =
type === 'document'
? await seedBlankDocument(user, team.id, { internalVersion: 2 })
: await seedBlankTemplate(user, team.id, { internalVersion: 2 });
await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents` });
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: envelopeRecord.id },
include: { envelopeItems: true },
});
return { envelope, envelopeItemId: envelope.envelopeItems[0].id, teamId: team.id };
};
test('logs a created content', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page);
const res = await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta() }]);
expect(res.ok()).toBeTruthy();
const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
const logs = await getContentAuditLogs(envelope.id);
expect(logs).toHaveLength(1);
expect(logs[0].type).toBe('CONTENT_CREATED');
expect(logs[0].data).toMatchObject({
contentId: content.id,
contentType: EnvelopeContentType.TEXT,
envelopeItemId,
contentMeta: content.contentMeta,
dataContentId: null,
});
});
test('logs a moved content as updated, recording the position', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page);
await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta() }]);
const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
const res = await setContents(page, teamId, envelope.id, [
{ id: content.id, envelopeItemId, contentMeta: textMeta({ positionX: 55, page: 1 }) },
]);
expect(res.ok()).toBeTruthy();
const logs = await getContentAuditLogs(envelope.id);
expect(logs.map((log) => log.type)).toEqual(['CONTENT_CREATED', 'CONTENT_UPDATED']);
expect(logs[1].data).toMatchObject({
contentId: content.id,
changes: [{ type: 'PROPERTY', key: 'positionX', from: 10, to: 55 }],
});
});
test('logs an edited text as updated', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page);
await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta() }]);
const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
const res = await setContents(page, teamId, envelope.id, [
{ id: content.id, envelopeItemId, contentMeta: textMeta({ text: 'Rejected' }) },
]);
expect(res.ok()).toBeTruthy();
const logs = await getContentAuditLogs(envelope.id);
expect(logs.map((log) => log.type)).toEqual(['CONTENT_CREATED', 'CONTENT_UPDATED']);
expect(logs[1].data).toMatchObject({
contentId: content.id,
changes: [{ type: 'PROPERTY', key: 'text', from: 'Approved', to: 'Rejected' }],
});
});
test('logs a restyled content as updated', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page);
await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta({ color: '#000000' }) }]);
const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
await setContents(page, teamId, envelope.id, [
{ id: content.id, envelopeItemId, contentMeta: textMeta({ color: '#ff0000' }) },
]);
const logs = await getContentAuditLogs(envelope.id);
expect(logs[1].data).toMatchObject({
changes: [{ type: 'PROPERTY', key: 'color', from: '#000000', to: '#ff0000' }],
});
});
/**
* A content belongs to the item it was created on. The diff which feeds the
* audit log does not cover the item, so a move is refused outright rather
* than applied without a record.
*/
test('rejects moving a content to another envelope item', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page);
const firstItem = await prisma.envelopeItem.findFirstOrThrow({
where: { id: envelopeItemId },
include: { documentData: true },
});
const secondDocumentData = await prisma.documentData.create({
data: {
type: firstItem.documentData.type,
data: firstItem.documentData.data,
initialData: firstItem.documentData.initialData,
},
});
const secondItem = await prisma.envelopeItem.create({
data: {
id: prefixedId('envelope_item'),
title: `${firstItem.title} - 2`,
documentDataId: secondDocumentData.id,
order: 2,
envelopeId: envelope.id,
},
});
await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta() }]);
const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
const res = await setContents(page, teamId, envelope.id, [
{ id: content.id, envelopeItemId: secondItem.id, contentMeta: textMeta() },
]);
expect(res.status()).toBe(400);
const reloaded = await prisma.envelopeContent.findFirstOrThrow({ where: { id: content.id } });
expect(reloaded.envelopeItemId).toBe(envelopeItemId);
const logs = await getContentAuditLogs(envelope.id);
expect(logs.map((log) => log.type)).toEqual(['CONTENT_CREATED']);
});
test('does not log a save which changed nothing', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page);
await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta() }]);
const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
await setContents(page, teamId, envelope.id, [{ id: content.id, envelopeItemId, contentMeta: textMeta() }]);
const logs = await getContentAuditLogs(envelope.id);
expect(logs.map((log) => log.type)).toEqual(['CONTENT_CREATED']);
});
/**
* Bringing a content to the front is an authoring action like any other
* property change, so it is recorded. Merely selecting a content no longer
* rewrites its order, so this is only logged when the order actually moves.
*/
test('logs a changed stacking order as updated', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page);
await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta() }]);
const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
const res = await setContents(page, teamId, envelope.id, [
{ id: content.id, envelopeItemId, contentMeta: textMeta({ zIndex: content.contentMeta.zIndex + 5 }) },
]);
expect(res.ok()).toBeTruthy();
const reloaded = await prisma.envelopeContent.findFirstOrThrow({ where: { id: content.id } });
expect(reloaded.contentMeta.zIndex).toBe(content.contentMeta.zIndex + 5);
const logs = await getContentAuditLogs(envelope.id);
expect(logs.map((log) => log.type)).toEqual(['CONTENT_CREATED', 'CONTENT_UPDATED']);
expect(logs[1].data).toMatchObject({
contentId: content.id,
changes: [
{ type: 'PROPERTY', key: 'zIndex', from: content.contentMeta.zIndex, to: content.contentMeta.zIndex + 5 },
],
});
});
test('logs a removed content as deleted', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page);
await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta() }]);
const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
const res = await setContents(page, teamId, envelope.id, []);
expect(res.ok()).toBeTruthy();
const logs = await getContentAuditLogs(envelope.id);
expect(logs.map((log) => log.type)).toEqual(['CONTENT_CREATED', 'CONTENT_DELETED']);
expect(logs[1].data).toMatchObject({
contentId: content.id,
contentType: EnvelopeContentType.TEXT,
envelopeItemId,
});
});
/**
* Templates are not audit logged, matching `setFieldsForTemplate`.
*/
test('does not log contents authored on a template', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page, 'template');
const res = await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta() }]);
expect(res.ok()).toBeTruthy();
expect(await prisma.envelopeContent.count({ where: { envelopeId: envelope.id } })).toBe(1);
expect(await getContentAuditLogs(envelope.id)).toHaveLength(0);
});
@@ -0,0 +1,97 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { EnvelopeContentType, type TEnvelopeContentMetaInput } from '@documenso/lib/types/envelope-content-meta';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import { apiSignin } from '../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
/**
* The editor sends every content on every save, and the ones which already
* exist are identified by their ID. An ID listed twice would update the same
* content twice, so it is rejected. New contents have no ID, so any number of
* them can be created in one save.
*/
const textMeta = (text: string, positionY = 10): TEnvelopeContentMetaInput => ({
type: EnvelopeContentType.TEXT,
page: 1,
positionX: 10,
positionY,
width: 20,
height: 6,
text,
});
type SetContentInput = {
id?: string;
envelopeItemId: string;
contentMeta: TEnvelopeContentMetaInput;
};
const setContents = async (page: Page, teamId: number, envelopeId: string, contents: SetContentInput[]) =>
await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.content.set`, {
headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
data: JSON.stringify({
json: { envelopeId, contents: contents.map((content) => ({ dataContentId: null, ...content })) },
}),
});
const setupDocument = async (page: Page) => {
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` });
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
include: { envelopeItems: true },
});
return { envelopeId: envelope.id, envelopeItemId: envelope.envelopeItems[0].id, teamId: team.id };
};
test('rejects a content listed twice without changing it', async ({ page }) => {
const { envelopeId, envelopeItemId, teamId } = await setupDocument(page);
const created = await setContents(page, teamId, envelopeId, [{ envelopeItemId, contentMeta: textMeta('Original') }]);
expect(created.ok(), await created.text()).toBeTruthy();
const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId } });
const res = await setContents(page, teamId, envelopeId, [
{ id: content.id, envelopeItemId, contentMeta: textMeta('First edit') },
{ id: content.id, envelopeItemId, contentMeta: textMeta('Second edit') },
]);
expect(res.status()).toBe(400);
expect(await res.text()).toContain('Content IDs must be unique');
const contents = await prisma.envelopeContent.findMany({ where: { envelopeId } });
expect(contents).toHaveLength(1);
expect(contents[0].contentMeta).toEqual(content.contentMeta);
});
test('creates several new contents in one save', async ({ page }) => {
const { envelopeId, envelopeItemId, teamId } = await setupDocument(page);
const res = await setContents(page, teamId, envelopeId, [
{ envelopeItemId, contentMeta: textMeta('First', 10) },
{ envelopeItemId, contentMeta: textMeta('Second', 30) },
{ envelopeItemId, contentMeta: textMeta('Third', 50) },
]);
expect(res.ok(), await res.text()).toBeTruthy();
expect(await prisma.envelopeContent.count({ where: { envelopeId } })).toBe(3);
});
@@ -0,0 +1,219 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import {
CONTENT_MAX_Z_INDEX,
EnvelopeContentType,
type TEnvelopeContentMetaInput,
ZEnvelopeContentMetaSchema,
} from '@documenso/lib/types/envelope-content-meta';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
/**
* The editor hides the palette once a limit is reached, so these cover the
* server side of that rule: the API is the only way to push an envelope past
* its organisation's allowance.
*/
const textMeta = (index: number, overrides: { zIndex?: number } = {}): TEnvelopeContentMetaInput => ({
type: EnvelopeContentType.TEXT,
page: 1,
rotation: 0,
positionX: 10,
positionY: 10,
width: 20,
height: 6,
text: `Content ${index}`,
...overrides,
});
const imageMeta = (): TEnvelopeContentMetaInput => ({
type: EnvelopeContentType.IMAGE,
page: 1,
rotation: 0,
positionX: 10,
positionY: 30,
width: 20,
height: 10,
});
const setOrganisationContentLimits = async (
teamId: number,
limits: { envelopeContentCount?: number; envelopeContentImageCount?: number },
) => {
const team = await prisma.team.findFirstOrThrow({ where: { id: teamId } });
const organisationClaim = await prisma.organisationClaim.findFirstOrThrow({
where: { organisation: { id: team.organisationId } },
});
await prisma.organisationClaim.update({ where: { id: organisationClaim.id }, data: limits });
};
/**
* Seed contents straight onto the envelope, bypassing the limit check, to set
* up an envelope which is already over its allowance.
*/
const seedContents = async (envelopeId: string, envelopeItemId: string, count: number, images = 0) => {
await prisma.envelopeContent.createMany({
data: Array.from({ length: count }).map((_, index) => ({
id: generateDatabaseId('envelope_content'),
envelopeId,
envelopeItemId,
contentMeta: ZEnvelopeContentMetaSchema.parse(index < images ? imageMeta() : textMeta(index)),
})),
});
};
const setContents = async (
page: Page,
teamId: number,
envelopeId: string,
contents: {
id?: string;
envelopeItemId: string;
contentMeta: TEnvelopeContentMetaInput;
dataContentId?: string | null;
}[],
) =>
await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.content.set`, {
headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
data: JSON.stringify({
json: { envelopeId, contents: contents.map((content) => ({ dataContentId: null, ...content })) },
}),
});
const getContentCount = async (envelopeId: string) => await prisma.envelopeContent.count({ where: { envelopeId } });
const setupEnvelope = async (
page: Page,
limits: { envelopeContentCount?: number; envelopeContentImageCount?: number },
) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id, { internalVersion: 2 });
await setOrganisationContentLimits(team.id, limits);
await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents` });
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
include: { envelopeItems: true },
});
return { envelope, envelopeItemId: envelope.envelopeItems[0].id, teamId: team.id };
};
test('rejects a save which exceeds the content limit', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page, { envelopeContentCount: 2 });
const withinLimit = await setContents(
page,
teamId,
envelope.id,
Array.from({ length: 2 }).map((_, index) => ({ envelopeItemId, contentMeta: textMeta(index) })),
);
expect(withinLimit.ok()).toBeTruthy();
expect(await getContentCount(envelope.id)).toBe(2);
const overLimit = await setContents(
page,
teamId,
envelope.id,
Array.from({ length: 3 }).map((_, index) => ({ envelopeItemId, contentMeta: textMeta(index) })),
);
expect(overLimit.status()).toBe(400);
expect(await overLimit.text()).toContain('ENVELOPE_CONTENT_LIMIT_EXCEEDED');
// The rejected save left the envelope untouched.
expect(await getContentCount(envelope.id)).toBe(2);
});
test('rejects a save which exceeds the image content limit', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page, { envelopeContentImageCount: 1 });
const overLimit = await setContents(page, teamId, envelope.id, [
{ envelopeItemId, contentMeta: imageMeta() },
{ envelopeItemId, contentMeta: imageMeta() },
]);
expect(overLimit.status()).toBe(400);
expect(await overLimit.text()).toContain('ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED');
expect(await getContentCount(envelope.id)).toBe(0);
});
/**
* An envelope can end up over its limit without being edited, e.g. after the
* organisation's plan is lowered, so removals must keep working.
*/
test('allows an over limit envelope to be trimmed back down', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page, { envelopeContentCount: 2 });
await seedContents(envelope.id, envelopeItemId, 5);
const existing = await prisma.envelopeContent.findMany({
where: { envelopeId: envelope.id },
orderBy: { id: 'asc' },
});
// Removing one while still over the limit is allowed.
const trimmed = await setContents(
page,
teamId,
envelope.id,
existing.slice(0, 4).map((content) => ({
id: content.id,
envelopeItemId,
contentMeta: textMeta(0),
})),
);
expect(trimmed.ok()).toBeTruthy();
expect(await getContentCount(envelope.id)).toBe(4);
// Adding while over the limit is still refused.
const added = await setContents(page, teamId, envelope.id, [
...existing.slice(0, 4).map((content) => ({ id: content.id, envelopeItemId, contentMeta: textMeta(0) })),
{ envelopeItemId, contentMeta: textMeta(99) },
]);
expect(added.status()).toBe(400);
expect(await getContentCount(envelope.id)).toBe(4);
// And once back within the allowance everything works normally again.
const backWithin = await setContents(
page,
teamId,
envelope.id,
existing.slice(0, 2).map((content) => ({ id: content.id, envelopeItemId, contentMeta: textMeta(0) })),
);
expect(backWithin.ok()).toBeTruthy();
expect(await getContentCount(envelope.id)).toBe(2);
});
test('rejects a stacking order above the authored ceiling', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page, {});
const res = await setContents(page, teamId, envelope.id, [
{ envelopeItemId, contentMeta: textMeta(0, { zIndex: CONTENT_MAX_Z_INDEX + 1 }) },
]);
expect(res.ok()).toBeFalsy();
expect(await getContentCount(envelope.id)).toBe(0);
});
@@ -0,0 +1,482 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createDataContentImage } from '@documenso/lib/server-only/data-content/create-data-content-image';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import {
EnvelopeContentType,
type TEnvelopeContentMeta,
ZEnvelopeContentMetaSchema,
} from '@documenso/lib/types/envelope-content-meta';
import { generateDatabaseId, nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import type { TUpdateEmbeddingEnvelopePayload } from '@documenso/trpc/server/embedding-router/update-embedding-envelope.types';
import { type APIRequestContext, expect, test } from '@playwright/test';
import type { Team } from '@prisma/client';
import { createGifLabelledAsPng, createImageFile, type TestImageFile } from '../../fixtures/contents';
/**
* The embedded editor saves everything in one request when the envelope is
* updated. New contents have temporary IDs, new images are referenced by their
* index in `contentImages`, and the contents are checked before anything is
* written, so a request which fails changes nothing.
*/
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
/**
* Seed a draft document and a presign token scoped to it, as the embedded
* editor is given.
*/
const setupEmbeddedDocument = async (request: APIRequestContext) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id, { internalVersion: 2 });
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: document.id },
include: { envelopeItems: true },
});
const { token: apiToken } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'e2e-embed-update-contents',
expiresIn: null,
});
const presignRes = await request.post(`${WEBAPP_BASE_URL}/api/v2/embedding/create-presign-token`, {
headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
data: { scope: `envelopeId:${envelope.id}` },
});
expect(presignRes.ok()).toBeTruthy();
const { token: presignToken }: { token: string } = await presignRes.json();
return { team, envelope, envelopeItem: envelope.envelopeItems[0], presignToken };
};
type UpdateEmbeddedEnvelopeOptions = {
request: APIRequestContext;
presignToken: string;
envelope: { id: string; title: string };
envelopeItem: { id: string; title: string; order: number };
data: Partial<TUpdateEmbeddingEnvelopePayload['data']>;
images?: TestImageFile[];
};
/**
* Save the envelope through the embedded update route, as the embedded
* editor does.
*/
const updateEmbeddedEnvelope = async ({
request,
presignToken,
envelope,
envelopeItem,
data,
images = [],
}: UpdateEmbeddedEnvelopeOptions) => {
const payload: TUpdateEmbeddingEnvelopePayload = {
envelopeId: envelope.id,
data: {
title: envelope.title,
envelopeItems: [{ id: envelopeItem.id, title: envelopeItem.title, order: envelopeItem.order }],
recipients: [],
attachments: [],
...data,
},
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
for (const image of images) {
formData.append('contentImages', new File([image.buffer], image.name, { type: image.mimeType }));
}
return await request.post(`${WEBAPP_BASE_URL}/api/trpc/embeddingPresign.updateEmbeddingEnvelope`, {
headers: { authorization: `Bearer ${presignToken}` },
multipart: formData,
});
};
const setOrganisationContentLimit = async (team: Team, envelopeContentCount: number) => {
const organisationClaim = await prisma.organisationClaim.findFirstOrThrow({
where: {
organisation: {
id: team.organisationId,
},
},
});
await prisma.organisationClaim.update({
where: {
id: organisationClaim.id,
},
data: {
envelopeContentCount,
},
});
};
const countDataContentsNamed = async (fileName: string) =>
await prisma.dataContent.count({
where: {
metadata: {
path: ['fileName'],
equals: fileName,
},
},
});
/**
* A failed update must leave the envelope as it was: the title is only
* changed by the steps which run before the contents are set.
*/
const expectEnvelopeUnchanged = async (envelope: { id: string; title: string }) => {
const current = await prisma.envelope.findUniqueOrThrow({
where: { id: envelope.id },
include: { contents: true },
});
expect(current.title).toBe(envelope.title);
expect(current.contents).toHaveLength(0);
};
const textMeta = (positionY = 10): TEnvelopeContentMeta =>
ZEnvelopeContentMetaSchema.parse({
type: EnvelopeContentType.TEXT,
page: 1,
positionX: 10,
positionY,
width: 40,
height: 5,
text: 'Text',
});
const imageMeta = (positionY: number): TEnvelopeContentMeta =>
ZEnvelopeContentMetaSchema.parse({
type: EnvelopeContentType.IMAGE,
page: 1,
positionX: 10,
positionY,
width: 30,
height: 10,
});
test.describe('Update embedded envelope contents', () => {
test('saves new contents and their new images', async ({ request }) => {
const { envelope, envelopeItem, presignToken } = await setupEmbeddedDocument(request);
const image = await createImageFile(`embed-update-${nanoid()}.png`, 300, 100);
const res = await updateEmbeddedEnvelope({
request,
presignToken,
envelope,
envelopeItem,
data: {
contents: [
{
id: 'PRESIGNED_content-a',
envelopeItemId: envelopeItem.id,
contentMeta: imageMeta(10),
dataContentId: null,
imageIndex: 0,
},
// A duplicate showing the same image.
{
id: 'PRESIGNED_content-b',
envelopeItemId: envelopeItem.id,
contentMeta: imageMeta(40),
dataContentId: null,
imageIndex: 0,
},
{
id: 'PRESIGNED_content-c',
envelopeItemId: envelopeItem.id,
contentMeta: textMeta(),
dataContentId: null,
},
],
},
images: [image],
});
expect(res.ok(), await res.text()).toBeTruthy();
const contents = await prisma.envelopeContent.findMany({
where: { envelopeId: envelope.id },
include: { dataContent: true },
});
expect(contents).toHaveLength(3);
expect(contents.some((content) => content.id.startsWith('PRESIGNED_'))).toBe(false);
const imageContents = contents.filter((content) => content.contentMeta.type === EnvelopeContentType.IMAGE);
expect(imageContents).toHaveLength(2);
// The image was stored once and is shared by both contents.
expect(imageContents[0].dataContentId).not.toBeNull();
expect(imageContents[0].dataContentId).toBe(imageContents[1].dataContentId);
expect(imageContents[0].dataContent?.metadata).toMatchObject({
width: 300,
height: 100,
fileName: image.name,
});
});
test('rejects contents over the limit without changing anything', async ({ request }) => {
const { team, envelope, envelopeItem, presignToken } = await setupEmbeddedDocument(request);
const image = await createImageFile(`embed-limit-${nanoid()}.png`, 50, 50);
await setOrganisationContentLimit(team, 1);
const res = await updateEmbeddedEnvelope({
request,
presignToken,
envelope,
envelopeItem,
data: {
title: 'Changed title',
contents: [
{
id: 'PRESIGNED_content-a',
envelopeItemId: envelopeItem.id,
contentMeta: imageMeta(10),
dataContentId: null,
imageIndex: 0,
},
{
id: 'PRESIGNED_content-b',
envelopeItemId: envelopeItem.id,
contentMeta: textMeta(),
dataContentId: null,
},
],
},
images: [image],
});
expect(res.status()).toBe(400);
expect(await res.text()).toContain('ENVELOPE_CONTENT_LIMIT_EXCEEDED');
await expectEnvelopeUnchanged(envelope);
expect(await countDataContentsNamed(image.name)).toBe(0);
});
test('rejects an unknown content id without changing anything', async ({ request }) => {
const { envelope, envelopeItem, presignToken } = await setupEmbeddedDocument(request);
const res = await updateEmbeddedEnvelope({
request,
presignToken,
envelope,
envelopeItem,
data: {
title: 'Changed title',
contents: [
{
id: 'envelope_content_unknown',
envelopeItemId: envelopeItem.id,
contentMeta: textMeta(),
dataContentId: null,
},
],
},
});
expect(res.status()).toBe(404);
expect(await res.text()).toContain('Envelope content not found');
await expectEnvelopeUnchanged(envelope);
});
test('rejects an image which is not attached to the envelope', async ({ request }) => {
const { envelope, envelopeItem, presignToken } = await setupEmbeddedDocument(request);
// An image stored for something else, referenced by its ID.
const png = await createImageFile(`embed-foreign-${nanoid()}.png`, 50, 50);
const foreignDataContent = await createDataContentImage({
file: {
name: png.name,
arrayBuffer: async () => await new Blob([png.buffer]).arrayBuffer(),
},
});
const res = await updateEmbeddedEnvelope({
request,
presignToken,
envelope,
envelopeItem,
data: {
title: 'Changed title',
contents: [
{
id: 'PRESIGNED_content-a',
envelopeItemId: envelopeItem.id,
contentMeta: imageMeta(10),
dataContentId: foreignDataContent.id,
},
],
},
});
expect(res.status()).toBe(404);
expect(await res.text()).toContain(`Data content ${foreignDataContent.id} not found`);
await expectEnvelopeUnchanged(envelope);
});
test('rejects an image index without an uploaded image', async ({ request }) => {
const { envelope, envelopeItem, presignToken } = await setupEmbeddedDocument(request);
const res = await updateEmbeddedEnvelope({
request,
presignToken,
envelope,
envelopeItem,
data: {
title: 'Changed title',
contents: [
{
id: 'PRESIGNED_content-a',
envelopeItemId: envelopeItem.id,
contentMeta: imageMeta(10),
dataContentId: null,
imageIndex: 0,
},
],
},
});
expect(res.status()).toBe(400);
expect(await res.text()).toContain('Invalid content image index');
await expectEnvelopeUnchanged(envelope);
});
test('rejects a content listed twice without changing anything', async ({ request }) => {
const { envelope, envelopeItem, presignToken } = await setupEmbeddedDocument(request);
const existingContent = await prisma.envelopeContent.create({
data: {
id: generateDatabaseId('envelope_content'),
envelopeId: envelope.id,
envelopeItemId: envelopeItem.id,
contentMeta: textMeta(),
},
});
// Both entries would update the same content.
const res = await updateEmbeddedEnvelope({
request,
presignToken,
envelope,
envelopeItem,
data: {
title: 'Changed title',
contents: [
{
id: existingContent.id,
envelopeItemId: envelopeItem.id,
contentMeta: textMeta(30),
dataContentId: null,
},
{
id: existingContent.id,
envelopeItemId: envelopeItem.id,
contentMeta: textMeta(60),
dataContentId: null,
},
],
},
});
expect(res.status()).toBe(400);
expect(await res.text()).toContain('Content IDs must be unique');
const current = await prisma.envelope.findUniqueOrThrow({
where: { id: envelope.id },
include: { contents: true },
});
expect(current.title).toBe(envelope.title);
expect(current.contents).toHaveLength(1);
expect(current.contents[0].contentMeta).toEqual(existingContent.contentMeta);
});
test('rejects a new content listed twice without changing anything', async ({ request }) => {
const { envelope, envelopeItem, presignToken } = await setupEmbeddedDocument(request);
const res = await updateEmbeddedEnvelope({
request,
presignToken,
envelope,
envelopeItem,
data: {
title: 'Changed title',
contents: [
{
id: 'PRESIGNED_content-a',
envelopeItemId: envelopeItem.id,
contentMeta: textMeta(),
dataContentId: null,
},
{
id: 'PRESIGNED_content-a',
envelopeItemId: envelopeItem.id,
contentMeta: imageMeta(40),
dataContentId: null,
},
],
},
});
expect(res.status()).toBe(400);
expect(await res.text()).toContain('Content IDs must be unique');
await expectEnvelopeUnchanged(envelope);
});
test('rejects an image which is not really a PNG, JPEG or WebP', async ({ request }) => {
const { envelope, envelopeItem, presignToken } = await setupEmbeddedDocument(request);
const image = createGifLabelledAsPng(`embed-format-${nanoid()}.png`);
const res = await updateEmbeddedEnvelope({
request,
presignToken,
envelope,
envelopeItem,
data: {
title: 'Changed title',
contents: [
{
id: 'PRESIGNED_content-a',
envelopeItemId: envelopeItem.id,
contentMeta: imageMeta(10),
dataContentId: null,
imageIndex: 0,
},
],
},
images: [image],
});
expect(res.status()).toBe(400);
expect(await res.text()).toContain('Unsupported image format: gif');
await expectEnvelopeUnchanged(envelope);
expect(await countDataContentsNamed(image.name)).toBe(0);
});
});
@@ -0,0 +1,287 @@
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createDataContentImage } from '@documenso/lib/server-only/data-content/create-data-content-image';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { EnvelopeContentType, ZEnvelopeContentMetaSchema } from '@documenso/lib/types/envelope-content-meta';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType, FieldType, RecipientRole } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import { type APIRequestContext, type APIResponse, expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
const logoPngBuffer = fs.readFileSync(path.join(__dirname, '../../../../../packages/assets/logo.png'));
/**
* Set the content allowances on the organisation that owns the seeded team.
*
* A value of `0` means unlimited.
*/
const setOrganisationContentLimits = async (
team: Team,
limits: { envelopeContentCount?: number; envelopeContentImageCount?: number },
) => {
const organisationClaim = await prisma.organisationClaim.findFirstOrThrow({
where: {
organisation: {
id: team.organisationId,
},
},
});
await prisma.organisationClaim.update({
where: {
id: organisationClaim.id,
},
data: limits,
});
};
const createEnvelope = async (request: APIRequestContext, authToken: string) => {
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Envelope Content Limit Test',
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('files', new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${authToken}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
return (await res.json()) as TCreateEnvelopeResponse;
};
/**
* Build a distributable envelope holding `contentCount` contents, of which
* `imageCount` are image contents, then attempt to distribute it.
*
* Contents are inserted directly rather than created through the API, since
* the API refuses to create an envelope over the limits and these tests need
* one, as left behind when an organisation's plan is lowered. Image contents
* are given a real image, since sending rejects an image content without one
* and decodes it to draw it into the PDF.
*/
const buildAndDistributeEnvelopeWithContents = async ({
request,
authToken,
contentCount,
imageCount = 0,
}: {
request: APIRequestContext;
authToken: string;
contentCount: number;
imageCount?: number;
}): Promise<{ envelopeId: string; distributeRes: APIResponse }> => {
const envelope = await createEnvelope(request, authToken);
const envelopeRes = await request.get(`${baseUrl}/envelope/${envelope.id}`, {
headers: { Authorization: `Bearer ${authToken}` },
});
expect(envelopeRes.ok()).toBeTruthy();
const envelopeData = (await envelopeRes.json()) as TGetEnvelopeResponse;
const envelopeItemId = envelopeData.envelopeItems[0].id;
const recipientRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
data: {
envelopeId: envelope.id,
data: [
{
email: `recipient-${Date.now()}-${Math.random().toString(36).slice(2)}@test.documenso.com`,
name: 'Recipient',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
],
},
});
expect(recipientRes.ok()).toBeTruthy();
const [recipient] = (await recipientRes.json()).data;
const fieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
data: {
envelopeId: envelope.id,
data: [
{
recipientId: recipient.id,
envelopeItemId,
type: FieldType.SIGNATURE,
page: 1,
positionX: 10,
positionY: 60,
width: 30,
height: 8,
},
],
},
});
expect(fieldsRes.ok()).toBeTruthy();
const dataContentIds = await Promise.all(
Array.from({ length: imageCount }).map(async () => {
const dataContent = await createDataContentImage({
file: { name: 'logo.png', arrayBuffer: async () => new Uint8Array(logoPngBuffer).buffer as ArrayBuffer },
});
return dataContent.id;
}),
);
await prisma.envelopeContent.createMany({
data: Array.from({ length: contentCount }).map((_, index) => {
const isImage = index < imageCount;
return {
id: generateDatabaseId('envelope_content'),
envelopeId: envelope.id,
envelopeItemId,
dataContentId: isImage ? dataContentIds[index] : null,
contentMeta: ZEnvelopeContentMetaSchema.parse(
isImage
? {
type: EnvelopeContentType.IMAGE,
page: 1,
rotation: 0,
zIndex: 0,
positionX: 10,
positionY: 10,
width: 20,
height: 10,
}
: {
type: EnvelopeContentType.TEXT,
page: 1,
rotation: 0,
zIndex: 0,
positionX: 10,
positionY: 10,
width: 20,
height: 6,
text: `Content ${index}`,
},
),
};
}),
});
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
data: {
envelopeId: envelope.id,
} satisfies TDistributeEnvelopeRequest,
});
return { envelopeId: envelope.id, distributeRes };
};
const expectEnvelopeStatus = async (envelopeId: string, status: DocumentStatus) => {
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: envelopeId },
});
expect(envelope.status).toBe(status);
};
test.describe('Envelope content limits on distribute', () => {
let user: User;
let team: Team;
let token: string;
test.beforeEach(async () => {
({ user, team } = await seedUser());
({ token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-envelope-content-limit',
expiresIn: null,
}));
});
test('allows distribution when the content count is at the limit', async ({ request }) => {
await setOrganisationContentLimits(team, { envelopeContentCount: 3 });
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithContents({
request,
authToken: token,
contentCount: 3,
});
expect(distributeRes.status()).toBe(200);
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
});
test('denies distribution when the content count is over the limit', async ({ request }) => {
await setOrganisationContentLimits(team, { envelopeContentCount: 3 });
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithContents({
request,
authToken: token,
contentCount: 4,
});
expect(distributeRes.status()).toBe(400);
expect(await distributeRes.text()).toContain('ENVELOPE_CONTENT_LIMIT_EXCEEDED');
await expectEnvelopeStatus(envelopeId, DocumentStatus.DRAFT);
});
test('denies distribution when the image content count is over the limit', async ({ request }) => {
await setOrganisationContentLimits(team, { envelopeContentImageCount: 1 });
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithContents({
request,
authToken: token,
contentCount: 3,
imageCount: 2,
});
expect(distributeRes.status()).toBe(400);
expect(await distributeRes.text()).toContain('ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED');
await expectEnvelopeStatus(envelopeId, DocumentStatus.DRAFT);
});
test('allows distribution when the limits are unlimited', async ({ request }) => {
await setOrganisationContentLimits(team, { envelopeContentCount: 0, envelopeContentImageCount: 0 });
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithContents({
request,
authToken: token,
contentCount: 12,
imageCount: 6,
});
expect(distributeRes.status()).toBe(200);
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
});
});
@@ -0,0 +1,238 @@
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 { EnvelopeContentType, type TEnvelopeContentMetaInput } from '@documenso/lib/types/envelope-content-meta';
import { nanoid } from '@documenso/lib/universal/id';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import type { TCreateEnvelopeResponse } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TDuplicateEnvelopeResponse } from '@documenso/trpc/server/envelope-router/duplicate-envelope.types';
import type {
TUseEnvelopePayload,
TUseEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/use-envelope.types';
import { type APIRequestContext, expect, test } from '@playwright/test';
import { EnvelopeType } from '@prisma/client';
import { createImageFile } from '../../fixtures/contents';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
/**
* Duplicating an envelope, or creating a document from a template, copies the
* contents onto the matching items of the new envelope. Images are shared
* rather than copied, since a data content never changes.
*/
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
const textMeta = (text: string): TEnvelopeContentMetaInput => ({
type: EnvelopeContentType.TEXT,
page: 1,
positionX: 10,
positionY: 10,
width: 40,
height: 5,
text,
});
const IMAGE_META: TEnvelopeContentMetaInput = {
type: EnvelopeContentType.IMAGE,
page: 1,
positionX: 10,
positionY: 40,
width: 30,
height: 10,
};
/**
* Create an envelope with two files through the public create route, with a
* text on each file and an image on the second.
*/
const createSourceEnvelope = async (request: APIRequestContext, token: string, type: EnvelopeType) => {
const image = await createImageFile(`copy-${nanoid()}.png`, 120, 60);
const formData = new FormData();
formData.append(
'payload',
JSON.stringify({
type,
title: 'Envelope With Contents',
contents: [
{ identifier: 'first.pdf', contentMeta: textMeta('On the first file') },
{ identifier: 'second.pdf', contentMeta: textMeta('On the second file') },
{ identifier: 'second.pdf', contentMeta: IMAGE_META, imageIndex: 0 },
],
}),
);
for (const pdfName of ['first.pdf', 'second.pdf']) {
formData.append('files', new File([examplePdfBuffer], pdfName, { type: 'application/pdf' }));
}
formData.append('contentImages', new File([image.buffer], image.name, { type: image.mimeType }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok(), await res.text()).toBeTruthy();
const { id } = (await res.json()) as TCreateEnvelopeResponse;
return id;
};
/**
* An envelope's contents, each described by the position of the item it is
* on rather than the item's ID, so a copy can be compared with its source.
*
* Titles would not do, since creating a document from a template drops the
* ".pdf" from them.
*/
const getContentSummaries = async (envelopeId: string) => {
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: envelopeId },
include: { envelopeItems: { orderBy: { order: 'asc' } }, contents: true },
});
return envelope.contents.map((content) => ({
envelopeItemIndex: envelope.envelopeItems.findIndex((item) => item.id === content.envelopeItemId),
contentMeta: content.contentMeta,
dataContentId: content.dataContentId,
}));
};
type CopyEnvelopeOptions = {
request: APIRequestContext;
token: string;
sourceId: string;
includeContents?: boolean;
};
type CopyRoute = {
name: string;
/**
* The type of envelope the route copies.
*/
sourceType: EnvelopeType;
/**
* Copy the source envelope, returning the ID of the new envelope.
*/
copy: (options: CopyEnvelopeOptions) => Promise<string>;
};
const COPY_ROUTES: CopyRoute[] = [
{
name: 'envelope/duplicate',
sourceType: EnvelopeType.DOCUMENT,
copy: async ({ request, token, sourceId, includeContents }) => {
const res = await request.post(`${baseUrl}/envelope/duplicate`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: sourceId, includeContents },
});
expect(res.ok(), await res.text()).toBeTruthy();
const { id } = (await res.json()) as TDuplicateEnvelopeResponse;
return id;
},
},
{
name: 'envelope/use',
sourceType: EnvelopeType.TEMPLATE,
copy: async ({ request, token, sourceId, includeContents }) => {
const payload: TUseEnvelopePayload = { envelopeId: sourceId, includeContents };
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const res = await request.post(`${baseUrl}/envelope/use`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok(), await res.text()).toBeTruthy();
const { id } = (await res.json()) as TUseEnvelopeResponse;
return id;
},
},
{
name: 'template/use',
sourceType: EnvelopeType.TEMPLATE,
copy: async ({ request, token, sourceId, includeContents }) => {
const template = await prisma.envelope.findUniqueOrThrow({ where: { id: sourceId } });
const res = await request.post(`${baseUrl}/template/use`, {
headers: { Authorization: `Bearer ${token}` },
data: {
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
recipients: [],
includeContents,
},
});
expect(res.ok(), await res.text()).toBeTruthy();
const { envelopeId }: { envelopeId: string } = await res.json();
return envelopeId;
},
},
];
test.describe('Copy contents to new envelopes', () => {
let token: string;
test.beforeEach(async () => {
const { user, team } = await seedUser();
({ token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-envelope-copy-contents',
expiresIn: null,
}));
});
for (const route of COPY_ROUTES) {
test(`${route.name} copies the contents onto the matching items`, async ({ request }) => {
const sourceId = await createSourceEnvelope(request, token, route.sourceType);
const copyId = await route.copy({ request, token, sourceId });
const source = await getContentSummaries(sourceId);
const copy = await getContentSummaries(copyId);
expect(source).toHaveLength(3);
// The same contents on the same files, showing the same image.
expect(copy).toHaveLength(source.length);
expect(copy).toEqual(expect.arrayContaining(source));
});
test(`${route.name} leaves the contents behind when asked to`, async ({ request }) => {
const sourceId = await createSourceEnvelope(request, token, route.sourceType);
const copyId = await route.copy({ request, token, sourceId, includeContents: false });
expect(await prisma.envelopeContent.count({ where: { envelopeId: copyId } })).toBe(0);
expect(await prisma.envelopeContent.count({ where: { envelopeId: sourceId } })).toBe(3);
});
}
});
@@ -0,0 +1,430 @@
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 {
EnvelopeContentShapeType,
EnvelopeContentType,
type TEnvelopeContentMetaInput,
} from '@documenso/lib/types/envelope-content-meta';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import { type APIRequestContext, expect, test } from '@playwright/test';
import { EnvelopeType, type Team } from '@prisma/client';
import { createGifLabelledAsPng, createImageFile, type TestImageFile } from '../../fixtures/contents';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
type TCreateContentInput = {
identifier?: string | number;
contentMeta: TEnvelopeContentMetaInput;
imageIndex?: number;
};
type CreateEnvelopeWithContentsOptions = {
request: APIRequestContext;
token: string;
payload?: Partial<Omit<TCreateEnvelopePayload, 'contents'>>;
contents: TCreateContentInput[];
pdfNames?: string[];
images?: TestImageFile[];
};
/**
* Create an envelope with contents through the public create route.
*/
const createEnvelopeWithContents = async ({
request,
token,
payload = {},
contents,
pdfNames = ['example.pdf'],
images = [],
}: CreateEnvelopeWithContentsOptions) => {
const formData = new FormData();
formData.append(
'payload',
JSON.stringify({
type: EnvelopeType.DOCUMENT,
title: 'Envelope With Contents',
...payload,
contents,
}),
);
for (const pdfName of pdfNames) {
formData.append('files', new File([examplePdfBuffer], pdfName, { type: 'application/pdf' }));
}
for (const image of images) {
formData.append('contentImages', new File([image.buffer], image.name, { type: image.mimeType }));
}
return await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
};
const getEnvelope = async (request: APIRequestContext, token: string, envelopeId: string) => {
const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.ok()).toBeTruthy();
return (await res.json()) as TGetEnvelopeResponse;
};
/**
* Set the image content allowance on the organisation that owns the team.
*/
const setOrganisationContentImageLimit = async (team: Team, envelopeContentImageCount: number) => {
const organisationClaim = await prisma.organisationClaim.findFirstOrThrow({
where: {
organisation: {
id: team.organisationId,
},
},
});
await prisma.organisationClaim.update({
where: {
id: organisationClaim.id,
},
data: {
envelopeContentImageCount,
},
});
};
const countDataContentsNamed = async (fileName: string) =>
await prisma.dataContent.count({
where: {
metadata: {
path: ['fileName'],
equals: fileName,
},
},
});
const textMeta = (text: string): TEnvelopeContentMetaInput => ({
type: EnvelopeContentType.TEXT,
page: 1,
positionX: 10,
positionY: 10,
width: 40,
height: 5,
text,
});
const RECTANGLE_META: TEnvelopeContentMetaInput = {
type: EnvelopeContentType.SHAPE,
shape: EnvelopeContentShapeType.RECTANGLE,
page: 1,
positionX: 10,
positionY: 20,
width: 20,
height: 10,
};
const imageMeta = (positionY: number): TEnvelopeContentMetaInput => ({
type: EnvelopeContentType.IMAGE,
page: 1,
positionX: 10,
positionY,
width: 30,
height: 10,
});
test.describe('Create envelope with contents', () => {
let team: Team;
let token: string;
test.beforeEach(async () => {
const seeded = await seedUser();
team = seeded.team;
({ token } = await createApiToken({
userId: seeded.user.id,
teamId: team.id,
tokenName: 'test-envelope-create-contents',
expiresIn: null,
}));
});
for (const envelopeType of [EnvelopeType.DOCUMENT, EnvelopeType.TEMPLATE]) {
test(`creates a ${envelopeType.toLowerCase()} with contents and images`, async ({ request }) => {
const logo = await createImageFile(`logo-${nanoid()}.png`, 300, 100);
const res = await createEnvelopeWithContents({
request,
token,
payload: { type: envelopeType },
contents: [
{ contentMeta: textMeta('Hello') },
{ contentMeta: RECTANGLE_META },
{ contentMeta: imageMeta(40), imageIndex: 0 },
// A second content showing the same image.
{ contentMeta: imageMeta(60), imageIndex: 0 },
// An image content which has no image yet.
{ contentMeta: imageMeta(80) },
],
images: [logo],
});
expect(res.ok(), await res.text()).toBeTruthy();
const { id } = (await res.json()) as TCreateEnvelopeResponse;
// Contents are returned by the public get route.
const envelope = await getEnvelope(request, token, id);
const [envelopeItem] = envelope.envelopeItems;
expect(envelope.contents).toHaveLength(5);
expect(envelope.contents.every((content) => content.envelopeItemId === envelopeItem.id)).toBe(true);
const text = envelope.contents.find((content) => content.contentMeta.type === EnvelopeContentType.TEXT);
const shape = envelope.contents.find((content) => content.contentMeta.type === EnvelopeContentType.SHAPE);
expect(text?.contentMeta).toMatchObject({ text: 'Hello', page: 1, positionX: 10, positionY: 10 });
expect(shape?.contentMeta).toMatchObject({ shape: EnvelopeContentShapeType.RECTANGLE });
const images = envelope.contents.filter((content) => content.contentMeta.type === EnvelopeContentType.IMAGE);
const withImage = images.filter((content) => content.dataContentId !== null);
expect(images).toHaveLength(3);
expect(withImage).toHaveLength(2);
// The image is stored once and shared by both contents showing it.
expect(withImage[0].dataContentId).toBe(withImage[1].dataContentId);
const dataContent = await prisma.dataContent.findUniqueOrThrow({
where: { id: withImage[0].dataContentId ?? '' },
});
expect(dataContent.metadata).toMatchObject({
type: 'image',
width: 300,
height: 100,
mimeType: 'image/png',
fileName: logo.name,
});
});
}
test('places each content on the file it identifies', async ({ request }) => {
const res = await createEnvelopeWithContents({
request,
token,
pdfNames: ['first.pdf', 'second.pdf'],
contents: [
{ contentMeta: textMeta('Default') },
{ identifier: 'second.pdf', contentMeta: textMeta('By name') },
{ identifier: 1, contentMeta: textMeta('By index') },
],
});
expect(res.ok(), await res.text()).toBeTruthy();
const { id } = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await getEnvelope(request, token, id);
const [first, second] = [...envelope.envelopeItems].sort((a, b) => a.order - b.order);
const envelopeItemIdByText = Object.fromEntries(
envelope.contents.map((content) => [
content.contentMeta.type === EnvelopeContentType.TEXT ? content.contentMeta.text : content.id,
content.envelopeItemId,
]),
);
expect(envelopeItemIdByText).toEqual({
Default: first.id,
'By name': second.id,
'By index': second.id,
});
});
test('rejects contents over the limits without storing their images', async ({ request }) => {
await setOrganisationContentImageLimit(team, 1);
const externalId = `e2e-contents-limit-${nanoid()}`;
const images = await Promise.all([
createImageFile(`limit-a-${nanoid()}.png`, 50, 50),
createImageFile(`limit-b-${nanoid()}.png`, 50, 50),
]);
const res = await createEnvelopeWithContents({
request,
token,
payload: { externalId },
contents: [
{ contentMeta: imageMeta(40), imageIndex: 0 },
{ contentMeta: imageMeta(60), imageIndex: 1 },
],
images,
});
expect(res.status()).toBe(400);
expect(await res.text()).toContain('ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED');
expect(await prisma.envelope.count({ where: { externalId } })).toBe(0);
for (const image of images) {
expect(await countDataContentsNamed(image.name)).toBe(0);
}
});
test('rejects a content on a page its file does not have', async ({ request }) => {
const externalId = `e2e-contents-page-${nanoid()}`;
const image = await createImageFile(`page-${nanoid()}.png`, 50, 50);
// The example PDF has a single page.
const res = await createEnvelopeWithContents({
request,
token,
payload: { externalId },
contents: [{ contentMeta: { ...imageMeta(40), page: 2 }, imageIndex: 0 }],
images: [image],
});
expect(res.status()).toBe(400);
expect(await res.text()).toContain('only has 1 page(s)');
expect(await prisma.envelope.count({ where: { externalId } })).toBe(0);
// Rejected before the image was stored.
expect(await countDataContentsNamed(image.name)).toBe(0);
});
test('rejects an image index without an image', async ({ request }) => {
const externalId = `e2e-contents-index-${nanoid()}`;
const res = await createEnvelopeWithContents({
request,
token,
payload: { externalId },
contents: [{ contentMeta: imageMeta(40), imageIndex: 1 }],
images: [await createImageFile(`index-${nanoid()}.png`, 50, 50)],
});
expect(res.status()).toBe(400);
expect(await res.text()).toContain('Invalid content image index');
expect(await prisma.envelope.count({ where: { externalId } })).toBe(0);
});
test('rejects an image on a content which cannot hold one', async ({ request }) => {
const externalId = `e2e-contents-type-${nanoid()}`;
const image = await createImageFile(`type-${nanoid()}.png`, 50, 50);
const res = await createEnvelopeWithContents({
request,
token,
payload: { externalId },
contents: [{ contentMeta: textMeta('Not an image'), imageIndex: 0 }],
images: [image],
});
expect(res.status()).toBe(400);
expect(await res.text()).toContain('A text content cannot hold an image');
expect(await prisma.envelope.count({ where: { externalId } })).toBe(0);
expect(await countDataContentsNamed(image.name)).toBe(0);
});
for (const { identifier, description } of [
{ identifier: 'missing.pdf', description: 'a file name' },
{ identifier: 1, description: 'a file index' },
]) {
test(`rejects a content on ${description} which was not uploaded`, async ({ request }) => {
const externalId = `e2e-contents-identifier-${nanoid()}`;
const image = await createImageFile(`identifier-${nanoid()}.png`, 50, 50);
// Only "example.pdf" is uploaded, at index 0.
const res = await createEnvelopeWithContents({
request,
token,
payload: { externalId },
contents: [{ identifier, contentMeta: imageMeta(40), imageIndex: 0 }],
images: [image],
});
expect(res.status()).toBe(404);
expect(await res.text()).toContain('Document data not found');
expect(await prisma.envelope.count({ where: { externalId } })).toBe(0);
// Rejected before the image was stored.
expect(await countDataContentsNamed(image.name)).toBe(0);
});
}
test('rejects an image which is not really a PNG, JPEG or WebP', async ({ request }) => {
const externalId = `e2e-contents-format-${nanoid()}`;
const image = createGifLabelledAsPng(`format-${nanoid()}.png`);
const res = await createEnvelopeWithContents({
request,
token,
payload: { externalId },
contents: [{ contentMeta: imageMeta(40), imageIndex: 0 }],
images: [image],
});
expect(res.status()).toBe(400);
expect(await res.text()).toContain('Unsupported image format: gif');
expect(await prisma.envelope.count({ where: { externalId } })).toBe(0);
expect(await countDataContentsNamed(image.name)).toBe(0);
});
test('documents contents on the public create route', async ({ request }) => {
const res = await request.get(`${WEBAPP_BASE_URL}/api/v2/openapi.json`);
expect(res.ok()).toBeTruthy();
const openApiDocument: unknown = await res.json();
const requestSchemaPath = [
'paths',
'/envelope/create',
'post',
'requestBody',
'content',
'multipart/form-data',
'schema',
'properties',
];
expect(openApiDocument).toHaveProperty([...requestSchemaPath, 'contentImages']);
expect(openApiDocument).toHaveProperty([
...requestSchemaPath,
'payload',
'properties',
'contents',
'items',
'properties',
'imageIndex',
]);
});
});
@@ -0,0 +1,71 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { type APIRequestContext, expect, test } from '@playwright/test';
import { apiSeedDraftDocument } from '../../fixtures/api-seeds';
const API_BASE_URL = `${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta`;
/**
* `Recipient.signingOrder` is an Int column, but nothing constrained the input
* to an integer. Prisma does not reject a fraction — it truncates it (1.5 -> 1),
* so distinct orders could silently collapse onto the same value, which under
* signing groups means "same step". Negatives were persisted as-is and sort
* ahead of everything, including the `?? 0` fallback in assistant scoping.
* Zero remains valid for legacy handling.
*/
const createRecipient = async (request: APIRequestContext, token: string, envelopeId: string, signingOrder: number) =>
await request.post(`${API_BASE_URL}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: {
envelopeId,
data: [
{
email: `signing-order-${Date.now()}-${signingOrder}@documenso.com`,
name: 'Signing Order Test',
role: 'SIGNER',
signingOrder,
},
],
},
});
test('[SIGNING_ORDER_VALIDATION]: rejects a fractional signing order with a client error', async ({ request }) => {
const { envelope, token } = await apiSeedDraftDocument(request, { title: '[TEST] Signing order validation' });
const response = await createRecipient(request, token, envelope.id, 1.5);
expect(response.status()).toBe(400);
// Nothing may be written — in particular not a silently truncated `1`.
const recipients = await prisma.recipient.findMany({ where: { envelopeId: envelope.id } });
expect(recipients).toHaveLength(0);
});
test('[SIGNING_ORDER_VALIDATION]: rejects a negative signing order', async ({ request }) => {
const { envelope, token } = await apiSeedDraftDocument(request, {
title: '[TEST] Signing order validation negative',
});
const response = await createRecipient(request, token, envelope.id, -1);
expect(response.status()).toBe(400);
const persisted = await prisma.recipient.findMany({ where: { envelopeId: envelope.id, signingOrder: -1 } });
expect(persisted).toHaveLength(0);
});
test('[SIGNING_ORDER_VALIDATION]: still accepts a valid positive integer signing order', async ({ request }) => {
const { envelope, token } = await apiSeedDraftDocument(request, { title: '[TEST] Signing order validation valid' });
const response = await createRecipient(request, token, envelope.id, 2);
expect(response.ok(), await response.text()).toBeTruthy();
const persisted = await prisma.recipient.findMany({ where: { envelopeId: envelope.id, signingOrder: 2 } });
expect(persisted).toHaveLength(1);
});
@@ -0,0 +1,118 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { type APIRequestContext, expect, test } from '@playwright/test';
import { apiSeedDraftDocument } from '../../fixtures/api-seeds';
const API_BASE_URL = `${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta`;
/**
* AES/QES envelopes cannot contain signing groups: two recipients sharing a
* step sign in parallel, which breaks the per-recipient /ByteRange invariant
* TSP signatures depend on. That rule previously lived only in the editor's
* form schema, so the API would happily create the forbidden state.
*
* The signature level is seeded directly because `resolveSignatureLevel`
* coerces AES/QES down to SES on a non-CSC instance, so it cannot be requested
* through the API here.
*/
const createRecipients = async (
request: APIRequestContext,
token: string,
envelopeId: string,
recipients: Array<{ signingOrder?: number }>,
) =>
await request.post(`${API_BASE_URL}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: {
envelopeId,
data: recipients.map((recipient, index) => ({
email: `tsp-grouping-${Date.now()}-${index}@documenso.com`,
name: `TSP Recipient ${index}`,
role: 'SIGNER',
...recipient,
})),
},
});
const seedEnvelopeAtSignatureLevel = async (request: APIRequestContext, signatureLevel: string) => {
const { envelope, token } = await apiSeedDraftDocument(request, { title: `[TEST] ${signatureLevel} grouping` });
await prisma.envelope.update({ where: { id: envelope.id }, data: { signatureLevel } });
return { envelopeId: envelope.id, token };
};
test('[TSP_GROUPING]: rejects two recipients sharing a signing order on an AES envelope', async ({ request }) => {
const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'AES');
const response = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }, { signingOrder: 1 }]);
expect(response.status()).toBe(400);
const recipients = await prisma.recipient.findMany({ where: { envelopeId } });
expect(recipients).toHaveLength(0);
});
test('[TSP_GROUPING]: rejects a second recipient joining an existing step on an AES envelope', async ({ request }) => {
const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'AES');
const first = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }]);
expect(first.ok(), await first.text()).toBeTruthy();
// The payload alone looks fine — only the resulting set reveals the group.
const second = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }]);
expect(second.status()).toBe(400);
const recipients = await prisma.recipient.findMany({ where: { envelopeId } });
expect(recipients).toHaveLength(1);
});
test('[TSP_GROUPING]: numbers recipients created without a signing order on a QES envelope', async ({ request }) => {
const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'QES');
const response = await createRecipients(request, token, envelopeId, [{}, { signingOrder: 3 }, {}]);
expect(response.ok(), await response.text()).toBeTruthy();
const recipients = await prisma.recipient.findMany({ where: { envelopeId }, orderBy: { id: 'asc' } });
expect(recipients.map((recipient) => recipient.signingOrder)).toEqual([4, 3, 5]);
const second = await createRecipients(request, token, envelopeId, [{}]);
expect(second.ok(), await second.text()).toBeTruthy();
const recipientsAfter = await prisma.recipient.findMany({ where: { envelopeId }, orderBy: { id: 'asc' } });
expect(recipientsAfter.map((recipient) => recipient.signingOrder)).toEqual([4, 3, 5, 6]);
});
test('[TSP_GROUPING]: accepts distinct signing orders on an AES envelope', async ({ request }) => {
const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'AES');
const response = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }, { signingOrder: 2 }]);
expect(response.ok(), await response.text()).toBeTruthy();
const recipients = await prisma.recipient.findMany({ where: { envelopeId } });
expect(recipients).toHaveLength(2);
});
test('[TSP_GROUPING]: still allows signing groups on an SES envelope', async ({ request }) => {
const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'SES');
const response = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }, { signingOrder: 1 }]);
expect(response.ok(), await response.text()).toBeTruthy();
const recipients = await prisma.recipient.findMany({ where: { envelopeId } });
expect(recipients.map((recipient) => recipient.signingOrder)).toEqual([1, 1]);
});
@@ -0,0 +1,116 @@
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentSigningOrder, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
import { signDirectSignaturePad } from '../fixtures/signature';
/**
* An assistant sharing a signing step with an unsigned peer cannot dictate
* the next signer: the flow does not advance until the whole step completes,
* so the server ignores any dictated identity. The signing page must
* therefore not OFFER dictation in that state — historically it did, because
* the assistant's recipient list excludes their own group peers, and the
* client derived dictation eligibility from that truncated list while the
* server decided from the full one.
*/
test('[NEXT_RECIPIENT_DICTATION]: assistant with an unsigned group peer is not offered dictation', async ({ page }) => {
const { user, team } = await seedUser();
const { user: assistant } = await seedUser();
const { user: peerSigner } = await seedUser();
const { user: lastSigner } = await seedUser();
const { recipients, document } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [assistant, peerSigner, lastSigner],
recipientsCreateOptions: [
// The assistant shares step 1 with an unsigned peer; step 2 holds a
// single recipient — the exact shape where dictation looks available
// from the assistant's truncated recipient list.
{ signingOrder: 1, role: RecipientRole.ASSISTANT },
{ signingOrder: 1, role: RecipientRole.SIGNER },
{ signingOrder: 2, role: RecipientRole.SIGNER },
],
updateDocumentOptions: {
documentMeta: {
upsert: {
create: {
signingOrder: DocumentSigningOrder.SEQUENTIAL,
allowDictateNextSigner: true,
},
update: {
signingOrder: DocumentSigningOrder.SEQUENTIAL,
allowDictateNextSigner: true,
},
},
},
},
});
const assistantRecipient = recipients[0];
const lastRecipient = recipients[2];
const signUrl = `/sign/${assistantRecipient.token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Assist Document' })).toBeVisible();
await page.waitForTimeout(1000);
await page.getByRole('radio', { name: assistantRecipient.name }).click();
// Fill in the assistant's own fields.
for (const field of assistantRecipient.fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.SIGNATURE) {
await signDirectSignaturePad(page);
await page.getByRole('button', { name: 'Sign', exact: true }).click();
}
if (field.type === FieldType.TEXT) {
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
await page.getByRole('button', { name: 'Continue' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// The unsigned peer blocks advancement, so dictation must not be offered.
await expect(dialog.getByText('The next recipient to sign this document will be')).not.toBeVisible();
await expect(dialog.getByRole('button', { name: 'Update Recipient' })).not.toBeVisible();
// Later recipients' fields are still uninserted, so the confirm button
// reads "Proceed" rather than "Continue".
await dialog.getByRole('button', { name: /Continue|Proceed/ }).click();
await page.waitForURL(`${signUrl}/complete`);
// The assistant completed; nobody was renamed and the flow did not advance
// past the unsigned peer.
await expect
.poll(async () => {
const assistantAfter = await prisma.recipient.findUniqueOrThrow({
where: { id: assistantRecipient.id },
});
return assistantAfter.signingStatus;
})
.toBe(SigningStatus.SIGNED);
const lastAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: lastRecipient.id } });
expect(lastAfter.name).toBe(lastRecipient.name);
expect(lastAfter.email).toBe(lastRecipient.email);
const envelope = await prisma.envelope.findUniqueOrThrow({ where: { id: document.id } });
expect(envelope.status).toBe('PENDING');
});
@@ -0,0 +1,250 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token';
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentSigningOrder, FieldType, RecipientRole } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
/**
* Assistant scoping must follow the same position model as signing (numbered
* first, then unordered by id). Historically `signingOrder ?? 0` treated an
* unordered assistant as FIRST, letting their token prefill every ordered
* recipient's fields.
*/
const seedAssistantDocument = async (options: {
assistantOrder: number | null;
signerOrder: number | null;
internalVersion?: number;
}) => {
const { user, team } = await seedUser();
const { user: assistantUser } = await seedUser();
const { user: signerUser } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [assistantUser, signerUser],
recipientsCreateOptions: [
{ signingOrder: options.assistantOrder, role: RecipientRole.ASSISTANT },
{ signingOrder: options.signerOrder, role: RecipientRole.SIGNER },
],
fields: [FieldType.TEXT],
updateDocumentOptions: {
internalVersion: options.internalVersion ?? 1,
documentMeta: {
upsert: {
create: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
},
},
},
});
// The seed returns recipients ordered by signingOrder (nulls last), so
// positional destructuring would swap roles — select by role instead.
const assistant = recipients.find((recipient) => recipient.role === RecipientRole.ASSISTANT);
const signer = recipients.find((recipient) => recipient.role === RecipientRole.SIGNER);
if (!assistant || !signer) {
throw new Error('Seeded recipients not found');
}
const signerTextField = signer.fields.find((field) => field.type === FieldType.TEXT);
if (!signerTextField) {
throw new Error('Seeded text field not found');
}
return { assistant, signer, signerTextField };
};
const callSignEnvelopeField = async (page: Page, input: { token: string; fieldId: number }) => {
return await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.field.sign`, {
headers: { 'content-type': 'application/json' },
data: JSON.stringify({
json: {
token: input.token,
fieldId: input.fieldId,
fieldValue: {
type: FieldType.TEXT,
value: 'TEXT',
},
},
}),
});
};
test('[ASSISTANT_NULL_ORDER]: an ordered assistant can assist a null-order (tail-step) recipient', async () => {
const { assistant, signer, signerTextField } = await seedAssistantDocument({
assistantOrder: 1,
signerOrder: null,
});
// The tail-step recipient is strictly later, so they must be assistable.
const assistableRecipients = await getRecipientsForAssistant({ token: assistant.token });
expect(assistableRecipients.map((recipient) => recipient.id)).toContain(signer.id);
// Their non-signature fields must be visible to the assistant.
const fields = await getFieldsForToken({ token: assistant.token });
expect(fields.map((field) => field.id)).toContain(signerTextField.id);
// And prefillable.
await signFieldWithToken({
token: assistant.token,
fieldId: signerTextField.id,
value: 'TEXT',
});
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: signerTextField.id } });
expect(fieldAfter.inserted).toBe(true);
});
test('[ASSISTANT_NULL_ORDER]: a null-order assistant cannot assist an ordered recipient', async () => {
const { assistant, signerTextField } = await seedAssistantDocument({
assistantOrder: null,
signerOrder: 1,
});
const assistableRecipients = await getRecipientsForAssistant({ token: assistant.token });
expect(assistableRecipients.map((recipient) => recipient.id)).toEqual([assistant.id]);
const fields = await getFieldsForToken({ token: assistant.token });
expect(fields.map((field) => field.id)).not.toContain(signerTextField.id);
await expect(
signFieldWithToken({
token: assistant.token,
fieldId: signerTextField.id,
value: 'TEXT',
}),
).rejects.toThrow();
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: signerTextField.id } });
expect(fieldAfter.inserted).toBe(false);
});
test('[ASSISTANT_NULL_ORDER]: a null-order assistant can assist a null-order recipient created after them', async () => {
const { assistant, signer, signerTextField } = await seedAssistantDocument({
assistantOrder: null,
signerOrder: null,
});
const assistableRecipients = await getRecipientsForAssistant({ token: assistant.token });
expect(assistableRecipients.map((recipient) => recipient.id)).toEqual([assistant.id, signer.id]);
const fields = await getFieldsForToken({ token: assistant.token });
expect(fields.map((field) => field.id)).toContain(signerTextField.id);
await signFieldWithToken({
token: assistant.token,
fieldId: signerTextField.id,
value: 'TEXT',
});
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: signerTextField.id } });
expect(fieldAfter.inserted).toBe(true);
});
test('[ASSISTANT_NULL_ORDER]: a null-order recipient cannot be assisted by a null-order assistant created after them', async () => {
const { user, team } = await seedUser();
const { user: signerUser } = await seedUser();
const { user: assistantUser } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [signerUser, assistantUser],
recipientsCreateOptions: [
{ signingOrder: null, role: RecipientRole.SIGNER },
{ signingOrder: null, role: RecipientRole.ASSISTANT },
],
fields: [FieldType.TEXT],
updateDocumentOptions: {
documentMeta: {
upsert: {
create: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
},
},
},
});
const assistant = recipients.find((recipient) => recipient.role === RecipientRole.ASSISTANT);
const signerTextField = recipients
.find((recipient) => recipient.role === RecipientRole.SIGNER)
?.fields.find((field) => field.type === FieldType.TEXT);
if (!assistant || !signerTextField) {
throw new Error('Seeded recipients not found');
}
const assistableRecipients = await getRecipientsForAssistant({ token: assistant.token });
expect(assistableRecipients.map((recipient) => recipient.id)).toEqual([assistant.id]);
await expect(
signFieldWithToken({
token: assistant.token,
fieldId: signerTextField.id,
value: 'TEXT',
}),
).rejects.toThrow();
});
test('[ASSISTANT_NULL_ORDER]: V2 route allows an ordered assistant to prefill a null-order recipient', async ({
page,
}) => {
const { assistant, signerTextField } = await seedAssistantDocument({
assistantOrder: 1,
signerOrder: null,
internalVersion: 2,
});
const response = await callSignEnvelopeField(page, {
token: assistant.token,
fieldId: signerTextField.id,
});
expect(response.ok()).toBeTruthy();
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: signerTextField.id } });
expect(fieldAfter.inserted).toBe(true);
});
test('[ASSISTANT_NULL_ORDER]: V2 route rejects a null-order assistant prefilling an ordered recipient', async ({
page,
}) => {
const { assistant, signerTextField } = await seedAssistantDocument({
assistantOrder: null,
signerOrder: 1,
internalVersion: 2,
});
const response = await callSignEnvelopeField(page, {
token: assistant.token,
fieldId: signerTextField.id,
});
expect(response.ok()).toBeFalsy();
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: signerTextField.id } });
expect(fieldAfter.inserted).toBe(false);
});
@@ -0,0 +1,266 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { prisma } from '@documenso/prisma';
import { type APIRequestContext, expect, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
import { apiSeedPendingDocument } from '../fixtures/api-seeds';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
type SeededGroupEnvelope = {
assistantToken: string;
assistantOwnTextFieldId: number;
peerTextFieldId: number;
peerSignatureFieldId: number;
laterTextFieldId: number;
laterSignatureFieldId: number;
};
/**
* Seeds a pending SEQUENTIAL envelope where the ASSISTANT shares a signing
* step (duplicate signingOrder) with a SIGNER:
*
* - Step 1: ASSISTANT (own TEXT field) + "Peer Signer" (SIGNATURE + TEXT).
* - Step 2: "Later Signer" (SIGNATURE + TEXT).
*
* Product rule under signing groups: assistants only assist STRICTLY LATER
* steps — never their own group peers — and never insert SIGNATURE fields
* belonging to anyone else.
*/
const seedGroupedAssistantEnvelope = async (request: APIRequestContext): Promise<SeededGroupEnvelope> => {
const timestamp = Date.now();
const peerEmail = `peer-signer-${timestamp}@documenso.com`;
const laterEmail = `later-signer-${timestamp}@documenso.com`;
const { envelope, distributeResult } = await apiSeedPendingDocument(request, {
title: '[TEST] Grouped assistant envelope',
meta: {
signingOrder: 'SEQUENTIAL',
},
recipients: [
{
email: `assistant-${timestamp}@documenso.com`,
name: 'Assistant',
role: 'ASSISTANT',
signingOrder: 1,
},
{
email: peerEmail,
name: 'Peer Signer',
role: 'SIGNER',
signingOrder: 1,
},
{
email: laterEmail,
name: 'Later Signer',
role: 'SIGNER',
signingOrder: 2,
},
],
fieldsPerRecipient: [
[{ type: FieldType.TEXT, page: 1, positionX: 5, positionY: 5, width: 5, height: 5 }],
[
{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 15, width: 5, height: 5 },
{ type: FieldType.TEXT, page: 1, positionX: 5, positionY: 25, width: 5, height: 5 },
],
[
{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 35, width: 5, height: 5 },
{ type: FieldType.TEXT, page: 1, positionX: 5, positionY: 45, width: 5, height: 5 },
],
],
});
const assistant = distributeResult.recipients.find((r) => r.role === 'ASSISTANT');
const peer = distributeResult.recipients.find((r) => r.email === peerEmail);
const later = distributeResult.recipients.find((r) => r.email === laterEmail);
if (!assistant || !peer || !later) {
throw new Error('Seeded recipients not found');
}
const fields = await prisma.field.findMany({
where: { envelopeId: envelope.id },
});
const findField = (recipientId: number, type: FieldType) => {
const field = fields.find((f) => f.recipientId === recipientId && f.type === type);
if (!field) {
throw new Error(`Field ${type} not found for recipient ${recipientId}`);
}
return field;
};
return {
assistantToken: assistant.token,
assistantOwnTextFieldId: findField(assistant.id, FieldType.TEXT).id,
peerTextFieldId: findField(peer.id, FieldType.TEXT).id,
peerSignatureFieldId: findField(peer.id, FieldType.SIGNATURE).id,
laterTextFieldId: findField(later.id, FieldType.TEXT).id,
laterSignatureFieldId: findField(later.id, FieldType.SIGNATURE).id,
};
};
const trpcMutation = async (request: APIRequestContext, procedure: string, input: Record<string, unknown>) => {
return await request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, {
headers: { 'content-type': 'application/json' },
data: JSON.stringify({ json: input }),
});
};
test.describe('[ASSISTANT_SIGNING_GROUPS]: same-step (group peer) field access', () => {
test('field.signFieldWithToken (V1) rejects a group peer field', async ({ request }) => {
const { assistantToken, peerTextFieldId } = await seedGroupedAssistantEnvelope(request);
const res = await trpcMutation(request, 'field.signFieldWithToken', {
token: assistantToken,
fieldId: peerTextFieldId,
value: 'TEXT',
isBase64: false,
});
expect(res.ok()).toBeFalsy();
const fieldAfter = await prisma.field.findUniqueOrThrow({
where: { id: peerTextFieldId },
});
expect(fieldAfter.inserted).toBe(false);
expect(fieldAfter.customText).toBe('');
});
test('field.removeSignedFieldWithToken (V1) rejects a group peer field', async ({ request }) => {
const { assistantToken, peerTextFieldId } = await seedGroupedAssistantEnvelope(request);
// Pre-insert the peer's field so a successful (incorrect) uninsert is detectable.
await prisma.field.update({
where: { id: peerTextFieldId },
data: { inserted: true, customText: 'pre-existing-value' },
});
const res = await trpcMutation(request, 'field.removeSignedFieldWithToken', {
token: assistantToken,
fieldId: peerTextFieldId,
});
expect(res.ok()).toBeFalsy();
const fieldAfter = await prisma.field.findUniqueOrThrow({
where: { id: peerTextFieldId },
});
expect(fieldAfter.inserted).toBe(true);
expect(fieldAfter.customText).toBe('pre-existing-value');
});
test('envelope.field.sign (V2) rejects a group peer field', async ({ request }) => {
const { assistantToken, peerTextFieldId } = await seedGroupedAssistantEnvelope(request);
const res = await trpcMutation(request, 'envelope.field.sign', {
token: assistantToken,
fieldId: peerTextFieldId,
fieldValue: { type: FieldType.TEXT, value: 'TEXT' },
});
expect(res.ok()).toBeFalsy();
const fieldAfter = await prisma.field.findUniqueOrThrow({
where: { id: peerTextFieldId },
});
expect(fieldAfter.inserted).toBe(false);
});
test('getFieldsForToken excludes group peer fields but keeps own and later-step fields', async ({ request }) => {
const {
assistantToken,
assistantOwnTextFieldId,
peerTextFieldId,
peerSignatureFieldId,
laterTextFieldId,
laterSignatureFieldId,
} = await seedGroupedAssistantEnvelope(request);
const fields = await getFieldsForToken({ token: assistantToken });
const fieldIds = fields.map((field) => field.id);
// Own fields and strictly-later non-signature fields remain visible.
expect(fieldIds).toContain(assistantOwnTextFieldId);
expect(fieldIds).toContain(laterTextFieldId);
// Group peer fields are never visible to the assistant.
expect(fieldIds).not.toContain(peerTextFieldId);
expect(fieldIds).not.toContain(peerSignatureFieldId);
// Signature fields of other recipients are never visible to the assistant.
expect(fieldIds).not.toContain(laterSignatureFieldId);
});
});
test.describe('[ASSISTANT_SIGNING_GROUPS]: signature fields of other recipients', () => {
test('field.signFieldWithToken (V1) rejects inserting a later recipient signature field', async ({ request }) => {
const { assistantToken, laterSignatureFieldId } = await seedGroupedAssistantEnvelope(request);
const res = await trpcMutation(request, 'field.signFieldWithToken', {
token: assistantToken,
fieldId: laterSignatureFieldId,
value: 'John Doe',
isBase64: false,
});
expect(res.ok()).toBeFalsy();
const fieldAfter = await prisma.field.findUniqueOrThrow({
where: { id: laterSignatureFieldId },
include: { signature: true },
});
expect(fieldAfter.inserted).toBe(false);
expect(fieldAfter.signature).toBeNull();
});
});
test.describe('[ASSISTANT_SIGNING_GROUPS]: preserved assistant abilities', () => {
test('field.signFieldWithToken (V1) still allows filling the assistant own field', async ({ request }) => {
const { assistantToken, assistantOwnTextFieldId } = await seedGroupedAssistantEnvelope(request);
const res = await trpcMutation(request, 'field.signFieldWithToken', {
token: assistantToken,
fieldId: assistantOwnTextFieldId,
value: 'MY OWN TEXT',
isBase64: false,
});
expect(res.ok(), await res.text()).toBeTruthy();
const fieldAfter = await prisma.field.findUniqueOrThrow({
where: { id: assistantOwnTextFieldId },
});
expect(fieldAfter.inserted).toBe(true);
expect(fieldAfter.customText).toBe('MY OWN TEXT');
});
test('field.signFieldWithToken (V1) still allows prefilling a later recipient text field', async ({ request }) => {
const { assistantToken, laterTextFieldId } = await seedGroupedAssistantEnvelope(request);
const res = await trpcMutation(request, 'field.signFieldWithToken', {
token: assistantToken,
fieldId: laterTextFieldId,
value: 'PREFILLED FOR LATER SIGNER',
isBase64: false,
});
expect(res.ok(), await res.text()).toBeTruthy();
const fieldAfter = await prisma.field.findUniqueOrThrow({
where: { id: laterTextFieldId },
});
expect(fieldAfter.inserted).toBe(true);
expect(fieldAfter.customText).toBe('PREFILLED FOR LATER SIGNER');
});
});

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