Merge branch 'main' into feat/expiry-links

This commit is contained in:
Lucas Smith
2025-09-09 17:09:54 +10:00
committed by GitHub
31 changed files with 2746 additions and 383 deletions

View File

@ -19,13 +19,13 @@ device, and other FDA-regulated industries.
- [x] User Access Management - [x] User Access Management
- [x] Quality Assurance Documentation - [x] Quality Assurance Documentation
## SOC/ SOC II ## SOC 2
<Callout type="warning" emoji=""> <Callout type="info" emoji="">
Status: [Planned](https://github.com/documenso/backlog/issues/24) Status: [Compliant](https://documen.so/trust)
</Callout> </Callout>
SOC II is a framework for managing and auditing the security, availability, processing integrity, confidentiality, SOC 2 is a framework for managing and auditing the security, availability, processing integrity, confidentiality,
and data privacy in cloud and IT service organizations, established by the American Institute of Certified and data privacy in cloud and IT service organizations, established by the American Institute of Certified
Public Accountants (AICPA). Public Accountants (AICPA).
@ -34,9 +34,9 @@ Public Accountants (AICPA).
<Callout type="warning" emoji="⏳"> <Callout type="warning" emoji="⏳">
Status: [Planned](https://github.com/documenso/backlog/issues/26) Status: [Planned](https://github.com/documenso/backlog/issues/26)
</Callout> </Callout>
ISO 27001 is an international standard for managing information security, specifying requirements for ISO 27001 is an international standard for managing information security, specifying requirements
establishing, implementing, maintaining, and continually improving an information security management for establishing, implementing, maintaining, and continually improving an information security
system (ISMS). management system (ISMS).
### HIPAA ### HIPAA

View File

@ -57,7 +57,7 @@ export const EmbedDirectTemplateClientPage = ({
token, token,
updatedAt, updatedAt,
documentData, documentData,
recipient, recipient: _recipient,
fields, fields,
metadata, metadata,
hidePoweredBy = false, hidePoweredBy = false,
@ -95,6 +95,8 @@ export const EmbedDirectTemplateClientPage = ({
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE); const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
const { mutateAsync: createDocumentFromDirectTemplate, isPending: isSubmitting } = const { mutateAsync: createDocumentFromDirectTemplate, isPending: isSubmitting } =
trpc.template.createDocumentFromDirectTemplate.useMutation(); trpc.template.createDocumentFromDirectTemplate.useMutation();
@ -345,19 +347,34 @@ export const EmbedDirectTemplateClientPage = ({
<Trans>Sign document</Trans> <Trans>Sign document</Trans>
</h3> </h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
{isExpanded ? ( {isExpanded ? (
<LucideChevronDown <Button
className="text-muted-foreground h-5 w-5" variant="outline"
className="h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(false)} onClick={() => setIsExpanded(false)}
/> >
) : ( <LucideChevronDown className="text-muted-foreground h-5 w-5" />
<LucideChevronUp
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(true)}
/>
)}
</Button> </Button>
) : pendingFields.length > 0 ? (
<Button
variant="outline"
className="h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(true)}
>
<LucideChevronUp className="text-muted-foreground h-5 w-5" />
</Button>
) : (
<Button
variant="default"
size="sm"
className="md:hidden"
disabled={isThrottled || (hasSignatureField && !signatureValid)}
loading={isSubmitting}
onClick={() => throttledOnCompleteClick()}
>
<Trans>Complete</Trans>
</Button>
)}
</div> </div>
</div> </div>

View File

@ -89,7 +89,7 @@ export const EmbedSignDocumentClientPage = ({
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [isNameLocked, setIsNameLocked] = useState(false); const [isNameLocked, setIsNameLocked] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false); const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const [showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] = const [_showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
useState(false); useState(false);
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false); const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
@ -118,6 +118,8 @@ export const EmbedSignDocumentClientPage = ({
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE); const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
const assistantSignersId = useId(); const assistantSignersId = useId();
const onNextFieldClick = () => { const onNextFieldClick = () => {
@ -307,19 +309,36 @@ export const EmbedSignDocumentClientPage = ({
)} )}
</h3> </h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
{isExpanded ? ( {isExpanded ? (
<LucideChevronDown <Button
className="text-muted-foreground h-5 w-5" variant="outline"
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(false)} onClick={() => setIsExpanded(false)}
/> >
) : ( <LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
<LucideChevronUp
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(true)}
/>
)}
</Button> </Button>
) : pendingFields.length > 0 ? (
<Button
variant="outline"
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(true)}
>
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
</Button>
) : (
<Button
variant="default"
size="sm"
className="md:hidden"
disabled={
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
}
loading={isSubmitting}
onClick={() => throttledOnCompleteClick()}
>
<Trans>Complete</Trans>
</Button>
)}
</div> </div>
</div> </div>

View File

@ -7,14 +7,11 @@ import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/cl
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
@ -34,29 +31,33 @@ export type DocumentSigningFormProps = {
document: DocumentAndSender; document: DocumentAndSender;
recipient: Recipient; recipient: Recipient;
fields: Field[]; fields: Field[];
redirectUrl?: string | null;
isRecipientsTurn: boolean; isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[]; allRecipients?: RecipientWithFields[];
setSelectedSignerId?: (id: number | null) => void; setSelectedSignerId?: (id: number | null) => void;
completeDocument: (
authOptions?: TRecipientActionAuth,
nextSigner?: { email: string; name: string },
) => Promise<void>;
isSubmitting: boolean;
fieldsValidated: () => void;
nextRecipient?: RecipientWithFields;
}; };
export const DocumentSigningForm = ({ export const DocumentSigningForm = ({
document, document,
recipient, recipient,
fields, fields,
redirectUrl,
isRecipientsTurn, isRecipientsTurn,
allRecipients = [], allRecipients = [],
setSelectedSignerId, setSelectedSignerId,
completeDocument,
isSubmitting,
fieldsValidated,
nextRecipient,
}: DocumentSigningFormProps) => { }: DocumentSigningFormProps) => {
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
const analytics = useAnalytics();
const assistantSignersId = useId(); const assistantSignersId = useId();
@ -66,21 +67,12 @@ export const DocumentSigningForm = ({
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false); const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false); const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
const {
mutateAsync: completeDocumentWithToken,
isPending,
isSuccess,
} = trpc.recipient.completeDocumentWithToken.useMutation();
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({ const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
defaultValues: { defaultValues: {
selectedSignerId: undefined, selectedSignerId: undefined,
}, },
}); });
// Keep the loading state going if successful since the redirect may take some time.
const isSubmitting = isPending || isSuccess;
const fieldsRequiringValidation = useMemo( const fieldsRequiringValidation = useMemo(
() => fields.filter(isFieldUnsignedAndRequired), () => fields.filter(isFieldUnsignedAndRequired),
[fields], [fields],
@ -96,9 +88,9 @@ export const DocumentSigningForm = ({
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id); return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
}, [fieldsRequiringValidation, recipient]); }, [fieldsRequiringValidation, recipient]);
const fieldsValidated = () => { const localFieldsValidated = () => {
setValidateUninsertedFields(true); setValidateUninsertedFields(true);
validateFieldsInserted(fieldsRequiringValidation); fieldsValidated();
}; };
const onAssistantFormSubmit = () => { const onAssistantFormSubmit = () => {
@ -126,55 +118,6 @@ export const DocumentSigningForm = ({
} }
}; };
const completeDocument = async (
authOptions?: TRecipientActionAuth,
nextSigner?: { email: string; name: string },
) => {
const payload = {
token: recipient.token,
documentId: document.id,
authOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
await completeDocumentWithToken(payload);
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: document.id,
timestamp: new Date().toISOString(),
});
if (redirectUrl) {
window.location.href = redirectUrl;
} else {
await navigate(`/sign/${recipient.token}/complete`);
}
};
const nextRecipient = useMemo(() => {
if (
!document.documentMeta?.signingOrder ||
document.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]);
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{validateUninsertedFields && uninsertedFields[0] && ( {validateUninsertedFields && uninsertedFields[0] && (
@ -205,7 +148,7 @@ export const DocumentSigningForm = ({
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
documentTitle={document.title} documentTitle={document.title}
fields={fields} fields={fields}
fieldsValidated={fieldsValidated} fieldsValidated={localFieldsValidated}
onSignatureComplete={async (nextSigner) => { onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner); await completeDocument(undefined, nextSigner);
}} }}
@ -364,7 +307,7 @@ export const DocumentSigningForm = ({
isSubmitting={isSubmitting || isAssistantSubmitting} isSubmitting={isSubmitting || isAssistantSubmitting}
documentTitle={document.title} documentTitle={document.title}
fields={fields} fields={fields}
fieldsValidated={fieldsValidated} fieldsValidated={localFieldsValidated}
disabled={!isRecipientsTurn} disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) => { onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner); await completeDocument(undefined, nextSigner);

View File

@ -1,15 +1,18 @@
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { Field } from '@prisma/client'; import type { Field } from '@prisma/client';
import { FieldType, RecipientRole } from '@prisma/client'; import { FieldType, RecipientRole } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { match } from 'ts-pattern'; import { useNavigate } from 'react-router';
import { P, match } from 'ts-pattern';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { import {
ZCheckboxFieldMeta, ZCheckboxFieldMeta,
ZDropdownFieldMeta, ZDropdownFieldMeta,
@ -18,8 +21,11 @@ import {
ZTextFieldMeta, ZTextFieldMeta,
} from '@documenso/lib/types/field-meta'; } from '@documenso/lib/types/field-meta';
import type { CompletedField } from '@documenso/lib/types/fields'; import type { CompletedField } from '@documenso/lib/types/fields';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields'; import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -40,6 +46,7 @@ import { DocumentSigningRejectDialog } from '~/components/general/document-signi
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field'; import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field'; import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider'; import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
export type DocumentSigningPageViewProps = { export type DocumentSigningPageViewProps = {
@ -63,9 +70,56 @@ export const DocumentSigningPageView = ({
}: DocumentSigningPageViewProps) => { }: DocumentSigningPageViewProps) => {
const { documentData, documentMeta } = document; const { documentData, documentMeta } = document;
const navigate = useNavigate();
const analytics = useAnalytics();
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id); const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const {
mutateAsync: completeDocumentWithToken,
isPending,
isSuccess,
} = trpc.recipient.completeDocumentWithToken.useMutation();
// Keep the loading state going if successful since the redirect may take some time.
const isSubmitting = isPending || isSuccess;
const fieldsRequiringValidation = useMemo(
() => fields.filter(isFieldUnsignedAndRequired),
[fields],
);
const fieldsValidated = () => {
validateFieldsInserted(fieldsRequiringValidation);
};
const completeDocument = async (
authOptions?: TRecipientActionAuth,
nextSigner?: { email: string; name: string },
) => {
const payload = {
token: recipient.token,
documentId: document.id,
authOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
await completeDocumentWithToken(payload);
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: document.id,
timestamp: new Date().toISOString(),
});
if (documentMeta?.redirectUrl) {
window.location.href = documentMeta.redirectUrl;
} else {
await navigate(`/sign/${recipient.token}/complete`);
}
};
let senderName = document.user.name ?? ''; let senderName = document.user.name ?? '';
let senderEmail = `(${document.user.email})`; let senderEmail = `(${document.user.email})`;
@ -78,8 +132,31 @@ export const DocumentSigningPageView = ({
const targetSigner = const targetSigner =
recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null; 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 highestPageNumber = Math.max(...fields.map((field) => field.page)); const highestPageNumber = Math.max(...fields.map((field) => field.page));
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
const hasPendingFields = pendingFields.length > 0;
return ( return (
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}> <DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
<div className="mx-auto w-full max-w-screen-xl sm:px-6"> <div className="mx-auto w-full max-w-screen-xl sm:px-6">
@ -165,19 +242,55 @@ export const DocumentSigningPageView = ({
.otherwise(() => null)} .otherwise(() => null)}
</h3> </h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden"> {match({ hasPendingFields, isExpanded, role: recipient.role })
{isExpanded ? ( .with(
<LucideChevronDown {
className="text-muted-foreground h-5 w-5" hasPendingFields: false,
role: P.not(RecipientRole.ASSISTANT),
isExpanded: false,
},
() => (
<div className="md:hidden">
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
allowDictateNextSigner={
nextRecipient && documentMeta?.allowDictateNextSigner
}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
/>
</div>
),
)
.with({ isExpanded: true }, () => (
<Button
variant="outline"
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(false)} onClick={() => setIsExpanded(false)}
/> >
) : ( <LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
<LucideChevronUp
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(true)}
/>
)}
</Button> </Button>
))
.otherwise(() => (
<Button
variant="outline"
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(true)}
>
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
</Button>
))}
</div> </div>
<div className="hidden group-data-[expanded]/document-widget:block md:block"> <div className="hidden group-data-[expanded]/document-widget:block md:block">
@ -206,10 +319,13 @@ export const DocumentSigningPageView = ({
document={document} document={document}
recipient={recipient} recipient={recipient}
fields={fields} fields={fields}
redirectUrl={documentMeta?.redirectUrl}
isRecipientsTurn={isRecipientsTurn} isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients} allRecipients={allRecipients}
setSelectedSignerId={setSelectedSignerId} setSelectedSignerId={setSelectedSignerId}
completeDocument={completeDocument}
isSubmitting={isSubmitting}
fieldsValidated={fieldsValidated}
nextRecipient={nextRecipient}
/> />
</div> </div>
</div> </div>

View File

@ -159,15 +159,14 @@ export const DocumentEditForm = ({
return initialStep; return initialStep;
}); });
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => { const saveSettingsData = async (data: TAddSettingsFormSchema) => {
try {
const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta; const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta;
const parsedGlobalAccessAuth = z const parsedGlobalAccessAuth = z
.array(ZDocumentAccessAuthTypesSchema) .array(ZDocumentAccessAuthTypesSchema)
.safeParse(data.globalAccessAuth); .safeParse(data.globalAccessAuth);
await updateDocument({ return updateDocument({
documentId: document.id, documentId: document.id,
data: { data: {
title: data.title, title: data.title,
@ -184,11 +183,13 @@ export const DocumentEditForm = ({
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
expiryAmount: data.meta.expiryAmount,
expiryUnit: data.meta.expiryUnit,
}, },
}); });
};
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
try {
await saveSettingsData(data);
setStep('signers'); setStep('signers');
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -201,14 +202,36 @@ export const DocumentEditForm = ({
} }
}; };
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => { const onAddSettingsFormAutoSave = async (data: TAddSettingsFormSchema) => {
try { try {
await Promise.all([ await saveSettingsData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the document settings.`),
variant: 'destructive',
});
}
};
const saveSignersData = async (data: TAddSignersFormSchema) => {
return Promise.all([
updateDocument({ updateDocument({
documentId: document.id, documentId: document.id,
meta: { meta: {
timezone,
dateFormat,
redirectUrl,
language: isValidLanguageCode(language) ? language : undefined,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
allowDictateNextSigner: data.allowDictateNextSigner, allowDictateNextSigner: data.allowDictateNextSigner,
signingOrder: data.signingOrder, signingOrder: data.signingOrder,
expiryAmount: data.meta.expiryAmount,
expiryUnit: data.meta.expiryUnit,
}, },
}), }),
@ -221,6 +244,25 @@ export const DocumentEditForm = ({
})), })),
}), }),
]); ]);
};
const onAddSignersFormAutoSave = async (data: TAddSignersFormSchema) => {
try {
await saveSignersData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while adding signers.`),
variant: 'destructive',
});
}
};
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
try {
await saveSignersData(data);
setStep('fields'); setStep('fields');
} catch (err) { } catch (err) {
@ -234,12 +276,16 @@ export const DocumentEditForm = ({
} }
}; };
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => { const saveFieldsData = async (data: TAddFieldsFormSchema) => {
try { return addFields({
await addFields({
documentId: document.id, documentId: document.id,
fields: data.fields, fields: data.fields,
}); });
};
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
try {
await saveFieldsData(data);
// Clear all field data from localStorage // Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
@ -261,12 +307,43 @@ export const DocumentEditForm = ({
} }
}; };
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => { const onAddFieldsFormAutoSave = async (data: TAddFieldsFormSchema) => {
try {
await saveFieldsData(data);
// Don't clear localStorage on auto-save, only on explicit submit
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the fields.`),
variant: 'destructive',
});
}
};
const saveSubjectData = async (data: TAddSubjectFormSchema) => {
const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } = const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } =
data.meta; data.meta;
try { return updateDocument({
await sendDocument({ documentId: document.id,
meta: {
subject,
message,
distributionMethod,
emailId,
emailReplyTo,
emailSettings: emailSettings,
},
});
};
const sendDocumentWithSubject = async (data: TAddSubjectFormSchema) => {
const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } =
data.meta;
return sendDocument({
documentId: document.id, documentId: document.id,
meta: { meta: {
subject, subject,
@ -274,11 +351,16 @@ export const DocumentEditForm = ({
distributionMethod, distributionMethod,
emailId, emailId,
emailReplyTo: emailReplyTo || null, emailReplyTo: emailReplyTo || null,
emailSettings: emailSettings, emailSettings,
}, },
}); });
};
if (distributionMethod === DocumentDistributionMethod.EMAIL) { const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
try {
await sendDocumentWithSubject(data);
if (data.meta.distributionMethod === DocumentDistributionMethod.EMAIL) {
toast({ toast({
title: _(msg`Document sent`), title: _(msg`Document sent`),
description: _(msg`Your document has been sent successfully.`), description: _(msg`Your document has been sent successfully.`),
@ -306,6 +388,21 @@ export const DocumentEditForm = ({
} }
}; };
const onAddSubjectFormAutoSave = async (data: TAddSubjectFormSchema) => {
try {
// Save form data without sending the document
await saveSubjectData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the subject form.`),
variant: 'destructive',
});
}
};
const currentDocumentFlow = documentFlow[step]; const currentDocumentFlow = documentFlow[step];
/** /**
@ -351,25 +448,28 @@ export const DocumentEditForm = ({
fields={fields} fields={fields}
isDocumentPdfLoaded={isDocumentPdfLoaded} isDocumentPdfLoaded={isDocumentPdfLoaded}
onSubmit={onAddSettingsFormSubmit} onSubmit={onAddSettingsFormSubmit}
onAutoSave={onAddSettingsFormAutoSave}
/> />
<AddSignersFormPartial <AddSignersFormPartial
key={recipients.length} key={document.id}
documentFlow={documentFlow.signers} documentFlow={documentFlow.signers}
recipients={recipients} recipients={recipients}
signingOrder={document.documentMeta?.signingOrder} signingOrder={document.documentMeta?.signingOrder}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner} allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
fields={fields} fields={fields}
onSubmit={onAddSignersFormSubmit} onSubmit={onAddSignersFormSubmit}
onAutoSave={onAddSignersFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded} isDocumentPdfLoaded={isDocumentPdfLoaded}
/> />
<AddFieldsFormPartial <AddFieldsFormPartial
key={fields.length} key={document.id}
documentFlow={documentFlow.fields} documentFlow={documentFlow.fields}
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
onSubmit={onAddFieldsFormSubmit} onSubmit={onAddFieldsFormSubmit}
onAutoSave={onAddFieldsFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded} isDocumentPdfLoaded={isDocumentPdfLoaded}
teamId={team.id} teamId={team.id}
/> />
@ -381,6 +481,7 @@ export const DocumentEditForm = ({
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
onSubmit={onAddSubjectFormSubmit} onSubmit={onAddSubjectFormSubmit}
onAutoSave={onAddSubjectFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded} isDocumentPdfLoaded={isDocumentPdfLoaded}
/> />
</Stepper> </Stepper>

View File

@ -124,15 +124,14 @@ export const TemplateEditForm = ({
}, },
}); });
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => { const saveSettingsData = async (data: TAddTemplateSettingsFormSchema) => {
const { signatureTypes } = data.meta; const { signatureTypes } = data.meta;
const parsedGlobalAccessAuth = z const parsedGlobalAccessAuth = z
.array(ZDocumentAccessAuthTypesSchema) .array(ZDocumentAccessAuthTypesSchema)
.safeParse(data.globalAccessAuth); .safeParse(data.globalAccessAuth);
try { return updateTemplateSettings({
await updateTemplateSettings({
templateId: template.id, templateId: template.id,
data: { data: {
title: data.title, title: data.title,
@ -150,6 +149,11 @@ export const TemplateEditForm = ({
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined, language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
}, },
}); });
};
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
try {
await saveSettingsData(data);
setStep('signers'); setStep('signers');
} catch (err) { } catch (err) {
@ -163,11 +167,22 @@ export const TemplateEditForm = ({
} }
}; };
const onAddTemplatePlaceholderFormSubmit = async ( const onAddSettingsFormAutoSave = async (data: TAddTemplateSettingsFormSchema) => {
data: TAddTemplatePlacholderRecipientsFormSchema,
) => {
try { try {
await Promise.all([ await saveSettingsData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the template settings.`),
variant: 'destructive',
});
}
};
const saveTemplatePlaceholderData = async (data: TAddTemplatePlacholderRecipientsFormSchema) => {
return Promise.all([
updateTemplateSettings({ updateTemplateSettings({
templateId: template.id, templateId: template.id,
meta: { meta: {
@ -181,6 +196,13 @@ export const TemplateEditForm = ({
recipients: data.signers, recipients: data.signers,
}), }),
]); ]);
};
const onAddTemplatePlaceholderFormSubmit = async (
data: TAddTemplatePlacholderRecipientsFormSchema,
) => {
try {
await saveTemplatePlaceholderData(data);
setStep('fields'); setStep('fields');
} catch (err) { } catch (err) {
@ -192,12 +214,46 @@ export const TemplateEditForm = ({
} }
}; };
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => { const onAddTemplatePlaceholderFormAutoSave = async (
data: TAddTemplatePlacholderRecipientsFormSchema,
) => {
try { try {
await addTemplateFields({ await saveTemplatePlaceholderData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the template placeholders.`),
variant: 'destructive',
});
}
};
const saveFieldsData = async (data: TAddTemplateFieldsFormSchema) => {
return addTemplateFields({
templateId: template.id, templateId: template.id,
fields: data.fields, fields: data.fields,
}); });
};
const onAddFieldsFormAutoSave = async (data: TAddTemplateFieldsFormSchema) => {
try {
await saveFieldsData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the template fields.`),
variant: 'destructive',
});
}
};
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
try {
await saveFieldsData(data);
// Clear all field data from localStorage // Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
@ -270,11 +326,12 @@ export const TemplateEditForm = ({
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
onSubmit={onAddSettingsFormSubmit} onSubmit={onAddSettingsFormSubmit}
onAutoSave={onAddSettingsFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded} isDocumentPdfLoaded={isDocumentPdfLoaded}
/> />
<AddTemplatePlaceholderRecipientsFormPartial <AddTemplatePlaceholderRecipientsFormPartial
key={recipients.length} key={template.id}
documentFlow={documentFlow.signers} documentFlow={documentFlow.signers}
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
@ -282,15 +339,17 @@ export const TemplateEditForm = ({
allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner} allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner}
templateDirectLink={template.directLink} templateDirectLink={template.directLink}
onSubmit={onAddTemplatePlaceholderFormSubmit} onSubmit={onAddTemplatePlaceholderFormSubmit}
onAutoSave={onAddTemplatePlaceholderFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded} isDocumentPdfLoaded={isDocumentPdfLoaded}
/> />
<AddTemplateFieldsFormPartial <AddTemplateFieldsFormPartial
key={fields.length} key={template.id}
documentFlow={documentFlow.fields} documentFlow={documentFlow.fields}
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
onSubmit={onAddFieldsFormSubmit} onSubmit={onAddFieldsFormSubmit}
onAutoSave={onAddFieldsFormAutoSave}
teamId={team?.id} teamId={team?.id}
/> />
</Stepper> </Stepper>

View File

@ -50,10 +50,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw redirect(documentRootPath); throw redirect(documentRootPath);
} }
if (document.folderId) {
throw redirect(documentRootPath);
}
const recipients = await getRecipientsForDocument({ const recipients = await getRecipientsForDocument({
documentId, documentId,
userId: user.id, userId: user.id,
@ -68,13 +64,13 @@ export async function loader({ params, request }: Route.LoaderArgs) {
return { return {
document, document,
documentRootPath,
recipients, recipients,
documentRootPath,
}; };
} }
export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps) { export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps) {
const { document, documentRootPath, recipients } = loaderData; const { document, recipients, documentRootPath } = loaderData;
const { _, i18n } = useLingui(); const { _, i18n } = useLingui();

View File

@ -9,10 +9,10 @@ import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { FolderGrid } from '~/components/general/folder/folder-grid'; import { FolderGrid } from '~/components/general/folder/folder-grid';
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
import { TemplatesTable } from '~/components/tables/templates-table'; import { TemplatesTable } from '~/components/tables/templates-table';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta'; import { appMetaTags } from '~/utils/meta';
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
export function meta() { export function meta() {
return appMetaTags('Templates'); return appMetaTags('Templates');

View File

@ -34,6 +34,7 @@ import { createTemplate } from '@documenso/lib/server-only/template/create-templ
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template'; import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
import { findTemplates } from '@documenso/lib/server-only/template/find-templates'; import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { extractDerivedDocumentEmailSettings } from '@documenso/lib/types/document-email'; import { extractDerivedDocumentEmailSettings } from '@documenso/lib/types/document-email';
import { import {
ZCheckboxFieldMeta, ZCheckboxFieldMeta,
@ -980,10 +981,12 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
userId: user.id, userId: user.id,
teamId: team?.id, teamId: team?.id,
recipients: [ recipients: [
...recipients.map(({ email, name }) => ({ ...recipients.map((recipient) => ({
email, email: recipient.email,
name, name: recipient.name,
role, role: recipient.role,
signingOrder: recipient.signingOrder,
actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? [],
})), })),
{ {
email, email,

View File

@ -33,7 +33,7 @@ export const ZNoBodyMutationSchema = null;
*/ */
export const ZGetDocumentsQuerySchema = z.object({ export const ZGetDocumentsQuerySchema = z.object({
page: z.coerce.number().min(1).optional().default(1), page: z.coerce.number().min(1).optional().default(1),
perPage: z.coerce.number().min(1).optional().default(1), perPage: z.coerce.number().min(1).optional().default(10),
}); });
export type TGetDocumentsQuerySchema = z.infer<typeof ZGetDocumentsQuerySchema>; export type TGetDocumentsQuerySchema = z.infer<typeof ZGetDocumentsQuerySchema>;
@ -637,5 +637,5 @@ export const ZSuccessfulGetTemplatesResponseSchema = z.object({
export const ZGetTemplatesQuerySchema = z.object({ export const ZGetTemplatesQuerySchema = z.object({
page: z.coerce.number().min(1).optional().default(1), page: z.coerce.number().min(1).optional().default(1),
perPage: z.coerce.number().min(1).optional().default(1), perPage: z.coerce.number().min(1).optional().default(10),
}); });

View File

@ -0,0 +1,293 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const setupDocumentAndNavigateToFieldsStep = async (page: Page) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add signer' }).click();
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
await page.getByRole('button', { name: 'Continue' }).click();
return { user, team, document };
};
const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
test.describe('AutoSave Fields Step', () => {
test('should autosave the fields without advanced settings', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
},
});
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getFieldsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedFields.length).toBe(3);
expect(retrievedFields[0].type).toBe('SIGNATURE');
expect(retrievedFields[1].type).toBe('TEXT');
expect(retrievedFields[2].type).toBe('SIGNATURE');
}).toPass();
});
test('should autosave the field deletion', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
},
});
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
await page.getByText('Text').nth(1).click();
await page.getByRole('button', { name: 'Remove' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getFieldsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedFields.length).toBe(2);
expect(retrievedFields[0].type).toBe('SIGNATURE');
expect(retrievedFields[1].type).toBe('SIGNATURE');
}).toPass();
});
test('should autosave the field duplication', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
},
});
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
await page.getByText('Signature').nth(1).click();
await page.getByRole('button', { name: 'Duplicate', exact: true }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getFieldsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedFields.length).toBe(4);
expect(retrievedFields[0].type).toBe('SIGNATURE');
expect(retrievedFields[1].type).toBe('TEXT');
expect(retrievedFields[2].type).toBe('SIGNATURE');
expect(retrievedFields[3].type).toBe('SIGNATURE');
}).toPass();
});
test('should autosave the fields with advanced settings', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByRole('textbox', { name: 'Field label' }).fill('Test Field');
await page.getByRole('textbox', { name: 'Field placeholder' }).fill('Test Placeholder');
await page.getByRole('textbox', { name: 'Add text to the field' }).fill('Test Text');
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Save' })
.click();
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getFieldsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedFields.length).toBe(2);
expect(retrievedFields[0].type).toBe('SIGNATURE');
expect(retrievedFields[1].type).toBe('TEXT');
const textField = retrievedFields[1];
expect(textField.fieldMeta).toBeDefined();
if (
textField.fieldMeta &&
typeof textField.fieldMeta === 'object' &&
'type' in textField.fieldMeta
) {
expect(textField.fieldMeta.type).toBe('text');
expect(textField.fieldMeta.label).toBe('Test Field');
expect(textField.fieldMeta.placeholder).toBe('Test Placeholder');
if (textField.fieldMeta.type === 'text') {
expect(textField.fieldMeta.text).toBe('Test Text');
}
} else {
throw new Error('fieldMeta should be defined and contain advanced settings');
}
}).toPass();
});
});

