mirror of
https://github.com/documenso/documenso.git
synced 2026-09-30 08:44:39 +10:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
724f5f62c5 | ||
|
|
58b5e6b5d3 | ||
|
|
573c928a0e | ||
|
|
d87ac2b552 | ||
|
|
be94bddd40 | ||
|
|
5a123be46c | ||
|
|
586b1f5cb5 | ||
|
|
b90aa86e24 | ||
|
|
3c84d25d5a | ||
|
|
c96ee7c84f | ||
|
|
941f4a1e4c | ||
|
|
30a0979573 | ||
|
|
a1d4bec143 | ||
|
|
94793c7cce |
@@ -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>
|
||||
|
||||
+4
-2
@@ -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>
|
||||
</>
|
||||
|
||||
+8
-29
@@ -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;
|
||||
|
||||
|
||||
+7
-1
@@ -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 (
|
||||
|
||||
+131
@@ -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>
|
||||
);
|
||||
};
|
||||
+423
@@ -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>
|
||||
);
|
||||
};
|
||||
+169
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+40
@@ -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>
|
||||
);
|
||||
};
|
||||
+34
@@ -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;
|
||||
};
|
||||
+112
@@ -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();
|
||||
}
|
||||
};
|
||||
+448
@@ -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,
|
||||
};
|
||||
};
|
||||
+472
@@ -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,
|
||||
};
|
||||
};
|
||||
+195
@@ -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 };
|
||||
};
|
||||
+201
@@ -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()),
|
||||
);
|
||||
};
|
||||
+88
@@ -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 };
|
||||
};
|
||||
+258
@@ -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),
|
||||
};
|
||||
};
|
||||
+411
@@ -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();
|
||||
};
|
||||
+11
-1
@@ -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(() => {
|
||||
|
||||
+231
-878
File diff suppressed because it is too large
Load Diff
+464
-344
@@ -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 } 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,33 +142,10 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
const selectedField = useMemo(() => structuredClone(editorFields.selectedField), [editorFields.selectedField]);
|
||||
|
||||
const [copiedField, setCopiedField] = useState<TLocalField | null>(null);
|
||||
|
||||
useHotkeys(['ctrl+c', 'meta+c'], (event) => {
|
||||
// Keep the native copy when the user has selected text.
|
||||
if (!selectedField || window.getSelection()?.toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
setCopiedField(selectedField);
|
||||
|
||||
toast({
|
||||
title: _(msg`Copied field`),
|
||||
description: _(msg`Copied field to clipboard`),
|
||||
});
|
||||
});
|
||||
|
||||
useHotkeys(['ctrl+v', 'meta+v'], (event) => {
|
||||
if (!copiedField) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// Paste the next copy offset from the last one, so repeated pastes do not stack.
|
||||
setCopiedField(editorFields.duplicateField(copiedField));
|
||||
});
|
||||
const selectedContent = useMemo(
|
||||
() => structuredClone(editorContents.selectedContent),
|
||||
[editorContents.selectedContent],
|
||||
);
|
||||
|
||||
/**
|
||||
* Debounce the fields used for overlap detection so we don't recompute on every
|
||||
@@ -218,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);
|
||||
@@ -234,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">
|
||||
|
||||
+43
-542
@@ -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"
|
||||
|
||||
+1
@@ -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>
|
||||
|
||||
+64
-3
@@ -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>
|
||||
);
|
||||
};
|
||||
+33
-1
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
Generated
+7
-7
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user