diff --git a/apps/documentation/pages/users/compliance/standards-and-regulations.mdx b/apps/documentation/pages/users/compliance/standards-and-regulations.mdx index c1c7845df..4d5193944 100644 --- a/apps/documentation/pages/users/compliance/standards-and-regulations.mdx +++ b/apps/documentation/pages/users/compliance/standards-and-regulations.mdx @@ -19,13 +19,13 @@ device, and other FDA-regulated industries. - [x] User Access Management - [x] Quality Assurance Documentation -## SOC/ SOC II +## SOC 2 - - Status: [Planned](https://github.com/documenso/backlog/issues/24) + + Status: [Compliant](https://documen.so/trust) -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 Public Accountants (AICPA). @@ -34,9 +34,9 @@ Public Accountants (AICPA). Status: [Planned](https://github.com/documenso/backlog/issues/26) -ISO 27001 is an international standard for managing information security, specifying requirements for -establishing, implementing, maintaining, and continually improving an information security management -system (ISMS). +ISO 27001 is an international standard for managing information security, specifying requirements +for establishing, implementing, maintaining, and continually improving an information security +management system (ISMS). ### HIPAA diff --git a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx index 8b883a36b..db429f7e4 100644 --- a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx +++ b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx @@ -57,7 +57,7 @@ export const EmbedDirectTemplateClientPage = ({ token, updatedAt, documentData, - recipient, + recipient: _recipient, fields, metadata, hidePoweredBy = false, @@ -95,6 +95,8 @@ export const EmbedDirectTemplateClientPage = ({ const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE); + const signatureValid = !hasSignatureField || (signature && signature.trim() !== ''); + const { mutateAsync: createDocumentFromDirectTemplate, isPending: isSubmitting } = trpc.template.createDocumentFromDirectTemplate.useMutation(); @@ -345,19 +347,34 @@ export const EmbedDirectTemplateClientPage = ({ Sign document - + {isExpanded ? ( + + ) : pendingFields.length > 0 ? ( + + ) : ( + + )} diff --git a/apps/remix/app/components/embed/embed-document-signing-page.tsx b/apps/remix/app/components/embed/embed-document-signing-page.tsx index d7d8a0713..2f24dceeb 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page.tsx @@ -89,7 +89,7 @@ export const EmbedSignDocumentClientPage = ({ const [isExpanded, setIsExpanded] = useState(false); const [isNameLocked, setIsNameLocked] = useState(false); const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false); - const [showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] = + const [_showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] = useState(false); const [allowDocumentRejection, setAllowDocumentRejection] = useState(false); @@ -118,6 +118,8 @@ export const EmbedSignDocumentClientPage = ({ const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE); + const signatureValid = !hasSignatureField || (signature && signature.trim() !== ''); + const assistantSignersId = useId(); const onNextFieldClick = () => { @@ -307,19 +309,36 @@ export const EmbedSignDocumentClientPage = ({ )} - + {isExpanded ? ( + + ) : pendingFields.length > 0 ? ( + + ) : ( + + )} diff --git a/apps/remix/app/components/general/document-signing/document-signing-form.tsx b/apps/remix/app/components/general/document-signing/document-signing-form.tsx index b3356bee8..b2b58aa3b 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-form.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-form.tsx @@ -7,14 +7,11 @@ import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/cl import { Controller, useForm } from 'react-hook-form'; 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 { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; 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 { trpc } from '@documenso/trpc/react'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { Button } from '@documenso/ui/primitives/button'; import { Input } from '@documenso/ui/primitives/input'; @@ -34,29 +31,33 @@ export type DocumentSigningFormProps = { document: DocumentAndSender; recipient: Recipient; fields: Field[]; - redirectUrl?: string | null; isRecipientsTurn: boolean; allRecipients?: RecipientWithFields[]; setSelectedSignerId?: (id: number | null) => void; + completeDocument: ( + authOptions?: TRecipientActionAuth, + nextSigner?: { email: string; name: string }, + ) => Promise; + isSubmitting: boolean; + fieldsValidated: () => void; + nextRecipient?: RecipientWithFields; }; export const DocumentSigningForm = ({ document, recipient, fields, - redirectUrl, isRecipientsTurn, allRecipients = [], setSelectedSignerId, + completeDocument, + isSubmitting, + fieldsValidated, + nextRecipient, }: DocumentSigningFormProps) => { - const { sessionData } = useOptionalSession(); - const user = sessionData?.user; - const { _ } = useLingui(); const { toast } = useToast(); - const navigate = useNavigate(); - const analytics = useAnalytics(); const assistantSignersId = useId(); @@ -66,21 +67,12 @@ export const DocumentSigningForm = ({ const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false); const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false); - const { - mutateAsync: completeDocumentWithToken, - isPending, - isSuccess, - } = trpc.recipient.completeDocumentWithToken.useMutation(); - const assistantForm = useForm<{ selectedSignerId: number | undefined }>({ defaultValues: { selectedSignerId: undefined, }, }); - // 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], @@ -96,9 +88,9 @@ export const DocumentSigningForm = ({ return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id); }, [fieldsRequiringValidation, recipient]); - const fieldsValidated = () => { + const localFieldsValidated = () => { setValidateUninsertedFields(true); - validateFieldsInserted(fieldsRequiringValidation); + fieldsValidated(); }; 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 (
{validateUninsertedFields && uninsertedFields[0] && ( @@ -205,7 +148,7 @@ export const DocumentSigningForm = ({ isSubmitting={isSubmitting} documentTitle={document.title} fields={fields} - fieldsValidated={fieldsValidated} + fieldsValidated={localFieldsValidated} onSignatureComplete={async (nextSigner) => { await completeDocument(undefined, nextSigner); }} @@ -364,7 +307,7 @@ export const DocumentSigningForm = ({ isSubmitting={isSubmitting || isAssistantSubmitting} documentTitle={document.title} fields={fields} - fieldsValidated={fieldsValidated} + fieldsValidated={localFieldsValidated} disabled={!isRecipientsTurn} onSignatureComplete={async (nextSigner) => { await completeDocument(undefined, nextSigner); diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx index cbbdc7926..626a5195f 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx @@ -1,15 +1,18 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { Trans } from '@lingui/react/macro'; import type { Field } from '@prisma/client'; import { FieldType, RecipientRole } from '@prisma/client'; 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 { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; 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 { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { ZCheckboxFieldMeta, ZDropdownFieldMeta, @@ -18,8 +21,11 @@ import { ZTextFieldMeta, } from '@documenso/lib/types/field-meta'; 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 { 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 { Button } from '@documenso/ui/primitives/button'; 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 { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field'; +import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog'; import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider'; export type DocumentSigningPageViewProps = { @@ -63,9 +70,56 @@ export const DocumentSigningPageView = ({ }: DocumentSigningPageViewProps) => { const { documentData, documentMeta } = document; + const navigate = useNavigate(); + const analytics = useAnalytics(); + const [selectedSignerId, setSelectedSignerId] = useState(allRecipients?.[0]?.id); 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 senderEmail = `(${document.user.email})`; @@ -78,8 +132,31 @@ export const DocumentSigningPageView = ({ const targetSigner = recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null; + const nextRecipient = useMemo(() => { + if (!documentMeta?.signingOrder || documentMeta.signingOrder !== 'SEQUENTIAL') { + return undefined; + } + + const sortedRecipients = allRecipients.sort((a, b) => { + // Sort by signingOrder first (nulls last), then by id + if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id; + if (a.signingOrder === null) return 1; + if (b.signingOrder === null) return -1; + if (a.signingOrder === b.signingOrder) return a.id - b.id; + return a.signingOrder - b.signingOrder; + }); + + const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id); + return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1 + ? sortedRecipients[currentIndex + 1] + : undefined; + }, [document.documentMeta?.signingOrder, allRecipients, recipient.id]); + const highestPageNumber = Math.max(...fields.map((field) => field.page)); + const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted); + const hasPendingFields = pendingFields.length > 0; + return (
@@ -165,19 +242,55 @@ export const DocumentSigningPageView = ({ .otherwise(() => null)} - + )) + .otherwise(() => ( + + > + + + ))}
@@ -206,10 +319,13 @@ export const DocumentSigningPageView = ({ document={document} recipient={recipient} fields={fields} - redirectUrl={documentMeta?.redirectUrl} isRecipientsTurn={isRecipientsTurn} allRecipients={allRecipients} setSelectedSignerId={setSelectedSignerId} + completeDocument={completeDocument} + isSubmitting={isSubmitting} + fieldsValidated={fieldsValidated} + nextRecipient={nextRecipient} />
diff --git a/apps/remix/app/components/general/document/document-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx index 9cb7debf5..0eb192b40 100644 --- a/apps/remix/app/components/general/document/document-edit-form.tsx +++ b/apps/remix/app/components/general/document/document-edit-form.tsx @@ -159,36 +159,37 @@ export const DocumentEditForm = ({ return initialStep; }); + const saveSettingsData = async (data: TAddSettingsFormSchema) => { + const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta; + + const parsedGlobalAccessAuth = z + .array(ZDocumentAccessAuthTypesSchema) + .safeParse(data.globalAccessAuth); + + return updateDocument({ + documentId: document.id, + data: { + title: data.title, + externalId: data.externalId || null, + visibility: data.visibility, + globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [], + globalActionAuth: data.globalActionAuth ?? [], + }, + meta: { + timezone, + dateFormat, + redirectUrl, + language: isValidLanguageCode(language) ? language : undefined, + typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), + uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), + drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), + }, + }); + }; + const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => { try { - const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta; - - const parsedGlobalAccessAuth = z - .array(ZDocumentAccessAuthTypesSchema) - .safeParse(data.globalAccessAuth); - - await updateDocument({ - documentId: document.id, - data: { - title: data.title, - externalId: data.externalId || null, - visibility: data.visibility, - globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [], - globalActionAuth: data.globalActionAuth ?? [], - }, - meta: { - timezone, - dateFormat, - redirectUrl, - language: isValidLanguageCode(language) ? language : undefined, - typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), - uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), - drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), - expiryAmount: data.meta.expiryAmount, - expiryUnit: data.meta.expiryUnit, - }, - }); - + await saveSettingsData(data); setStep('signers'); } catch (err) { console.error(err); @@ -201,26 +202,67 @@ export const DocumentEditForm = ({ } }; + const onAddSettingsFormAutoSave = async (data: TAddSettingsFormSchema) => { + try { + 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({ + documentId: document.id, + 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, + signingOrder: data.signingOrder, + expiryAmount: data.meta.expiryAmount, + expiryUnit: data.meta.expiryUnit, + }, + }), + + setRecipients({ + documentId: document.id, + recipients: data.signers.map((signer) => ({ + ...signer, + // Explicitly set to null to indicate we want to remove auth if required. + actionAuth: signer.actionAuth ?? [], + })), + }), + ]); + }; + + 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 Promise.all([ - updateDocument({ - documentId: document.id, - meta: { - allowDictateNextSigner: data.allowDictateNextSigner, - signingOrder: data.signingOrder, - }, - }), - - setRecipients({ - documentId: document.id, - recipients: data.signers.map((signer) => ({ - ...signer, - // Explicitly set to null to indicate we want to remove auth if required. - actionAuth: signer.actionAuth ?? [], - })), - }), - ]); + await saveSignersData(data); setStep('fields'); } catch (err) { @@ -234,12 +276,16 @@ export const DocumentEditForm = ({ } }; + const saveFieldsData = async (data: TAddFieldsFormSchema) => { + return addFields({ + documentId: document.id, + fields: data.fields, + }); + }; + const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => { try { - await addFields({ - documentId: document.id, - fields: data.fields, - }); + await saveFieldsData(data); // Clear all field data from localStorage for (let i = 0; i < localStorage.length; i++) { @@ -261,24 +307,60 @@ 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 } = data.meta; - try { - await sendDocument({ - documentId: document.id, - meta: { - subject, - message, - distributionMethod, - emailId, - emailReplyTo: emailReplyTo || null, - emailSettings: emailSettings, - }, - }); + return updateDocument({ + documentId: document.id, + meta: { + subject, + message, + distributionMethod, + emailId, + emailReplyTo, + emailSettings: emailSettings, + }, + }); + }; - if (distributionMethod === DocumentDistributionMethod.EMAIL) { + const sendDocumentWithSubject = async (data: TAddSubjectFormSchema) => { + const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } = + data.meta; + + return sendDocument({ + documentId: document.id, + meta: { + subject, + message, + distributionMethod, + emailId, + emailReplyTo: emailReplyTo || null, + emailSettings, + }, + }); + }; + + const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => { + try { + await sendDocumentWithSubject(data); + + if (data.meta.distributionMethod === DocumentDistributionMethod.EMAIL) { toast({ title: _(msg`Document sent`), 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]; /** @@ -351,25 +448,28 @@ export const DocumentEditForm = ({ fields={fields} isDocumentPdfLoaded={isDocumentPdfLoaded} onSubmit={onAddSettingsFormSubmit} + onAutoSave={onAddSettingsFormAutoSave} /> @@ -381,6 +481,7 @@ export const DocumentEditForm = ({ recipients={recipients} fields={fields} onSubmit={onAddSubjectFormSubmit} + onAutoSave={onAddSubjectFormAutoSave} isDocumentPdfLoaded={isDocumentPdfLoaded} /> diff --git a/apps/remix/app/components/general/template/template-edit-form.tsx b/apps/remix/app/components/general/template/template-edit-form.tsx index 3cea126c8..17d7a45a1 100644 --- a/apps/remix/app/components/general/template/template-edit-form.tsx +++ b/apps/remix/app/components/general/template/template-edit-form.tsx @@ -124,32 +124,36 @@ export const TemplateEditForm = ({ }, }); - const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => { + const saveSettingsData = async (data: TAddTemplateSettingsFormSchema) => { const { signatureTypes } = data.meta; const parsedGlobalAccessAuth = z .array(ZDocumentAccessAuthTypesSchema) .safeParse(data.globalAccessAuth); + return updateTemplateSettings({ + templateId: template.id, + data: { + title: data.title, + externalId: data.externalId || null, + visibility: data.visibility, + globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [], + globalActionAuth: data.globalActionAuth ?? [], + }, + meta: { + ...data.meta, + emailReplyTo: data.meta.emailReplyTo || null, + typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), + uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), + drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), + language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined, + }, + }); + }; + + const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => { try { - await updateTemplateSettings({ - templateId: template.id, - data: { - title: data.title, - externalId: data.externalId || null, - visibility: data.visibility, - globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [], - globalActionAuth: data.globalActionAuth ?? [], - }, - meta: { - ...data.meta, - emailReplyTo: data.meta.emailReplyTo || null, - typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), - uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), - drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), - language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined, - }, - }); + await saveSettingsData(data); setStep('signers'); } catch (err) { @@ -163,24 +167,42 @@ export const TemplateEditForm = ({ } }; + const onAddSettingsFormAutoSave = async (data: TAddTemplateSettingsFormSchema) => { + try { + 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({ + templateId: template.id, + meta: { + signingOrder: data.signingOrder, + allowDictateNextSigner: data.allowDictateNextSigner, + }, + }), + + setRecipients({ + templateId: template.id, + recipients: data.signers, + }), + ]); + }; + const onAddTemplatePlaceholderFormSubmit = async ( data: TAddTemplatePlacholderRecipientsFormSchema, ) => { try { - await Promise.all([ - updateTemplateSettings({ - templateId: template.id, - meta: { - signingOrder: data.signingOrder, - allowDictateNextSigner: data.allowDictateNextSigner, - }, - }), - - setRecipients({ - templateId: template.id, - recipients: data.signers, - }), - ]); + await saveTemplatePlaceholderData(data); setStep('fields'); } catch (err) { @@ -192,12 +214,46 @@ export const TemplateEditForm = ({ } }; + const onAddTemplatePlaceholderFormAutoSave = async ( + data: TAddTemplatePlacholderRecipientsFormSchema, + ) => { + try { + 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, + 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 addTemplateFields({ - templateId: template.id, - fields: data.fields, - }); + await saveFieldsData(data); // Clear all field data from localStorage for (let i = 0; i < localStorage.length; i++) { @@ -270,11 +326,12 @@ export const TemplateEditForm = ({ recipients={recipients} fields={fields} onSubmit={onAddSettingsFormSubmit} + onAutoSave={onAddSettingsFormAutoSave} isDocumentPdfLoaded={isDocumentPdfLoaded} /> diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx index 56a01bb90..b27ded2bb 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx @@ -50,10 +50,6 @@ export async function loader({ params, request }: Route.LoaderArgs) { throw redirect(documentRootPath); } - if (document.folderId) { - throw redirect(documentRootPath); - } - const recipients = await getRecipientsForDocument({ documentId, userId: user.id, @@ -68,13 +64,13 @@ export async function loader({ params, request }: Route.LoaderArgs) { return { document, - documentRootPath, recipients, + documentRootPath, }; } export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps) { - const { document, documentRootPath, recipients } = loaderData; + const { document, recipients, documentRootPath } = loaderData; const { _, i18n } = useLingui(); diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx index bd9f0de99..56e046f13 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx @@ -9,10 +9,10 @@ import { trpc } from '@documenso/trpc/react'; import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; 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 { useCurrentTeam } from '~/providers/team'; import { appMetaTags } from '~/utils/meta'; -import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper'; export function meta() { return appMetaTags('Templates'); diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index e9fcbf4d8..08556154a 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -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 { findTemplates } from '@documenso/lib/server-only/template/find-templates'; 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 { ZCheckboxFieldMeta, @@ -980,10 +981,12 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, { userId: user.id, teamId: team?.id, recipients: [ - ...recipients.map(({ email, name }) => ({ - email, - name, - role, + ...recipients.map((recipient) => ({ + email: recipient.email, + name: recipient.name, + role: recipient.role, + signingOrder: recipient.signingOrder, + actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? [], })), { email, diff --git a/packages/api/v1/schema.ts b/packages/api/v1/schema.ts index c33120ae3..b8e31bfe0 100644 --- a/packages/api/v1/schema.ts +++ b/packages/api/v1/schema.ts @@ -33,7 +33,7 @@ export const ZNoBodyMutationSchema = null; */ export const ZGetDocumentsQuerySchema = z.object({ 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; @@ -637,5 +637,5 @@ export const ZSuccessfulGetTemplatesResponseSchema = z.object({ export const ZGetTemplatesQuerySchema = z.object({ 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), }); diff --git a/packages/app-tests/e2e/document-flow/autosave-fields-step.spec.ts b/packages/app-tests/e2e/document-flow/autosave-fields-step.spec.ts new file mode 100644 index 000000000..247f87319 --- /dev/null +++ b/packages/app-tests/e2e/document-flow/autosave-fields-step.spec.ts @@ -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(); + }); +}); diff --git a/packages/app-tests/e2e/document-flow/autosave-settings-step.spec.ts b/packages/app-tests/e2e/document-flow/autosave-settings-step.spec.ts new file mode 100644 index 000000000..e34f2c104 --- /dev/null +++ b/packages/app-tests/e2e/document-flow/autosave-settings-step.spec.ts @@ -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(); + }); +}); diff --git a/packages/app-tests/e2e/document-flow/autosave-signers-step.spec.ts b/packages/app-tests/e2e/document-flow/autosave-signers-step.spec.ts new file mode 100644 index 000000000..e4d255750 --- /dev/null +++ b/packages/app-tests/e2e/document-flow/autosave-signers-step.spec.ts @@ -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(); + }); +}); diff --git a/packages/app-tests/e2e/document-flow/autosave-subject-step.spec.ts b/packages/app-tests/e2e/document-flow/autosave-subject-step.spec.ts new file mode 100644 index 000000000..270a31d8e --- /dev/null +++ b/packages/app-tests/e2e/document-flow/autosave-subject-step.spec.ts @@ -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(); + }); +}); diff --git a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts index 579e21e26..1e1a70288 100644 --- a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts +++ b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts @@ -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.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++) { if (i > 1) { 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}`); } + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + await page.getByLabel('Enable signing order').check(); + await page.getByRole('button', { name: 'Continue' }).click(); await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); diff --git a/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts b/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts new file mode 100644 index 000000000..5a167340a --- /dev/null +++ b/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts @@ -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(); + }); +}); diff --git a/packages/app-tests/e2e/templates-flow/template-autosave-settings-step.spec.ts b/packages/app-tests/e2e/templates-flow/template-autosave-settings-step.spec.ts new file mode 100644 index 000000000..af12e7290 --- /dev/null +++ b/packages/app-tests/e2e/templates-flow/template-autosave-settings-step.spec.ts @@ -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(); + }); +}); diff --git a/packages/app-tests/e2e/templates-flow/template-autosave-signers-step.spec.ts b/packages/app-tests/e2e/templates-flow/template-autosave-signers-step.spec.ts new file mode 100644 index 000000000..f5bd07e94 --- /dev/null +++ b/packages/app-tests/e2e/templates-flow/template-autosave-signers-step.spec.ts @@ -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(); + }); +}); diff --git a/packages/app-tests/playwright.config.ts b/packages/app-tests/playwright.config.ts index 5fb03ada5..3536e340d 100644 --- a/packages/app-tests/playwright.config.ts +++ b/packages/app-tests/playwright.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ testDir: './e2e', /* Run tests in files in parallel */ fullyParallel: false, - workers: 1, + workers: 4, maxFailures: process.env.CI ? 1 : undefined, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, diff --git a/packages/lib/client-only/hooks/use-autosave.ts b/packages/lib/client-only/hooks/use-autosave.ts new file mode 100644 index 000000000..5c9b3db62 --- /dev/null +++ b/packages/lib/client-only/hooks/use-autosave.ts @@ -0,0 +1,31 @@ +import { useCallback, useEffect, useRef } from 'react'; + +export const useAutoSave = (onSave: (data: T) => Promise) => { + const saveTimeoutRef = useRef(); + + 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 }; +}; diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index 1356be3cc..e1b313e90 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -149,33 +149,6 @@ export const sendDocument = async ({ // 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( (recipient) => 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({ event: WebhookTriggerEvents.DOCUMENT_SENT, data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)), diff --git a/packages/lib/server-only/recipient/get-recipients-for-template.ts b/packages/lib/server-only/recipient/get-recipients-for-template.ts index 14cafebfb..6d5c4c88f 100644 --- a/packages/lib/server-only/recipient/get-recipients-for-template.ts +++ b/packages/lib/server-only/recipient/get-recipients-for-template.ts @@ -1,5 +1,7 @@ import { prisma } from '@documenso/prisma'; +import { buildTeamWhereQuery } from '../../utils/teams'; + export interface GetRecipientsForTemplateOptions { templateId: number; userId: number; @@ -14,21 +16,12 @@ export const getRecipientsForTemplate = async ({ const recipients = await prisma.recipient.findMany({ where: { templateId, - template: teamId - ? { - team: { - id: teamId, - members: { - some: { - userId, - }, - }, - }, - } - : { - userId, - teamId: null, - }, + template: { + team: buildTeamWhereQuery({ + teamId, + userId, + }), + }, }, orderBy: { id: 'asc', diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index 137516804..820696e0e 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -21,6 +21,7 @@ import { useHotkeys } from 'react-hotkeys-hook'; import { prop, sortBy } from 'remeda'; 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 { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { @@ -83,6 +84,7 @@ export type AddFieldsFormProps = { recipients: Recipient[]; fields: Field[]; onSubmit: (_data: TAddFieldsFormSchema) => void; + onAutoSave: (_data: TAddFieldsFormSchema) => Promise; canGoBack?: boolean; isDocumentPdfLoaded: boolean; teamId: number; @@ -94,6 +96,7 @@ export const AddFieldsFormPartial = ({ recipients, fields, onSubmit, + onAutoSave, canGoBack = false, isDocumentPdfLoaded, 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 ( <> {showAdvancedSettings && currentField ? ( @@ -603,7 +620,14 @@ export const AddFieldsFormPartial = ({ fields={localFields} onAdvancedSettings={handleAdvancedSettings} 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} passive={isFieldWithinBounds && !!selectedField} onFocus={() => setLastActiveField(field)} - onBlur={() => setLastActiveField(null)} + onBlur={() => { + setLastActiveField(null); + void handleAutoSave(); + }} onMouseEnter={() => setLastActiveField(field)} onMouseLeave={() => setLastActiveField(null)} onResize={(options) => onFieldResize(options, index)} onMove={(options) => onFieldMove(options, index)} - onRemove={() => remove(index)} - onDuplicate={() => onFieldCopy(null, { duplicate: true })} - onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })} + onRemove={() => { + remove(index); + void handleAutoSave(); + }} + onDuplicate={() => { + onFieldCopy(null, { duplicate: true }); + void handleAutoSave(); + }} + onDuplicateAllPages={() => { + onFieldCopy(null, { duplicateAll: true }); + void handleAutoSave(); + }} onAdvancedSettings={() => { setCurrentField(field); handleAdvancedSettings(); diff --git a/packages/ui/primitives/document-flow/add-settings.tsx b/packages/ui/primitives/document-flow/add-settings.tsx index 8a6cc1e58..c479260a0 100644 --- a/packages/ui/primitives/document-flow/add-settings.tsx +++ b/packages/ui/primitives/document-flow/add-settings.tsx @@ -14,6 +14,7 @@ import { InfoIcon } from 'lucide-react'; import { useForm, useWatch } from 'react-hook-form'; 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 { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document'; @@ -92,6 +93,7 @@ export type AddSettingsFormProps = { document: TDocument; currentTeamMemberRole?: TeamMemberRole; onSubmit: (_data: TAddSettingsFormSchema) => void; + onAutoSave: (_data: TAddSettingsFormSchema) => Promise; }; export const AddSettingsFormPartial = ({ @@ -102,6 +104,7 @@ export const AddSettingsFormPartial = ({ document, currentTeamMemberRole, onSubmit, + onAutoSave, }: AddSettingsFormProps) => { const { t } = useLingui(); @@ -182,6 +185,28 @@ export const AddSettingsFormPartial = ({ 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 ( <> @@ -248,9 +274,13 @@ export const AddSettingsFormPartial = ({ + @@ -393,7 +434,10 @@ export const AddSettingsFormPartial = ({ value: option.value, }))} selectedValues={field.value} - onChange={field.onChange} + onChange={(value) => { + field.onChange(value); + void handleAutoSave(); + }} className="bg-background w-full" emptySelectionPlaceholder="Select signature types" /> @@ -415,8 +459,12 @@ export const AddSettingsFormPartial = ({ + diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 6b280f90f..a57c87167 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -14,6 +14,7 @@ import { useFieldArray, useForm } from 'react-hook-form'; import { prop, sortBy } from 'remeda'; 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 { useSession } from '@documenso/lib/client-only/providers/session'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; @@ -55,6 +56,7 @@ export type AddSignersFormProps = { signingOrder?: DocumentSigningOrder | null; allowDictateNextSigner?: boolean; onSubmit: (_data: TAddSignersFormSchema) => void; + onAutoSave: (_data: TAddSignersFormSchema) => Promise; isDocumentPdfLoaded: boolean; }; @@ -65,6 +67,7 @@ export const AddSignersFormPartial = ({ signingOrder, allowDictateNextSigner, onSubmit, + onAutoSave, isDocumentPdfLoaded, }: AddSignersFormProps) => { const { _ } = useLingui(); @@ -166,6 +169,29 @@ export const AddSignersFormPartial = ({ 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 isUserAlreadyARecipient = watchedSigners.some( (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); if (formStateIndex !== -1) { removeSigner(formStateIndex); + 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 = () => { if (emptySignerIndex !== -1) { - setValue(`signers.${emptySignerIndex}.name`, user?.name ?? ''); - setValue(`signers.${emptySignerIndex}.email`, user?.email ?? ''); - } else { - appendSigner({ - formId: nanoid(12), - name: user?.name ?? '', - email: user?.email ?? '', - role: RecipientRole.SIGNER, - actionAuth: [], - signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1, + setValue(`signers.${emptySignerIndex}.name`, user?.name ?? '', { + shouldValidate: true, + shouldDirty: true, }); + setValue(`signers.${emptySignerIndex}.email`, user?.email ?? '', { + shouldValidate: true, + shouldDirty: true, + }); + + form.setFocus(`signers.${emptySignerIndex}.email`); + } else { + appendSigner( + { + formId: nanoid(12), + name: user?.name ?? '', + email: user?.email ?? '', + role: RecipientRole.SIGNER, + actionAuth: [], + 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, })); - form.setValue('signers', updatedSigners); + form.setValue('signers', updatedSigners, { + shouldValidate: true, + shouldDirty: true, + }); const lastSigner = updatedSigners[updatedSigners.length - 1]; if (lastSigner.role === RecipientRole.ASSISTANT) { @@ -276,8 +328,10 @@ export const AddSignersFormPartial = ({ } await form.trigger('signers'); + + void handleAutoSave(); }, - [form, canRecipientBeModified, watchedSigners, toast], + [form, canRecipientBeModified, watchedSigners, handleAutoSave, toast], ); const handleRoleChange = useCallback( @@ -287,7 +341,10 @@ export const AddSignersFormPartial = ({ // Handle parallel to sequential conversion for assistants if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) { - form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL); + form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, { + shouldValidate: true, + shouldDirty: true, + }); toast({ title: _(msg`Signing order is enabled.`), 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, })); - form.setValue('signers', updatedSigners); + form.setValue('signers', updatedSigners, { + shouldValidate: true, + shouldDirty: true, + }); if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) { toast({ @@ -341,7 +401,10 @@ export const AddSignersFormPartial = ({ 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) { toast({ @@ -364,9 +427,20 @@ export const AddSignersFormPartial = ({ role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role, })); - form.setValue('signers', updatedSigners); - form.setValue('signingOrder', DocumentSigningOrder.PARALLEL); - form.setValue('allowDictateNextSigner', false); + form.setValue('signers', updatedSigners, { + shouldValidate: true, + shouldDirty: true, + }); + form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, { + shouldValidate: true, + shouldDirty: true, + }); + form.setValue('allowDictateNextSigner', false, { + shouldValidate: true, + shouldDirty: true, + }); + + void handleAutoSave(); }, [form]); return ( @@ -408,19 +482,39 @@ export const AddSignersFormPartial = ({ // If sequential signing is turned off, disable dictate next signer 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} /> - - Enable signing order - +
+ + Enable signing order + + + + + + + + + +

+ Add 2 or more signers to enable signing order. +

+
+
+
)} /> @@ -435,12 +529,15 @@ export const AddSignersFormPartial = ({ {...field} id="allowDictateNextSigner" checked={value} - onCheckedChange={field.onChange} + onCheckedChange={(checked) => { + field.onChange(checked); + void handleAutoSave(); + }} disabled={isSubmitting || hasDocumentBeenSent || !isSigningOrderSequential} /> -
+
{ field.onChange(e); handleSigningOrderChange(index, e.target.value); + void handleAutoSave(); }} onBlur={(e) => { field.onBlur(); handleSigningOrderChange(index, e.target.value); + void handleAutoSave(); }} disabled={ snapshot.isDragging || @@ -588,7 +688,9 @@ export const AddSignersFormPartial = ({ isSubmitting || !canRecipientBeModified(signer.nativeId) } + data-testid="signer-email-input" onKeyDown={onKeyDown} + onBlur={handleAutoSave} /> @@ -626,6 +728,7 @@ export const AddSignersFormPartial = ({ !canRecipientBeModified(signer.nativeId) } onKeyDown={onKeyDown} + onBlur={handleAutoSave} /> @@ -668,6 +771,7 @@ export const AddSignersFormPartial = ({
( + onValueChange={(value) => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - handleRoleChange(index, value as RecipientRole) - } + handleRoleChange(index, value as RecipientRole); + void handleAutoSave(); + }} disabled={ snapshot.isDragging || isSubmitting || @@ -706,6 +811,7 @@ export const AddSignersFormPartial = ({ 'mb-6': form.formState.errors.signers?.[index], }, )} + data-testid="remove-signer-button" disabled={ snapshot.isDragging || isSubmitting || diff --git a/packages/ui/primitives/document-flow/add-subject.tsx b/packages/ui/primitives/document-flow/add-subject.tsx index 6553597f2..82f6f11d5 100644 --- a/packages/ui/primitives/document-flow/add-subject.tsx +++ b/packages/ui/primitives/document-flow/add-subject.tsx @@ -1,3 +1,5 @@ +import { useEffect } from 'react'; + import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; @@ -8,6 +10,7 @@ import { AnimatePresence, motion } from 'framer-motion'; import { InfoIcon } from 'lucide-react'; 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 { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import type { TDocument } from '@documenso/lib/types/document'; @@ -60,6 +63,7 @@ export type AddSubjectFormProps = { fields: Field[]; document: TDocument; onSubmit: (_data: TAddSubjectFormSchema) => void; + onAutoSave: (_data: TAddSubjectFormSchema) => Promise; isDocumentPdfLoaded: boolean; }; @@ -69,6 +73,7 @@ export const AddSubjectFormPartial = ({ fields: fields, document, onSubmit, + onAutoSave, isDocumentPdfLoaded, }: AddSubjectFormProps) => { const { _ } = useLingui(); @@ -95,6 +100,8 @@ export const AddSubjectFormPartial = ({ handleSubmit, setValue, watch, + trigger, + getValues, formState: { isSubmitting }, } = form; @@ -129,6 +136,35 @@ export const AddSubjectFormPartial = ({ const onFormSubmit = handleSubmit(onSubmit); 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 ( <> Email Sender - @@ -592,6 +663,8 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ signers[index].email === user?.email || isSignerDirectRecipient(signer) } + onBlur={handleAutoSave} + data-testid="placeholder-recipient-name-input" /> @@ -633,10 +706,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ + onValueChange={(value) => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - handleRoleChange(index, value as RecipientRole) - } + handleRoleChange(index, value as RecipientRole); + }} disabled={isSubmitting} 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" disabled={isSubmitting || signers.length === 1} onClick={() => onRemoveSigner(index)} + data-testid="remove-placeholder-recipient-button" > diff --git a/packages/ui/primitives/template-flow/add-template-settings.tsx b/packages/ui/primitives/template-flow/add-template-settings.tsx index d880b8edb..374e31a69 100644 --- a/packages/ui/primitives/template-flow/add-template-settings.tsx +++ b/packages/ui/primitives/template-flow/add-template-settings.tsx @@ -9,6 +9,7 @@ import { InfoIcon } from 'lucide-react'; import { useForm } from 'react-hook-form'; 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 { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { @@ -83,6 +84,7 @@ export type AddTemplateSettingsFormProps = { template: TTemplate; currentTeamMemberRole?: TeamMemberRole; onSubmit: (_data: TAddTemplateSettingsFormSchema) => void; + onAutoSave: (_data: TAddTemplateSettingsFormSchema) => Promise; }; export const AddTemplateSettingsFormPartial = ({ @@ -93,6 +95,7 @@ export const AddTemplateSettingsFormPartial = ({ template, currentTeamMemberRole, onSubmit, + onAutoSave, }: AddTemplateSettingsFormProps) => { const { t, i18n } = useLingui(); @@ -160,6 +163,28 @@ export const AddTemplateSettingsFormPartial = ({ } }, [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 ( <> - + @@ -219,7 +244,13 @@ export const AddTemplateSettingsFormPartial = ({ - { + field.onChange(value); + void handleAutoSave(); + }} + > @@ -250,9 +281,13 @@ export const AddTemplateSettingsFormPartial = ({ { + field.onChange(value); + void handleAutoSave(); + }} value={field.value} disabled={field.disabled} - onValueChange={field.onChange} /> @@ -275,7 +310,10 @@ export const AddTemplateSettingsFormPartial = ({ canUpdateVisibility={canUpdateVisibility} currentTeamMemberRole={currentTeamMemberRole} {...field} - onValueChange={field.onChange} + onValueChange={(value) => { + field.onChange(value); + void handleAutoSave(); + }} /> @@ -334,7 +372,13 @@ export const AddTemplateSettingsFormPartial = ({ - { + field.onChange(value); + void handleAutoSave(); + }} + > @@ -371,7 +415,10 @@ export const AddTemplateSettingsFormPartial = ({ value: option.value, }))} selectedValues={field.value} - onChange={field.onChange} + onChange={(value) => { + field.onChange(value); + void handleAutoSave(); + }} className="bg-background w-full" emptySelectionPlaceholder="Select signature types" /> @@ -395,9 +442,13 @@ export const AddTemplateSettingsFormPartial = ({ { + field.onChange(value); + void handleAutoSave(); + }} value={field.value} disabled={field.disabled} - onValueChange={field.onChange} /> @@ -488,7 +539,7 @@ export const AddTemplateSettingsFormPartial = ({ - + @@ -515,7 +566,11 @@ export const AddTemplateSettingsFormPartial = ({ -