View File

@ -0,0 +1,243 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
const setupDocument = async (page: Page) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
return { user, team, document };
};
const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
test.describe('AutoSave Settings Step', () => {
test('should autosave the title change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
const newDocumentTitle = 'New Document Title';
await page.getByRole('textbox', { name: 'Title *' }).fill(newDocumentTitle);
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
await expect(page.getByRole('textbox', { name: 'Title *' })).toHaveValue(retrieved.title);
}).toPass();
});
test('should autosave the language change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
const newDocumentLanguage = 'French';
const expectedLanguageCode = 'fr';
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: newDocumentLanguage }).click();
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.documentMeta?.language).toBe(expectedLanguageCode);
}).toPass();
});
test('should autosave the document access change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
const access = 'Require account';
const accessValue = 'ACCOUNT';
await page.getByRole('combobox').nth(1).click();
await page.getByRole('option', { name: access }).click();
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.authOptions?.globalAccessAuth).toContain(accessValue);
}).toPass();
});
test('should autosave the external ID change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
const newExternalId = '1234567890';
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.externalId).toBe(newExternalId);
}).toPass();
});
test('should autosave the allowed signature types change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(3).click();
await page.getByRole('option', { name: 'Draw' }).click();
await page.getByRole('option', { name: 'Type' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.documentMeta?.drawSignatureEnabled).toBe(false);
expect(retrieved.documentMeta?.typedSignatureEnabled).toBe(false);
expect(retrieved.documentMeta?.uploadSignatureEnabled).toBe(true);
}).toPass();
});
test('should autosave the date format change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(4).click();
await page.getByRole('option', { name: 'ISO 8601', exact: true }).click();
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.documentMeta?.dateFormat).toBe("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
}).toPass();
});
test('should autosave the timezone change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(5).click();
await page.getByRole('option', { name: 'Europe/London' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.documentMeta?.timezone).toBe('Europe/London');
}).toPass();
});
test('should autosave the redirect URL change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
const newRedirectUrl = 'https://documenso.com/test/';
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('textbox', { name: 'Redirect URL' }).fill(newRedirectUrl);
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.documentMeta?.redirectUrl).toBe(newRedirectUrl);
}).toPass();
});
test('should autosave multiple field changes together', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
const newTitle = 'Updated Document Title';
await page.getByRole('textbox', { name: 'Title *' }).fill(newTitle);
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'German' }).click();
await page.getByRole('combobox').nth(1).click();
await page.getByRole('option', { name: 'Require account' }).click();
await page.getByRole('button', { name: 'Advanced Options' }).click();
const newExternalId = 'MULTI-TEST-123';
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
await page.getByRole('combobox').nth(5).click();
await page.getByRole('option', { name: 'Europe/Berlin' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.title).toBe(newTitle);
expect(retrieved.documentMeta?.language).toBe('de');
expect(retrieved.authOptions?.globalAccessAuth).toContain('ACCOUNT');
expect(retrieved.externalId).toBe(newExternalId);
expect(retrieved.documentMeta?.timezone).toBe('Europe/Berlin');
}).toPass();
});
});

View File

@ -0,0 +1,168 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
const setupDocumentAndNavigateToSignersStep = async (page: Page) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
return { user, team, document };
};
const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
const addSignerAndSave = async (page: Page) => {
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await triggerAutosave(page);
};
test.describe('AutoSave Signers Step', () => {
test('should autosave the signers addition', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await expect(async () => {
const retrievedRecipients = await getRecipientsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedRecipients.length).toBe(1);
expect(retrievedRecipients[0].email).toBe('recipient1@documenso.com');
expect(retrievedRecipients[0].name).toBe('Recipient 1');
}).toPass();
});
test('should autosave the signer deletion', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await page.getByRole('button', { name: 'Add myself' }).click();
await triggerAutosave(page);
await page.getByTestId('remove-signer-button').first().click();
await triggerAutosave(page);
await expect(async () => {
const retrievedRecipients = await getRecipientsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedRecipients.length).toBe(1);
expect(retrievedRecipients[0].email).toBe(user.email);
expect(retrievedRecipients[0].name).toBe(user.name);
}).toPass();
});
test('should autosave the signer update', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await page.getByPlaceholder('Name').fill('Documenso Manager');
await page.getByPlaceholder('Email').fill('manager@documenso.com');
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Receives copy' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedRecipients = await getRecipientsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedRecipients.length).toBe(1);
expect(retrievedRecipients[0].email).toBe('manager@documenso.com');
expect(retrievedRecipients[0].name).toBe('Documenso Manager');
expect(retrievedRecipients[0].role).toBe('CC');
}).toPass();
});
test('should autosave the signing order change', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await page.getByRole('button', { name: 'Add signer' }).click();
await page.getByTestId('signer-email-input').nth(1).fill('recipient2@documenso.com');
await page.getByLabel('Name').nth(1).fill('Recipient 2');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByTestId('signer-email-input').nth(2).fill('recipient3@documenso.com');
await page.getByLabel('Name').nth(2).fill('Recipient 3');
await triggerAutosave(page);
await page.getByLabel('Enable signing order').check();
await page.getByLabel('Allow signers to dictate next signer').check();
await triggerAutosave(page);
await page.getByTestId('signing-order-input').nth(0).fill('3');
await page.getByTestId('signing-order-input').nth(0).blur();
await triggerAutosave(page);
await page.getByTestId('signing-order-input').nth(1).fill('1');
await page.getByTestId('signing-order-input').nth(1).blur();
await triggerAutosave(page);
await page.getByTestId('signing-order-input').nth(2).fill('2');
await page.getByTestId('signing-order-input').nth(2).blur();
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
const retrievedRecipients = await getRecipientsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedDocumentData.documentMeta?.signingOrder).toBe('SEQUENTIAL');
expect(retrievedDocumentData.documentMeta?.allowDictateNextSigner).toBe(true);
expect(retrievedRecipients.length).toBe(3);
expect(retrievedRecipients[0].signingOrder).toBe(2);
expect(retrievedRecipients[1].signingOrder).toBe(3);
expect(retrievedRecipients[2].signingOrder).toBe(1);
}).toPass();
});
});

View File

@ -0,0 +1,200 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
return { user, team, document };
};
export const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
test.describe('AutoSave Subject Step', () => {
test('should autosave the subject field', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
const subject = 'Hello world!';
await page.getByRole('textbox', { name: 'Subject (Optional)' }).fill(subject);
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
await expect(page.getByRole('textbox', { name: 'Subject (Optional)' })).toHaveValue(
retrievedDocumentData.documentMeta?.subject ?? '',
);
}).toPass();
});
test('should autosave the message field', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
const message = 'Please review and sign this important document. Thank you!';
await page.getByRole('textbox', { name: 'Message (Optional)' }).fill(message);
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
await expect(page.getByRole('textbox', { name: 'Message (Optional)' })).toHaveValue(
retrievedDocumentData.documentMeta?.message ?? '',
);
}).toPass();
});
test('should autosave the email settings checkboxes', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
// Toggle some email settings checkboxes (randomly - some checked, some unchecked)
await page.getByText('Send recipient signed email').click();
await page.getByText('Send recipient removed email').click();
await page.getByText('Send document completed email', { exact: true }).click();
await page.getByText('Send document deleted email').click();
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
const emailSettings = retrievedDocumentData.documentMeta?.emailSettings;
await expect(page.getByText('Send recipient signed email')).toBeChecked({
checked: emailSettings?.recipientSigned,
});
await expect(page.getByText('Send recipient removed email')).toBeChecked({
checked: emailSettings?.recipientRemoved,
});
await expect(page.getByText('Send document completed email', { exact: true })).toBeChecked({
checked: emailSettings?.documentCompleted,
});
await expect(page.getByText('Send document deleted email')).toBeChecked({
checked: emailSettings?.documentDeleted,
});
await expect(page.getByText('Send recipient signing request email')).toBeChecked({
checked: emailSettings?.recipientSigningRequest,
});
await expect(page.getByText('Send document pending email')).toBeChecked({
checked: emailSettings?.documentPending,
});
await expect(page.getByText('Send document completed email to the owner')).toBeChecked({
checked: emailSettings?.ownerDocumentCompleted,
});
}).toPass();
});
test('should autosave all fields and settings together', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
const subject = 'Combined Test Subject - Please Sign';
const message =
'This is a comprehensive test message for autosave functionality. Please review and sign at your earliest convenience.';
await page.getByRole('textbox', { name: 'Subject (Optional)' }).fill(subject);
await page.getByRole('textbox', { name: 'Message (Optional)' }).fill(message);
await page.getByText('Send recipient signed email').click();
await page.getByText('Send recipient removed email').click();
await page.getByText('Send document completed email', { exact: true }).click();
await page.getByText('Send document deleted email').click();
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedDocumentData.documentMeta?.subject).toBe(subject);
expect(retrievedDocumentData.documentMeta?.message).toBe(message);
expect(retrievedDocumentData.documentMeta?.emailSettings).toBeDefined();
await expect(page.getByRole('textbox', { name: 'Subject (Optional)' })).toHaveValue(
retrievedDocumentData.documentMeta?.subject ?? '',
);
await expect(page.getByRole('textbox', { name: 'Message (Optional)' })).toHaveValue(
retrievedDocumentData.documentMeta?.message ?? '',
);
await expect(page.getByText('Send recipient signed email')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigned,
});
await expect(page.getByText('Send recipient removed email')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientRemoved,
});
await expect(page.getByText('Send document completed email', { exact: true })).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentCompleted,
});
await expect(page.getByText('Send document deleted email')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentDeleted,
});
await expect(page.getByText('Send recipient signing request email')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigningRequest,
});
await expect(page.getByText('Send document pending email')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentPending,
});
await expect(page.getByText('Send document completed email to the owner')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.ownerDocumentCompleted,
});
}).toPass();
});
});

View File

@ -534,9 +534,6 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
await page.getByLabel('Title').fill(documentTitle); await page.getByLabel('Title').fill(documentTitle);
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByLabel('Enable signing order').check();
for (let i = 1; i <= 3; i++) { for (let i = 1; i <= 3; i++) {
if (i > 1) { if (i > 1) {
await page.getByRole('button', { name: 'Add Signer' }).click(); await page.getByRole('button', { name: 'Add Signer' }).click();
@ -558,6 +555,9 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
.fill(`User ${i}`); .fill(`User ${i}`);
} }
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByLabel('Enable signing order').check();
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();

View File

@ -0,0 +1,304 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const setupTemplateAndNavigateToFieldsStep = async (page: Page) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
await page.getByRole('button', { name: 'Continue' }).click();
return { user, team, template };
};
const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
test.describe('AutoSave Fields Step', () => {
test('should autosave the fields without advanced settings', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
},
});
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
const fields = retrievedFields.fields;
expect(fields.length).toBe(3);
expect(fields[0].type).toBe('SIGNATURE');
expect(fields[1].type).toBe('TEXT');
expect(fields[2].type).toBe('SIGNATURE');
}).toPass();
});
test('should autosave the field deletion', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
},
});
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
await page.getByText('Text').nth(1).click();
await page.getByRole('button', { name: 'Remove' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
const fields = retrievedFields.fields;
expect(fields.length).toBe(2);
expect(fields[0].type).toBe('SIGNATURE');
expect(fields[1].type).toBe('SIGNATURE');
}).toPass();
});
test('should autosave the field duplication', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
},
});
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
await page.getByText('Signature').nth(1).click();
await page.getByRole('button', { name: 'Duplicate', exact: true }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
const fields = retrievedFields.fields;
expect(fields.length).toBe(4);
expect(fields[0].type).toBe('SIGNATURE');
expect(fields[1].type).toBe('TEXT');
expect(fields[2].type).toBe('SIGNATURE');
expect(fields[3].type).toBe('SIGNATURE');
}).toPass();
});
test('should autosave the fields with advanced settings', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByRole('textbox', { name: 'Field label' }).fill('Test Field');
await page.getByRole('textbox', { name: 'Field placeholder' }).fill('Test Placeholder');
await page.getByRole('textbox', { name: 'Add text to the field' }).fill('Test Text');
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Save' })
.click();
await page.waitForTimeout(2500);
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
const fields = retrievedTemplate.fields;
expect(fields.length).toBe(2);
expect(fields[0].type).toBe('SIGNATURE');
expect(fields[1].type).toBe('TEXT');
const textField = fields[1];
expect(textField.fieldMeta).toBeDefined();
if (
textField.fieldMeta &&
typeof textField.fieldMeta === 'object' &&
'type' in textField.fieldMeta
) {
expect(textField.fieldMeta.type).toBe('text');
expect(textField.fieldMeta.label).toBe('Test Field');
expect(textField.fieldMeta.placeholder).toBe('Test Placeholder');
if (textField.fieldMeta.type === 'text') {
expect(textField.fieldMeta.text).toBe('Test Text');
}
} else {
throw new Error('fieldMeta should be defined and contain advanced settings');
}
}).toPass();
});
});

View File

@ -0,0 +1,244 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
const setupTemplate = async (page: Page) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
return { user, team, template };
};
const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
test.describe('AutoSave Settings Step - Templates', () => {
test('should autosave the title change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
const newTemplateTitle = 'New Template Title';
await page.getByRole('textbox', { name: 'Title *' }).fill(newTemplateTitle);
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
await expect(page.getByRole('textbox', { name: 'Title *' })).toHaveValue(
retrievedTemplate.title,
);
}).toPass();
});
test('should autosave the language change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
const newTemplateLanguage = 'French';
const expectedLanguageCode = 'fr';
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: newTemplateLanguage }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.templateMeta?.language).toBe(expectedLanguageCode);
}).toPass();
});
test('should autosave the template access change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
const access = 'Require account';
const accessValue = 'ACCOUNT';
await page.getByRole('combobox').nth(1).click();
await page.getByRole('option', { name: access }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.authOptions?.globalAccessAuth).toContain(accessValue);
}).toPass();
});
test('should autosave the external ID change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
const newExternalId = '1234567890';
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.externalId).toBe(newExternalId);
}).toPass();
});
test('should autosave the allowed signature types change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(4).click();
await page.getByRole('option', { name: 'Draw' }).click();
await page.getByRole('option', { name: 'Type' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.templateMeta?.drawSignatureEnabled).toBe(false);
expect(retrievedTemplate.templateMeta?.typedSignatureEnabled).toBe(false);
expect(retrievedTemplate.templateMeta?.uploadSignatureEnabled).toBe(true);
}).toPass();
});
test('should autosave the date format change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(5).click();
await page.getByRole('option', { name: 'ISO 8601', exact: true }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.templateMeta?.dateFormat).toBe("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
}).toPass();
});
test('should autosave the timezone change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(6).click();
await page.getByRole('option', { name: 'Europe/London' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.templateMeta?.timezone).toBe('Europe/London');
}).toPass();
});
test('should autosave the redirect URL change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
const newRedirectUrl = 'https://documenso.com/test/';
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('textbox', { name: 'Redirect URL' }).fill(newRedirectUrl);
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.templateMeta?.redirectUrl).toBe(newRedirectUrl);
}).toPass();
});
test('should autosave multiple field changes together', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
const newTitle = 'Updated Template Title';
await page.getByRole('textbox', { name: 'Title *' }).fill(newTitle);
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'German' }).click();
await page.getByRole('combobox').nth(1).click();
await page.getByRole('option', { name: 'Require account' }).click();
await page.getByRole('button', { name: 'Advanced Options' }).click();
const newExternalId = 'MULTI-TEST-123';
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
await page.getByRole('combobox').nth(6).click();
await page.getByRole('option', { name: 'Europe/Berlin' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.title).toBe(newTitle);
expect(retrievedTemplate.templateMeta?.language).toBe('de');
expect(retrievedTemplate.authOptions?.globalAccessAuth).toContain('ACCOUNT');
expect(retrievedTemplate.externalId).toBe(newExternalId);
expect(retrievedTemplate.templateMeta?.timezone).toBe('Europe/Berlin');
}).toPass();
});
});

View File

@ -0,0 +1,174 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
const setupTemplateAndNavigateToSignersStep = async (page: Page) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
return { user, team, template };
};
const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
const addSignerAndSave = async (page: Page) => {
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await triggerAutosave(page);
};
test.describe('AutoSave Signers Step - Templates', () => {
test('should autosave the signers addition', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await expect(async () => {
const retrievedRecipients = await getRecipientsForTemplate({
templateId: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedRecipients.length).toBe(1);
expect(retrievedRecipients[0].email).toBe('recipient1@documenso.com');
expect(retrievedRecipients[0].name).toBe('Recipient 1');
}).toPass();
});
test('should autosave the signer deletion', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await page.getByRole('button', { name: 'Add myself' }).click();
await triggerAutosave(page);
await page.getByTestId('remove-placeholder-recipient-button').first().click();
await triggerAutosave(page);
await expect(async () => {
const retrievedRecipients = await getRecipientsForTemplate({
templateId: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedRecipients.length).toBe(1);
expect(retrievedRecipients[0].email).toBe(user.email);
expect(retrievedRecipients[0].name).toBe(user.name);
}).toPass();
});
test('should autosave the signer update', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await page.getByPlaceholder('Name').fill('Documenso Manager');
await page.getByPlaceholder('Email').fill('manager@documenso.com');
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Receives copy' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedRecipients = await getRecipientsForTemplate({
templateId: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedRecipients.length).toBe(1);
expect(retrievedRecipients[0].email).toBe('manager@documenso.com');
expect(retrievedRecipients[0].name).toBe('Documenso Manager');
expect(retrievedRecipients[0].role).toBe('CC');
}).toPass();
});
test('should autosave the signing order change', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await page.getByRole('button', { name: 'Add placeholder recipient' }).click();
await page
.getByTestId('placeholder-recipient-email-input')
.nth(1)
.fill('recipient2@documenso.com');
await page.getByTestId('placeholder-recipient-name-input').nth(1).fill('Recipient 2');
await page.getByRole('button', { name: 'Add placeholder recipient' }).click();
await page
.getByTestId('placeholder-recipient-email-input')
.nth(2)
.fill('recipient3@documenso.com');
await page.getByTestId('placeholder-recipient-name-input').nth(2).fill('Recipient 3');
await triggerAutosave(page);
await page.getByLabel('Enable signing order').check();
await page.getByLabel('Allow signers to dictate next signer').check();
await triggerAutosave(page);
await page.getByTestId('placeholder-recipient-signing-order-input').nth(0).fill('3');
await page.getByTestId('placeholder-recipient-signing-order-input').nth(0).blur();
await triggerAutosave(page);
await page.getByTestId('placeholder-recipient-signing-order-input').nth(1).fill('1');
await page.getByTestId('placeholder-recipient-signing-order-input').nth(1).blur();
await triggerAutosave(page);
await page.getByTestId('placeholder-recipient-signing-order-input').nth(2).fill('2');
await page.getByTestId('placeholder-recipient-signing-order-input').nth(2).blur();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
const retrievedRecipients = await getRecipientsForTemplate({
templateId: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.templateMeta?.signingOrder).toBe('SEQUENTIAL');
expect(retrievedTemplate.templateMeta?.allowDictateNextSigner).toBe(true);
expect(retrievedRecipients.length).toBe(3);
expect(retrievedRecipients[0].signingOrder).toBe(2);
expect(retrievedRecipients[1].signingOrder).toBe(3);
expect(retrievedRecipients[2].signingOrder).toBe(1);
}).toPass();
});
});

View File

@ -17,7 +17,7 @@ export default defineConfig({
testDir: './e2e', testDir: './e2e',
/* Run tests in files in parallel */ /* Run tests in files in parallel */
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 4,
maxFailures: process.env.CI ? 1 : undefined, maxFailures: process.env.CI ? 1 : undefined,
/* Fail the build on CI if you accidentally left test.only in the source code. */ /* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,

View File

@ -0,0 +1,31 @@
import { useCallback, useEffect, useRef } from 'react';
export const useAutoSave = <T>(onSave: (data: T) => Promise<void>) => {
const saveTimeoutRef = useRef<NodeJS.Timeout>();
const saveFormData = async (data: T) => {
try {
await onSave(data);
} catch (error) {
console.error('Auto-save failed:', error);
}
};
const scheduleSave = useCallback((data: T) => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(() => void saveFormData(data), 2000);
}, []);
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, []);
return { scheduleSave };
};

View File

@ -149,33 +149,6 @@ export const sendDocument = async ({
// throw new Error('Some signers have not been assigned a signature field.'); // throw new Error('Some signers have not been assigned a signature field.');
// } // }
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigningRequest;
// Only send email if one of the following is true:
// - It is explicitly set
// - The email is enabled for signing requests AND sendEmail is undefined
if (sendEmail || (isRecipientSigningRequestEmailEnabled && sendEmail === undefined)) {
await Promise.all(
recipientsToNotify.map(async (recipient) => {
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
return;
}
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId,
documentId,
recipientId: recipient.id,
requestMetadata: requestMetadata?.requestMetadata,
},
});
}),
);
}
const allRecipientsHaveNoActionToTake = document.recipients.every( const allRecipientsHaveNoActionToTake = document.recipients.every(
(recipient) => (recipient) =>
recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED, recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED,
@ -246,6 +219,33 @@ export const sendDocument = async ({
}); });
}); });
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigningRequest;
// Only send email if one of the following is true:
// - It is explicitly set
// - The email is enabled for signing requests AND sendEmail is undefined
if (sendEmail || (isRecipientSigningRequestEmailEnabled && sendEmail === undefined)) {
await Promise.all(
recipientsToNotify.map(async (recipient) => {
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
return;
}
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId,
documentId,
recipientId: recipient.id,
requestMetadata: requestMetadata?.requestMetadata,
},
});
}),
);
}
await triggerWebhook({ await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_SENT, event: WebhookTriggerEvents.DOCUMENT_SENT,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)), data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),

View File

@ -1,5 +1,7 @@
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface GetRecipientsForTemplateOptions { export interface GetRecipientsForTemplateOptions {
templateId: number; templateId: number;
userId: number; userId: number;
@ -14,20 +16,11 @@ export const getRecipientsForTemplate = async ({
const recipients = await prisma.recipient.findMany({ const recipients = await prisma.recipient.findMany({
where: { where: {
templateId, templateId,
template: teamId template: {
? { team: buildTeamWhereQuery({
team: { teamId,
id: teamId,
members: {
some: {
userId, userId,
}, }),
},
},
}
: {
userId,
teamId: null,
}, },
}, },
orderBy: { orderBy: {

View File

@ -21,6 +21,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { prop, sortBy } from 'remeda'; import { prop, sortBy } from 'remeda';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element'; import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { import {
@ -83,6 +84,7 @@ export type AddFieldsFormProps = {
recipients: Recipient[]; recipients: Recipient[];
fields: Field[]; fields: Field[];
onSubmit: (_data: TAddFieldsFormSchema) => void; onSubmit: (_data: TAddFieldsFormSchema) => void;
onAutoSave: (_data: TAddFieldsFormSchema) => Promise<void>;
canGoBack?: boolean; canGoBack?: boolean;
isDocumentPdfLoaded: boolean; isDocumentPdfLoaded: boolean;
teamId: number; teamId: number;
@ -94,6 +96,7 @@ export const AddFieldsFormPartial = ({
recipients, recipients,
fields, fields,
onSubmit, onSubmit,
onAutoSave,
canGoBack = false, canGoBack = false,
isDocumentPdfLoaded, isDocumentPdfLoaded,
teamId, teamId,
@ -590,6 +593,20 @@ export const AddFieldsFormPartial = ({
} }
}; };
const { scheduleSave } = useAutoSave(onAutoSave);
const handleAutoSave = async () => {
const isFormValid = await form.trigger();
if (!isFormValid) {
return;
}
const formData = form.getValues();
scheduleSave(formData);
};
return ( return (
<> <>
{showAdvancedSettings && currentField ? ( {showAdvancedSettings && currentField ? (
@ -603,7 +620,14 @@ export const AddFieldsFormPartial = ({
fields={localFields} fields={localFields}
onAdvancedSettings={handleAdvancedSettings} onAdvancedSettings={handleAdvancedSettings}
isDocumentPdfLoaded={isDocumentPdfLoaded} isDocumentPdfLoaded={isDocumentPdfLoaded}
onSave={handleSavedFieldSettings} onSave={(fieldState) => {
handleSavedFieldSettings(fieldState);
void handleAutoSave();
}}
onAutoSave={async (fieldState) => {
handleSavedFieldSettings(fieldState);
await handleAutoSave();
}}
/> />
) : ( ) : (
<> <>
@ -660,14 +684,26 @@ export const AddFieldsFormPartial = ({
defaultWidth={DEFAULT_WIDTH_PX} defaultWidth={DEFAULT_WIDTH_PX}
passive={isFieldWithinBounds && !!selectedField} passive={isFieldWithinBounds && !!selectedField}
onFocus={() => setLastActiveField(field)} onFocus={() => setLastActiveField(field)}
onBlur={() => setLastActiveField(null)} onBlur={() => {
setLastActiveField(null);
void handleAutoSave();
}}
onMouseEnter={() => setLastActiveField(field)} onMouseEnter={() => setLastActiveField(field)}
onMouseLeave={() => setLastActiveField(null)} onMouseLeave={() => setLastActiveField(null)}
onResize={(options) => onFieldResize(options, index)} onResize={(options) => onFieldResize(options, index)}
onMove={(options) => onFieldMove(options, index)} onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)} onRemove={() => {
onDuplicate={() => onFieldCopy(null, { duplicate: true })} remove(index);
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })} void handleAutoSave();
}}
onDuplicate={() => {
onFieldCopy(null, { duplicate: true });
void handleAutoSave();
}}
onDuplicateAllPages={() => {
onFieldCopy(null, { duplicateAll: true });
void handleAutoSave();
}}
onAdvancedSettings={() => { onAdvancedSettings={() => {
setCurrentField(field); setCurrentField(field);
handleAdvancedSettings(); handleAdvancedSettings();

View File

@ -14,6 +14,7 @@ import { InfoIcon } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form'; import { useForm, useWatch } from 'react-hook-form';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document'; import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document';
@ -92,6 +93,7 @@ export type AddSettingsFormProps = {
document: TDocument; document: TDocument;
currentTeamMemberRole?: TeamMemberRole; currentTeamMemberRole?: TeamMemberRole;
onSubmit: (_data: TAddSettingsFormSchema) => void; onSubmit: (_data: TAddSettingsFormSchema) => void;
onAutoSave: (_data: TAddSettingsFormSchema) => Promise<void>;
}; };
export const AddSettingsFormPartial = ({ export const AddSettingsFormPartial = ({
@ -102,6 +104,7 @@ export const AddSettingsFormPartial = ({
document, document,
currentTeamMemberRole, currentTeamMemberRole,
onSubmit, onSubmit,
onAutoSave,
}: AddSettingsFormProps) => { }: AddSettingsFormProps) => {
const { t } = useLingui(); const { t } = useLingui();
@ -182,6 +185,28 @@ export const AddSettingsFormPartial = ({
document.documentMeta?.timezone, document.documentMeta?.timezone,
]); ]);
const { scheduleSave } = useAutoSave(onAutoSave);
const handleAutoSave = async () => {
const isFormValid = await form.trigger();
if (!isFormValid) {
return;
}
const formData = form.getValues();
/*
* Parse the form data through the Zod schema to handle transformations
* (like -1 -> undefined for the Document Global Auth Access)
*/
const parseResult = ZAddSettingsFormSchema.safeParse(formData);
if (parseResult.success) {
scheduleSave(parseResult.data);
}
};
return ( return (
<> <>
<DocumentFlowFormContainerHeader <DocumentFlowFormContainerHeader
@ -217,6 +242,7 @@ export const AddSettingsFormPartial = ({
className="bg-background" className="bg-background"
{...field} {...field}
disabled={document.status !== DocumentStatus.DRAFT || field.disabled} disabled={document.status !== DocumentStatus.DRAFT || field.disabled}
onBlur={handleAutoSave}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@ -248,9 +274,13 @@ export const AddSettingsFormPartial = ({
<FormControl> <FormControl>
<Select <Select
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
value={field.value} value={field.value}
disabled={field.disabled} disabled={field.disabled}
onValueChange={field.onChange}
> >
<SelectTrigger className="bg-background"> <SelectTrigger className="bg-background">
<SelectValue /> <SelectValue />
@ -282,9 +312,13 @@ export const AddSettingsFormPartial = ({
<FormControl> <FormControl>
<DocumentGlobalAuthAccessSelect <DocumentGlobalAuthAccessSelect
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
value={field.value} value={field.value}
disabled={field.disabled} disabled={field.disabled}
onValueChange={field.onChange}
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>
@ -307,7 +341,10 @@ export const AddSettingsFormPartial = ({
canUpdateVisibility={canUpdateVisibility} canUpdateVisibility={canUpdateVisibility}
currentTeamMemberRole={currentTeamMemberRole} currentTeamMemberRole={currentTeamMemberRole}
{...field} {...field}
onValueChange={field.onChange} onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>
@ -328,9 +365,13 @@ export const AddSettingsFormPartial = ({
<FormControl> <FormControl>
<DocumentGlobalAuthActionSelect <DocumentGlobalAuthActionSelect
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
value={field.value} value={field.value}
disabled={field.disabled} disabled={field.disabled}
onValueChange={field.onChange}
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>
@ -368,7 +409,7 @@ export const AddSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input className="bg-background" {...field} /> <Input className="bg-background" {...field} onBlur={handleAutoSave} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@ -393,7 +434,10 @@ export const AddSettingsFormPartial = ({
value: option.value, value: option.value,
}))} }))}
selectedValues={field.value} selectedValues={field.value}
onChange={field.onChange} onChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
className="bg-background w-full" className="bg-background w-full"
emptySelectionPlaceholder="Select signature types" emptySelectionPlaceholder="Select signature types"
/> />
@ -415,8 +459,12 @@ export const AddSettingsFormPartial = ({
<FormControl> <FormControl>
<Select <Select
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
value={field.value} value={field.value}
onValueChange={field.onChange}
disabled={documentHasBeenSent} disabled={documentHasBeenSent}
> >
<SelectTrigger className="bg-background"> <SelectTrigger className="bg-background">
@ -451,8 +499,12 @@ export const AddSettingsFormPartial = ({
<Combobox <Combobox
className="bg-background" className="bg-background"
options={TIME_ZONES} options={TIME_ZONES}
{...field}
onChange={(value) => {
value && field.onChange(value);
void handleAutoSave();
}}
value={field.value} value={field.value}
onChange={(value) => value && field.onChange(value)}
disabled={documentHasBeenSent} disabled={documentHasBeenSent}
/> />
</FormControl> </FormControl>
@ -483,7 +535,7 @@ export const AddSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input className="bg-background" {...field} /> <Input className="bg-background" {...field} onBlur={handleAutoSave} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />

View File

@ -14,6 +14,7 @@ import { useFieldArray, useForm } from 'react-hook-form';
import { prop, sortBy } from 'remeda'; import { prop, sortBy } from 'remeda';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
@ -55,6 +56,7 @@ export type AddSignersFormProps = {
signingOrder?: DocumentSigningOrder | null; signingOrder?: DocumentSigningOrder | null;
allowDictateNextSigner?: boolean; allowDictateNextSigner?: boolean;
onSubmit: (_data: TAddSignersFormSchema) => void; onSubmit: (_data: TAddSignersFormSchema) => void;
onAutoSave: (_data: TAddSignersFormSchema) => Promise<void>;
isDocumentPdfLoaded: boolean; isDocumentPdfLoaded: boolean;
}; };
@ -65,6 +67,7 @@ export const AddSignersFormPartial = ({
signingOrder, signingOrder,
allowDictateNextSigner, allowDictateNextSigner,
onSubmit, onSubmit,
onAutoSave,
isDocumentPdfLoaded, isDocumentPdfLoaded,
}: AddSignersFormProps) => { }: AddSignersFormProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
@ -166,6 +169,29 @@ export const AddSignersFormPartial = ({
name: 'signers', name: 'signers',
}); });
const emptySigners = useCallback(
() => form.getValues('signers').filter((signer) => signer.email === ''),
[form],
);
const { scheduleSave } = useAutoSave(onAutoSave);
const handleAutoSave = async () => {
if (emptySigners().length > 0) {
return;
}
const isFormValid = await form.trigger();
if (!isFormValid) {
return;
}
const formData = form.getValues();
scheduleSave(formData);
};
const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email); const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email);
const isUserAlreadyARecipient = watchedSigners.some( const isUserAlreadyARecipient = watchedSigners.some(
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(), (signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
@ -216,24 +242,47 @@ export const AddSignersFormPartial = ({
const formStateIndex = form.getValues('signers').findIndex((s) => s.formId === signer.formId); const formStateIndex = form.getValues('signers').findIndex((s) => s.formId === signer.formId);
if (formStateIndex !== -1) { if (formStateIndex !== -1) {
removeSigner(formStateIndex); removeSigner(formStateIndex);
const updatedSigners = form.getValues('signers').filter((s) => s.formId !== signer.formId); const updatedSigners = form.getValues('signers').filter((s) => s.formId !== signer.formId);
form.setValue('signers', normalizeSigningOrders(updatedSigners));
form.setValue('signers', normalizeSigningOrders(updatedSigners), {
shouldValidate: true,
shouldDirty: true,
});
void handleAutoSave();
} }
}; };
const onAddSelfSigner = () => { const onAddSelfSigner = () => {
if (emptySignerIndex !== -1) { if (emptySignerIndex !== -1) {
setValue(`signers.${emptySignerIndex}.name`, user?.name ?? ''); setValue(`signers.${emptySignerIndex}.name`, user?.name ?? '', {
setValue(`signers.${emptySignerIndex}.email`, user?.email ?? ''); shouldValidate: true,
shouldDirty: true,
});
setValue(`signers.${emptySignerIndex}.email`, user?.email ?? '', {
shouldValidate: true,
shouldDirty: true,
});
form.setFocus(`signers.${emptySignerIndex}.email`);
} else { } else {
appendSigner({ appendSigner(
{
formId: nanoid(12), formId: nanoid(12),
name: user?.name ?? '', name: user?.name ?? '',
email: user?.email ?? '', email: user?.email ?? '',
role: RecipientRole.SIGNER, role: RecipientRole.SIGNER,
actionAuth: [], actionAuth: [],
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1, signingOrder:
}); signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
},
{
shouldFocus: true,
},
);
void form.trigger('signers');
} }
}; };
@ -263,7 +312,10 @@ export const AddSignersFormPartial = ({
signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : index + 1, signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : index + 1,
})); }));
form.setValue('signers', updatedSigners); form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
const lastSigner = updatedSigners[updatedSigners.length - 1]; const lastSigner = updatedSigners[updatedSigners.length - 1];
if (lastSigner.role === RecipientRole.ASSISTANT) { if (lastSigner.role === RecipientRole.ASSISTANT) {
@ -276,8 +328,10 @@ export const AddSignersFormPartial = ({
} }
await form.trigger('signers'); await form.trigger('signers');
void handleAutoSave();
}, },
[form, canRecipientBeModified, watchedSigners, toast], [form, canRecipientBeModified, watchedSigners, handleAutoSave, toast],
); );
const handleRoleChange = useCallback( const handleRoleChange = useCallback(
@ -287,7 +341,10 @@ export const AddSignersFormPartial = ({
// Handle parallel to sequential conversion for assistants // Handle parallel to sequential conversion for assistants
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) { if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL); form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, {
shouldValidate: true,
shouldDirty: true,
});
toast({ toast({
title: _(msg`Signing order is enabled.`), title: _(msg`Signing order is enabled.`),
description: _(msg`You cannot add assistants when signing order is disabled.`), description: _(msg`You cannot add assistants when signing order is disabled.`),
@ -302,7 +359,10 @@ export const AddSignersFormPartial = ({
signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : idx + 1, signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : idx + 1,
})); }));
form.setValue('signers', updatedSigners); form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) { if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
toast({ toast({
@ -341,7 +401,10 @@ export const AddSignersFormPartial = ({
signingOrder: !canRecipientBeModified(s.nativeId) ? s.signingOrder : idx + 1, signingOrder: !canRecipientBeModified(s.nativeId) ? s.signingOrder : idx + 1,
})); }));
form.setValue('signers', updatedSigners); form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) { if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
toast({ toast({
@ -364,9 +427,20 @@ export const AddSignersFormPartial = ({
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role, role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
})); }));
form.setValue('signers', updatedSigners); form.setValue('signers', updatedSigners, {
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL); shouldValidate: true,
form.setValue('allowDictateNextSigner', false); shouldDirty: true,
});
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, {
shouldValidate: true,
shouldDirty: true,
});
form.setValue('allowDictateNextSigner', false, {
shouldValidate: true,
shouldDirty: true,
});
void handleAutoSave();
}, [form]); }, [form]);
return ( return (
@ -408,19 +482,39 @@ export const AddSignersFormPartial = ({
// If sequential signing is turned off, disable dictate next signer // If sequential signing is turned off, disable dictate next signer
if (!checked) { if (!checked) {
form.setValue('allowDictateNextSigner', false); form.setValue('allowDictateNextSigner', false, {
shouldValidate: true,
shouldDirty: true,
});
} }
void handleAutoSave();
}} }}
disabled={isSubmitting || hasDocumentBeenSent} disabled={isSubmitting || hasDocumentBeenSent || emptySigners().length !== 0}
/> />
</FormControl> </FormControl>
<div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
<FormLabel <FormLabel
htmlFor="signingOrder" htmlFor="signingOrder"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
> >
<Trans>Enable signing order</Trans> <Trans>Enable signing order</Trans>
</FormLabel> </FormLabel>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground ml-1 cursor-help">
<HelpCircle className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 p-4">
<p>
<Trans>Add 2 or more signers to enable signing order.</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
</FormItem> </FormItem>
)} )}
/> />
@ -435,12 +529,15 @@ export const AddSignersFormPartial = ({
{...field} {...field}
id="allowDictateNextSigner" id="allowDictateNextSigner"
checked={value} checked={value}
onCheckedChange={field.onChange} onCheckedChange={(checked) => {
field.onChange(checked);
void handleAutoSave();
}}
disabled={isSubmitting || hasDocumentBeenSent || !isSigningOrderSequential} disabled={isSubmitting || hasDocumentBeenSent || !isSigningOrderSequential}
/> />
</FormControl> </FormControl>
<div className="flex items-center"> <div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
<FormLabel <FormLabel
htmlFor="allowDictateNextSigner" htmlFor="allowDictateNextSigner"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
@ -533,6 +630,7 @@ export const AddSignersFormPartial = ({
<Input <Input
type="number" type="number"
max={signers.length} max={signers.length}
data-testid="signing-order-input"
className={cn( className={cn(
'w-full text-center', 'w-full text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none', '[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
@ -541,10 +639,12 @@ export const AddSignersFormPartial = ({
onChange={(e) => { onChange={(e) => {
field.onChange(e); field.onChange(e);
handleSigningOrderChange(index, e.target.value); handleSigningOrderChange(index, e.target.value);
void handleAutoSave();
}} }}
onBlur={(e) => { onBlur={(e) => {
field.onBlur(); field.onBlur();
handleSigningOrderChange(index, e.target.value); handleSigningOrderChange(index, e.target.value);
void handleAutoSave();
}} }}
disabled={ disabled={
snapshot.isDragging || snapshot.isDragging ||
@ -588,7 +688,9 @@ export const AddSignersFormPartial = ({
isSubmitting || isSubmitting ||
!canRecipientBeModified(signer.nativeId) !canRecipientBeModified(signer.nativeId)
} }
data-testid="signer-email-input"
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onBlur={handleAutoSave}
/> />
</FormControl> </FormControl>
@ -626,6 +728,7 @@ export const AddSignersFormPartial = ({
!canRecipientBeModified(signer.nativeId) !canRecipientBeModified(signer.nativeId)
} }
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onBlur={handleAutoSave}
/> />
</FormControl> </FormControl>
@ -668,6 +771,7 @@ export const AddSignersFormPartial = ({
<div className="col-span-2 flex gap-x-2"> <div className="col-span-2 flex gap-x-2">
<FormField <FormField
control={form.control}
name={`signers.${index}.role`} name={`signers.${index}.role`}
render={({ field }) => ( render={({ field }) => (
<FormItem <FormItem
@ -681,10 +785,11 @@ export const AddSignersFormPartial = ({
<RecipientRoleSelect <RecipientRoleSelect
{...field} {...field}
isAssistantEnabled={isSigningOrderSequential} isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) => onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole) handleRoleChange(index, value as RecipientRole);
} void handleAutoSave();
}}
disabled={ disabled={
snapshot.isDragging || snapshot.isDragging ||
isSubmitting || isSubmitting ||
@ -706,6 +811,7 @@ export const AddSignersFormPartial = ({
'mb-6': form.formState.errors.signers?.[index], 'mb-6': form.formState.errors.signers?.[index],
}, },
)} )}
data-testid="remove-signer-button"
disabled={ disabled={
snapshot.isDragging || snapshot.isDragging ||
isSubmitting || isSubmitting ||

View File

@ -1,3 +1,5 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
@ -8,6 +10,7 @@ import { AnimatePresence, motion } from 'framer-motion';
import { InfoIcon } from 'lucide-react'; import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TDocument } from '@documenso/lib/types/document'; import type { TDocument } from '@documenso/lib/types/document';
@ -60,6 +63,7 @@ export type AddSubjectFormProps = {
fields: Field[]; fields: Field[];
document: TDocument; document: TDocument;
onSubmit: (_data: TAddSubjectFormSchema) => void; onSubmit: (_data: TAddSubjectFormSchema) => void;
onAutoSave: (_data: TAddSubjectFormSchema) => Promise<void>;
isDocumentPdfLoaded: boolean; isDocumentPdfLoaded: boolean;
}; };
@ -69,6 +73,7 @@ export const AddSubjectFormPartial = ({
fields: fields, fields: fields,
document, document,
onSubmit, onSubmit,
onAutoSave,
isDocumentPdfLoaded, isDocumentPdfLoaded,
}: AddSubjectFormProps) => { }: AddSubjectFormProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
@ -95,6 +100,8 @@ export const AddSubjectFormPartial = ({
handleSubmit, handleSubmit,
setValue, setValue,
watch, watch,
trigger,
getValues,
formState: { isSubmitting }, formState: { isSubmitting },
} = form; } = form;
@ -129,6 +136,35 @@ export const AddSubjectFormPartial = ({
const onFormSubmit = handleSubmit(onSubmit); const onFormSubmit = handleSubmit(onSubmit);
const { currentStep, totalSteps, previousStep } = useStep(); const { currentStep, totalSteps, previousStep } = useStep();
const { scheduleSave } = useAutoSave(onAutoSave);
const handleAutoSave = async () => {
const isFormValid = await trigger();
if (!isFormValid) {
return;
}
const formData = getValues();
scheduleSave(formData);
};
useEffect(() => {
const container = window.document.getElementById('document-flow-form-container');
const handleBlur = () => {
void handleAutoSave();
};
if (container) {
container.addEventListener('blur', handleBlur, true);
return () => {
container.removeEventListener('blur', handleBlur, true);
};
}
}, []);
return ( return (
<> <>
<DocumentFlowFormContainerHeader <DocumentFlowFormContainerHeader
@ -185,7 +221,6 @@ export const AddSubjectFormPartial = ({
<FormLabel> <FormLabel>
<Trans>Email Sender</Trans> <Trans>Email Sender</Trans>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Select <Select
{...field} {...field}

View File

@ -6,6 +6,7 @@ import { useLingui } from '@lingui/react';
import { FieldType } from '@prisma/client'; import { FieldType } from '@prisma/client';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import { import {
type TBaseFieldMeta as BaseFieldMeta, type TBaseFieldMeta as BaseFieldMeta,
type TCheckboxFieldMeta as CheckboxFieldMeta, type TCheckboxFieldMeta as CheckboxFieldMeta,
@ -48,6 +49,7 @@ export type FieldAdvancedSettingsProps = {
onAdvancedSettings?: () => void; onAdvancedSettings?: () => void;
isDocumentPdfLoaded?: boolean; isDocumentPdfLoaded?: boolean;
onSave?: (fieldState: FieldMeta) => void; onSave?: (fieldState: FieldMeta) => void;
onAutoSave?: (fieldState: FieldMeta) => Promise<void>;
}; };
export type FieldMetaKeys = export type FieldMetaKeys =
@ -146,7 +148,16 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => {
export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSettingsProps>( export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSettingsProps>(
( (
{ title, description, field, fields, onAdvancedSettings, isDocumentPdfLoaded = true, onSave }, {
title,
description,
field,
fields,
onAdvancedSettings,
isDocumentPdfLoaded = true,
onSave,
onAutoSave,
},
ref, ref,
) => { ) => {
const { _ } = useLingui(); const { _ } = useLingui();
@ -177,6 +188,24 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [fieldMeta]); }, [fieldMeta]);
const { scheduleSave } = useAutoSave(onAutoSave || (async () => {}));
const handleAutoSave = () => {
if (errors.length === 0) {
scheduleSave(fieldState);
}
};
// Auto-save to localStorage and schedule remote save when fieldState changes
useEffect(() => {
try {
localStorage.setItem(localStorageKey, JSON.stringify(fieldState));
handleAutoSave();
} catch (error) {
console.error('Failed to save to localStorage:', error);
}
}, [fieldState, localStorageKey, handleAutoSave]);
const handleFieldChange = ( const handleFieldChange = (
key: FieldMetaKeys, key: FieldMetaKeys,
value: value:
@ -325,7 +354,10 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
)} )}
</DocumentFlowFormContainerContent> </DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter className="mt-auto"> <DocumentFlowFormContainerFooter
className="mt-auto"
data-testid="field-advanced-settings-footer"
>
<DocumentFlowFormContainerActions <DocumentFlowFormContainerActions
goNextLabel={msg`Save`} goNextLabel={msg`Save`}
goBackLabel={msg`Cancel`} goBackLabel={msg`Cancel`}

View File

@ -21,6 +21,7 @@ import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element'; import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
@ -73,6 +74,7 @@ export type AddTemplateFieldsFormProps = {
recipients: Recipient[]; recipients: Recipient[];
fields: Field[]; fields: Field[];
onSubmit: (_data: TAddTemplateFieldsFormSchema) => void; onSubmit: (_data: TAddTemplateFieldsFormSchema) => void;
onAutoSave: (_data: TAddTemplateFieldsFormSchema) => Promise<void>;
teamId: number; teamId: number;
}; };
@ -81,6 +83,7 @@ export const AddTemplateFieldsFormPartial = ({
recipients, recipients,
fields, fields,
onSubmit, onSubmit,
onAutoSave,
teamId, teamId,
}: AddTemplateFieldsFormProps) => { }: AddTemplateFieldsFormProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
@ -121,6 +124,20 @@ export const AddTemplateFieldsFormPartial = ({
const onFormSubmit = form.handleSubmit(onSubmit); const onFormSubmit = form.handleSubmit(onSubmit);
const { scheduleSave } = useAutoSave(onAutoSave);
const handleAutoSave = async () => {
const isFormValid = await form.trigger();
if (!isFormValid) {
return;
}
const formData = form.getValues();
scheduleSave(formData);
};
const { const {
append, append,
remove, remove,
@ -160,6 +177,7 @@ export const AddTemplateFieldsFormPartial = ({
}; };
append(newField); append(newField);
void handleAutoSave();
return; return;
} }
@ -187,6 +205,7 @@ export const AddTemplateFieldsFormPartial = ({
append(newField); append(newField);
}); });
void handleAutoSave();
return; return;
} }
@ -198,7 +217,15 @@ export const AddTemplateFieldsFormPartial = ({
}); });
} }
}, },
[append, lastActiveField, selectedSigner?.email, selectedSigner?.id, toast], [
append,
lastActiveField,
selectedSigner?.email,
selectedSigner?.id,
selectedSigner?.token,
toast,
handleAutoSave,
],
); );
const onFieldPaste = useCallback( const onFieldPaste = useCallback(
@ -218,9 +245,18 @@ export const AddTemplateFieldsFormPartial = ({
pageX: copiedField.pageX + 3, pageX: copiedField.pageX + 3,
pageY: copiedField.pageY + 3, pageY: copiedField.pageY + 3,
}); });
void handleAutoSave();
} }
}, },
[append, fieldClipboard, selectedSigner?.email, selectedSigner?.id, selectedSigner?.token], [
append,
fieldClipboard,
selectedSigner?.email,
selectedSigner?.id,
selectedSigner?.token,
handleAutoSave,
],
); );
useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt)); useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt));
@ -378,8 +414,10 @@ export const AddTemplateFieldsFormPartial = ({
pageWidth, pageWidth,
pageHeight, pageHeight,
}); });
void handleAutoSave();
}, },
[getFieldPosition, localFields, update], [getFieldPosition, localFields, update, handleAutoSave],
); );
const onFieldMove = useCallback( const onFieldMove = useCallback(
@ -401,8 +439,10 @@ export const AddTemplateFieldsFormPartial = ({
pageX, pageX,
pageY, pageY,
}); });
void handleAutoSave();
}, },
[getFieldPosition, localFields, update], [getFieldPosition, localFields, update, handleAutoSave],
); );
useEffect(() => { useEffect(() => {
@ -504,6 +544,7 @@ export const AddTemplateFieldsFormPartial = ({
}); });
form.setValue('fields', updatedFields); form.setValue('fields', updatedFields);
void handleAutoSave();
}; };
return ( return (
@ -519,6 +560,10 @@ export const AddTemplateFieldsFormPartial = ({
fields={localFields} fields={localFields}
onAdvancedSettings={handleAdvancedSettings} onAdvancedSettings={handleAdvancedSettings}
onSave={handleSavedFieldSettings} onSave={handleSavedFieldSettings}
onAutoSave={async (fieldState) => {
handleSavedFieldSettings(fieldState);
await handleAutoSave();
}}
/> />
) : ( ) : (
<> <>
@ -566,12 +611,22 @@ export const AddTemplateFieldsFormPartial = ({
defaultWidth={DEFAULT_WIDTH_PX} defaultWidth={DEFAULT_WIDTH_PX}
passive={isFieldWithinBounds && !!selectedField} passive={isFieldWithinBounds && !!selectedField}
onFocus={() => setLastActiveField(field)} onFocus={() => setLastActiveField(field)}
onBlur={() => setLastActiveField(null)} onBlur={() => {
setLastActiveField(null);
void handleAutoSave();
}}
onResize={(options) => onFieldResize(options, index)} onResize={(options) => onFieldResize(options, index)}
onMove={(options) => onFieldMove(options, index)} onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)} onRemove={() => {
onDuplicate={() => onFieldCopy(null, { duplicate: true })} remove(index);
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })} void handleAutoSave();
}}
onDuplicate={() => {
onFieldCopy(null, { duplicate: true });
}}
onDuplicateAllPages={() => {
onFieldCopy(null, { duplicateAll: true });
}}
onAdvancedSettings={() => { onAdvancedSettings={() => {
setCurrentField(field); setCurrentField(field);
handleAdvancedSettings(); handleAdvancedSettings();

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import { useCallback, useId, useMemo, useRef, useState } from 'react';
import type { DropResult, SensorAPI } from '@hello-pangea/dnd'; import type { DropResult, SensorAPI } from '@hello-pangea/dnd';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'; import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
@ -12,6 +12,7 @@ import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircle, Link2Icon, Plus, Trash } from 'lucide-react'; import { GripVerticalIcon, HelpCircle, Link2Icon, Plus, Trash } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template'; import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
@ -55,6 +56,7 @@ export type AddTemplatePlaceholderRecipientsFormProps = {
allowDictateNextSigner?: boolean; allowDictateNextSigner?: boolean;
templateDirectLink?: TemplateDirectLink | null; templateDirectLink?: TemplateDirectLink | null;
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void; onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
onAutoSave: (_data: TAddTemplatePlacholderRecipientsFormSchema) => Promise<void>;
isDocumentPdfLoaded: boolean; isDocumentPdfLoaded: boolean;
}; };
@ -67,6 +69,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
allowDictateNextSigner, allowDictateNextSigner,
isDocumentPdfLoaded, isDocumentPdfLoaded,
onSubmit, onSubmit,
onAutoSave,
}: AddTemplatePlaceholderRecipientsFormProps) => { }: AddTemplatePlaceholderRecipientsFormProps) => {
const initialId = useId(); const initialId = useId();
const $sensorApi = useRef<SensorAPI | null>(null); const $sensorApi = useRef<SensorAPI | null>(null);
@ -123,15 +126,38 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
}, },
}); });
useEffect(() => { const emptySigners = useCallback(
form.reset({ () => form.getValues('signers').filter((signer) => signer.email === ''),
signers: generateDefaultFormSigners(), [form],
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL, );
allowDictateNextSigner: allowDictateNextSigner ?? false,
});
// eslint-disable-next-line react-hooks/exhaustive-deps const { scheduleSave } = useAutoSave(onAutoSave);
}, [recipients]);
const handleAutoSave = async () => {
if (emptySigners().length > 0) {
return;
}
const isFormValid = await form.trigger();
if (!isFormValid) {
return;
}
const formData = form.getValues();
scheduleSave(formData);
};
// useEffect(() => {
// form.reset({
// signers: generateDefaultFormSigners(),
// signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
// allowDictateNextSigner: allowDictateNextSigner ?? false,
// });
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, [recipients]);
// Always show advanced settings if any recipient has auth options. // Always show advanced settings if any recipient has auth options.
const alwaysShowAdvancedSettings = useMemo(() => { const alwaysShowAdvancedSettings = useMemo(() => {
@ -204,7 +230,12 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
const onRemoveSigner = (index: number) => { const onRemoveSigner = (index: number) => {
removeSigner(index); removeSigner(index);
const updatedSigners = signers.filter((_, idx) => idx !== index); const updatedSigners = signers.filter((_, idx) => idx !== index);
form.setValue('signers', normalizeSigningOrders(updatedSigners)); form.setValue('signers', normalizeSigningOrders(updatedSigners), {
shouldValidate: true,
shouldDirty: true,
});
void handleAutoSave();
}; };
const isSignerDirectRecipient = ( const isSignerDirectRecipient = (
@ -231,7 +262,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
signingOrder: index + 1, signingOrder: index + 1,
})); }));
form.setValue('signers', updatedSigners); form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
const lastSigner = updatedSigners[updatedSigners.length - 1]; const lastSigner = updatedSigners[updatedSigners.length - 1];
if (lastSigner.role === RecipientRole.ASSISTANT) { if (lastSigner.role === RecipientRole.ASSISTANT) {
@ -244,8 +278,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
} }
await form.trigger('signers'); await form.trigger('signers');
void handleAutoSave();
}, },
[form, watchedSigners, toast], [form, watchedSigners, toast, handleAutoSave],
); );
const handleSigningOrderChange = useCallback( const handleSigningOrderChange = useCallback(
@ -273,7 +309,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
signingOrder: idx + 1, signingOrder: idx + 1,
})); }));
form.setValue('signers', updatedSigners); form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) { if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
toast({ toast({
@ -283,8 +322,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
), ),
}); });
} }
void handleAutoSave();
}, },
[form, toast], [form, toast, handleAutoSave],
); );
const handleRoleChange = useCallback( const handleRoleChange = useCallback(
@ -294,7 +335,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
// Handle parallel to sequential conversion for assistants // Handle parallel to sequential conversion for assistants
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) { if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL); form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, {
shouldValidate: true,
shouldDirty: true,
});
toast({ toast({
title: _(msg`Signing order is enabled.`), title: _(msg`Signing order is enabled.`),
description: _(msg`You cannot add assistants when signing order is disabled.`), description: _(msg`You cannot add assistants when signing order is disabled.`),
@ -309,7 +353,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
signingOrder: idx + 1, signingOrder: idx + 1,
})); }));
form.setValue('signers', updatedSigners); form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) { if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
toast({ toast({
@ -319,8 +366,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
), ),
}); });
} }
void handleAutoSave();
}, },
[form, toast], [form, toast, handleAutoSave],
); );
const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false); const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false);
@ -334,10 +383,21 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role, role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
})); }));
form.setValue('signers', updatedSigners); form.setValue('signers', updatedSigners, {
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL); shouldValidate: true,
form.setValue('allowDictateNextSigner', false); shouldDirty: true,
}, [form]); });
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, {
shouldValidate: true,
shouldDirty: true,
});
form.setValue('allowDictateNextSigner', false, {
shouldValidate: true,
shouldDirty: true,
});
void handleAutoSave();
}, [form, handleAutoSave]);
return ( return (
<> <>
@ -382,8 +442,13 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
// If sequential signing is turned off, disable dictate next signer // If sequential signing is turned off, disable dictate next signer
if (!checked) { if (!checked) {
form.setValue('allowDictateNextSigner', false); form.setValue('allowDictateNextSigner', false, {
shouldValidate: true,
shouldDirty: true,
});
} }
void handleAutoSave();
}} }}
disabled={isSubmitting} disabled={isSubmitting}
/> />
@ -409,7 +474,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
{...field} {...field}
id="allowDictateNextSigner" id="allowDictateNextSigner"
checked={value} checked={value}
onCheckedChange={field.onChange} onCheckedChange={(checked) => {
field.onChange(checked);
void handleAutoSave();
}}
disabled={isSubmitting || !isSigningOrderSequential} disabled={isSubmitting || !isSigningOrderSequential}
/> />
</FormControl> </FormControl>
@ -500,6 +568,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
<Input <Input
type="number" type="number"
max={signers.length} max={signers.length}
data-testid="placeholder-recipient-signing-order-input"
className={cn( className={cn(
'w-full text-center', 'w-full text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none', '[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
@ -558,6 +627,8 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
signers[index].email === user?.email || signers[index].email === user?.email ||
isSignerDirectRecipient(signer) isSignerDirectRecipient(signer)
} }
onBlur={handleAutoSave}
data-testid="placeholder-recipient-email-input"
/> />
</FormControl> </FormControl>
@ -592,6 +663,8 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
signers[index].email === user?.email || signers[index].email === user?.email ||
isSignerDirectRecipient(signer) isSignerDirectRecipient(signer)
} }
onBlur={handleAutoSave}
data-testid="placeholder-recipient-name-input"
/> />
</FormControl> </FormControl>
@ -633,10 +706,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
<FormControl> <FormControl>
<RecipientRoleSelect <RecipientRoleSelect
{...field} {...field}
onValueChange={(value) => onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole) handleRoleChange(index, value as RecipientRole);
} }}
disabled={isSubmitting} disabled={isSubmitting}
hideCCRecipients={isSignerDirectRecipient(signer)} hideCCRecipients={isSignerDirectRecipient(signer)}
/> />
@ -672,6 +745,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50" className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSubmitting || signers.length === 1} disabled={isSubmitting || signers.length === 1}
onClick={() => onRemoveSigner(index)} onClick={() => onRemoveSigner(index)}
data-testid="remove-placeholder-recipient-button"
> >
<Trash className="h-5 w-5" /> <Trash className="h-5 w-5" />
</button> </button>

View File

@ -9,6 +9,7 @@ import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { import {
@ -83,6 +84,7 @@ export type AddTemplateSettingsFormProps = {
template: TTemplate; template: TTemplate;
currentTeamMemberRole?: TeamMemberRole; currentTeamMemberRole?: TeamMemberRole;
onSubmit: (_data: TAddTemplateSettingsFormSchema) => void; onSubmit: (_data: TAddTemplateSettingsFormSchema) => void;
onAutoSave: (_data: TAddTemplateSettingsFormSchema) => Promise<void>;
}; };
export const AddTemplateSettingsFormPartial = ({ export const AddTemplateSettingsFormPartial = ({
@ -93,6 +95,7 @@ export const AddTemplateSettingsFormPartial = ({
template, template,
currentTeamMemberRole, currentTeamMemberRole,
onSubmit, onSubmit,
onAutoSave,
}: AddTemplateSettingsFormProps) => { }: AddTemplateSettingsFormProps) => {
const { t, i18n } = useLingui(); const { t, i18n } = useLingui();
@ -160,6 +163,28 @@ export const AddTemplateSettingsFormPartial = ({
} }
}, [form, form.setValue, form.formState.touchedFields.meta?.timezone]); }, [form, form.setValue, form.formState.touchedFields.meta?.timezone]);
const { scheduleSave } = useAutoSave(onAutoSave);
const handleAutoSave = async () => {
const isFormValid = await form.trigger();
if (!isFormValid) {
return;
}
const formData = form.getValues();
/*
* Parse the form data through the Zod schema to handle transformations
* (like -1 -> undefined for the Document Global Auth Access)
*/
const parseResult = ZAddTemplateSettingsFormSchema.safeParse(formData);
if (parseResult.success) {
scheduleSave(parseResult.data);
}
};
return ( return (
<> <>
<DocumentFlowFormContainerHeader <DocumentFlowFormContainerHeader
@ -191,7 +216,7 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input className="bg-background" {...field} /> <Input className="bg-background" {...field} onBlur={handleAutoSave} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -219,7 +244,13 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Select {...field} onValueChange={field.onChange}> <Select
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
>
<SelectTrigger className="bg-background"> <SelectTrigger className="bg-background">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@ -250,9 +281,13 @@ export const AddTemplateSettingsFormPartial = ({
<FormControl> <FormControl>
<DocumentGlobalAuthAccessSelect <DocumentGlobalAuthAccessSelect
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
value={field.value} value={field.value}
disabled={field.disabled} disabled={field.disabled}
onValueChange={field.onChange}
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>
@ -275,7 +310,10 @@ export const AddTemplateSettingsFormPartial = ({
canUpdateVisibility={canUpdateVisibility} canUpdateVisibility={canUpdateVisibility}
currentTeamMemberRole={currentTeamMemberRole} currentTeamMemberRole={currentTeamMemberRole}
{...field} {...field}
onValueChange={field.onChange} onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>
@ -334,7 +372,13 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Select {...field} onValueChange={field.onChange}> <Select
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
>
<SelectTrigger className="bg-background text-muted-foreground"> <SelectTrigger className="bg-background text-muted-foreground">
<SelectValue data-testid="documentDistributionMethodSelectValue" /> <SelectValue data-testid="documentDistributionMethodSelectValue" />
</SelectTrigger> </SelectTrigger>
@ -371,7 +415,10 @@ export const AddTemplateSettingsFormPartial = ({
value: option.value, value: option.value,
}))} }))}
selectedValues={field.value} selectedValues={field.value}
onChange={field.onChange} onChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
className="bg-background w-full" className="bg-background w-full"
emptySelectionPlaceholder="Select signature types" emptySelectionPlaceholder="Select signature types"
/> />
@ -395,9 +442,13 @@ export const AddTemplateSettingsFormPartial = ({
<FormControl> <FormControl>
<DocumentGlobalAuthActionSelect <DocumentGlobalAuthActionSelect
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
value={field.value} value={field.value}
disabled={field.disabled} disabled={field.disabled}
onValueChange={field.onChange}
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>
@ -488,7 +539,7 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} onBlur={handleAutoSave} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@ -515,7 +566,11 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Textarea className="bg-background h-16 resize-none" {...field} /> <Textarea
className="bg-background h-16 resize-none"
{...field}
onBlur={handleAutoSave}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@ -525,7 +580,12 @@ export const AddTemplateSettingsFormPartial = ({
<DocumentEmailCheckboxes <DocumentEmailCheckboxes
value={emailSettings} value={emailSettings}
onChange={(value) => form.setValue('meta.emailSettings', value)} onChange={(value) => {
form.setValue('meta.emailSettings', value, {
shouldDirty: true,
});
void handleAutoSave();
}}
/> />
</div> </div>
</AccordionContent> </AccordionContent>
@ -563,7 +623,7 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input className="bg-background" {...field} /> <Input className="bg-background" {...field} onBlur={handleAutoSave} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@ -581,7 +641,13 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Select {...field} onValueChange={field.onChange}> <Select
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
>
<SelectTrigger className="bg-background"> <SelectTrigger className="bg-background">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@ -615,7 +681,10 @@ export const AddTemplateSettingsFormPartial = ({
className="bg-background time-zone-field" className="bg-background time-zone-field"
options={TIME_ZONES} options={TIME_ZONES}
{...field} {...field}
onChange={(value) => value && field.onChange(value)} onChange={(value) => {
value && field.onChange(value);
void handleAutoSave();
}}
/> />
</FormControl> </FormControl>
@ -645,7 +714,7 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input className="bg-background" {...field} /> <Input className="bg-background" {...field} onBlur={handleAutoSave} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />