mirror of
https://github.com/documenso/documenso.git
synced 2025-11-15 09:12:02 +10:00
Merge branch 'main' into feat/expiry-links
This commit is contained in:
@ -19,13 +19,13 @@ device, and other FDA-regulated industries.
|
|||||||
- [x] User Access Management
|
- [x] User Access Management
|
||||||
- [x] Quality Assurance Documentation
|
- [x] Quality Assurance Documentation
|
||||||
|
|
||||||
## SOC/ SOC II
|
## SOC 2
|
||||||
|
|
||||||
<Callout type="warning" emoji="⏳">
|
<Callout type="info" emoji="✅">
|
||||||
Status: [Planned](https://github.com/documenso/backlog/issues/24)
|
Status: [Compliant](https://documen.so/trust)
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
SOC II is a framework for managing and auditing the security, availability, processing integrity, confidentiality,
|
SOC 2 is a framework for managing and auditing the security, availability, processing integrity, confidentiality,
|
||||||
and data privacy in cloud and IT service organizations, established by the American Institute of Certified
|
and data privacy in cloud and IT service organizations, established by the American Institute of Certified
|
||||||
Public Accountants (AICPA).
|
Public Accountants (AICPA).
|
||||||
|
|
||||||
@ -34,9 +34,9 @@ Public Accountants (AICPA).
|
|||||||
<Callout type="warning" emoji="⏳">
|
<Callout type="warning" emoji="⏳">
|
||||||
Status: [Planned](https://github.com/documenso/backlog/issues/26)
|
Status: [Planned](https://github.com/documenso/backlog/issues/26)
|
||||||
</Callout>
|
</Callout>
|
||||||
ISO 27001 is an international standard for managing information security, specifying requirements for
|
ISO 27001 is an international standard for managing information security, specifying requirements
|
||||||
establishing, implementing, maintaining, and continually improving an information security management
|
for establishing, implementing, maintaining, and continually improving an information security
|
||||||
system (ISMS).
|
management system (ISMS).
|
||||||
|
|
||||||
### HIPAA
|
### HIPAA
|
||||||
|
|
||||||
|
|||||||
@ -57,7 +57,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
token,
|
token,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
documentData,
|
documentData,
|
||||||
recipient,
|
recipient: _recipient,
|
||||||
fields,
|
fields,
|
||||||
metadata,
|
metadata,
|
||||||
hidePoweredBy = false,
|
hidePoweredBy = false,
|
||||||
@ -95,6 +95,8 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
|
|
||||||
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
|
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
|
||||||
|
|
||||||
|
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
||||||
|
|
||||||
const { mutateAsync: createDocumentFromDirectTemplate, isPending: isSubmitting } =
|
const { mutateAsync: createDocumentFromDirectTemplate, isPending: isSubmitting } =
|
||||||
trpc.template.createDocumentFromDirectTemplate.useMutation();
|
trpc.template.createDocumentFromDirectTemplate.useMutation();
|
||||||
|
|
||||||
@ -345,19 +347,34 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
<Trans>Sign document</Trans>
|
<Trans>Sign document</Trans>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
{isExpanded ? (
|
||||||
{isExpanded ? (
|
<Button
|
||||||
<LucideChevronDown
|
variant="outline"
|
||||||
className="text-muted-foreground h-5 w-5"
|
className="h-8 w-8 p-0 md:hidden"
|
||||||
onClick={() => setIsExpanded(false)}
|
onClick={() => setIsExpanded(false)}
|
||||||
/>
|
>
|
||||||
) : (
|
<LucideChevronDown className="text-muted-foreground h-5 w-5" />
|
||||||
<LucideChevronUp
|
</Button>
|
||||||
className="text-muted-foreground h-5 w-5"
|
) : pendingFields.length > 0 ? (
|
||||||
onClick={() => setIsExpanded(true)}
|
<Button
|
||||||
/>
|
variant="outline"
|
||||||
)}
|
className="h-8 w-8 p-0 md:hidden"
|
||||||
</Button>
|
onClick={() => setIsExpanded(true)}
|
||||||
|
>
|
||||||
|
<LucideChevronUp className="text-muted-foreground h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="md:hidden"
|
||||||
|
disabled={isThrottled || (hasSignatureField && !signatureValid)}
|
||||||
|
loading={isSubmitting}
|
||||||
|
onClick={() => throttledOnCompleteClick()}
|
||||||
|
>
|
||||||
|
<Trans>Complete</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -89,7 +89,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||||
const [showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
|
const [_showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
||||||
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
||||||
@ -118,6 +118,8 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
|
|
||||||
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
||||||
|
|
||||||
|
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
||||||
|
|
||||||
const assistantSignersId = useId();
|
const assistantSignersId = useId();
|
||||||
|
|
||||||
const onNextFieldClick = () => {
|
const onNextFieldClick = () => {
|
||||||
@ -307,19 +309,36 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
{isExpanded ? (
|
||||||
{isExpanded ? (
|
<Button
|
||||||
<LucideChevronDown
|
variant="outline"
|
||||||
className="text-muted-foreground h-5 w-5"
|
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||||
onClick={() => setIsExpanded(false)}
|
onClick={() => setIsExpanded(false)}
|
||||||
/>
|
>
|
||||||
) : (
|
<LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||||
<LucideChevronUp
|
</Button>
|
||||||
className="text-muted-foreground h-5 w-5"
|
) : pendingFields.length > 0 ? (
|
||||||
onClick={() => setIsExpanded(true)}
|
<Button
|
||||||
/>
|
variant="outline"
|
||||||
)}
|
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||||
</Button>
|
onClick={() => setIsExpanded(true)}
|
||||||
|
>
|
||||||
|
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="md:hidden"
|
||||||
|
disabled={
|
||||||
|
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
|
||||||
|
}
|
||||||
|
loading={isSubmitting}
|
||||||
|
onClick={() => throttledOnCompleteClick()}
|
||||||
|
>
|
||||||
|
<Trans>Complete</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -7,14 +7,11 @@ import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/cl
|
|||||||
import { Controller, useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
|
||||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
|
||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
@ -34,29 +31,33 @@ export type DocumentSigningFormProps = {
|
|||||||
document: DocumentAndSender;
|
document: DocumentAndSender;
|
||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
redirectUrl?: string | null;
|
|
||||||
isRecipientsTurn: boolean;
|
isRecipientsTurn: boolean;
|
||||||
allRecipients?: RecipientWithFields[];
|
allRecipients?: RecipientWithFields[];
|
||||||
setSelectedSignerId?: (id: number | null) => void;
|
setSelectedSignerId?: (id: number | null) => void;
|
||||||
|
completeDocument: (
|
||||||
|
authOptions?: TRecipientActionAuth,
|
||||||
|
nextSigner?: { email: string; name: string },
|
||||||
|
) => Promise<void>;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
fieldsValidated: () => void;
|
||||||
|
nextRecipient?: RecipientWithFields;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentSigningForm = ({
|
export const DocumentSigningForm = ({
|
||||||
document,
|
document,
|
||||||
recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
redirectUrl,
|
|
||||||
isRecipientsTurn,
|
isRecipientsTurn,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
setSelectedSignerId,
|
setSelectedSignerId,
|
||||||
|
completeDocument,
|
||||||
|
isSubmitting,
|
||||||
|
fieldsValidated,
|
||||||
|
nextRecipient,
|
||||||
}: DocumentSigningFormProps) => {
|
}: DocumentSigningFormProps) => {
|
||||||
const { sessionData } = useOptionalSession();
|
|
||||||
const user = sessionData?.user;
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const analytics = useAnalytics();
|
|
||||||
|
|
||||||
const assistantSignersId = useId();
|
const assistantSignersId = useId();
|
||||||
|
|
||||||
@ -66,21 +67,12 @@ export const DocumentSigningForm = ({
|
|||||||
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
|
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
|
||||||
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
|
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
|
||||||
|
|
||||||
const {
|
|
||||||
mutateAsync: completeDocumentWithToken,
|
|
||||||
isPending,
|
|
||||||
isSuccess,
|
|
||||||
} = trpc.recipient.completeDocumentWithToken.useMutation();
|
|
||||||
|
|
||||||
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
|
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
selectedSignerId: undefined,
|
selectedSignerId: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keep the loading state going if successful since the redirect may take some time.
|
|
||||||
const isSubmitting = isPending || isSuccess;
|
|
||||||
|
|
||||||
const fieldsRequiringValidation = useMemo(
|
const fieldsRequiringValidation = useMemo(
|
||||||
() => fields.filter(isFieldUnsignedAndRequired),
|
() => fields.filter(isFieldUnsignedAndRequired),
|
||||||
[fields],
|
[fields],
|
||||||
@ -96,9 +88,9 @@ export const DocumentSigningForm = ({
|
|||||||
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
|
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
|
||||||
}, [fieldsRequiringValidation, recipient]);
|
}, [fieldsRequiringValidation, recipient]);
|
||||||
|
|
||||||
const fieldsValidated = () => {
|
const localFieldsValidated = () => {
|
||||||
setValidateUninsertedFields(true);
|
setValidateUninsertedFields(true);
|
||||||
validateFieldsInserted(fieldsRequiringValidation);
|
fieldsValidated();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAssistantFormSubmit = () => {
|
const onAssistantFormSubmit = () => {
|
||||||
@ -126,55 +118,6 @@ export const DocumentSigningForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const completeDocument = async (
|
|
||||||
authOptions?: TRecipientActionAuth,
|
|
||||||
nextSigner?: { email: string; name: string },
|
|
||||||
) => {
|
|
||||||
const payload = {
|
|
||||||
token: recipient.token,
|
|
||||||
documentId: document.id,
|
|
||||||
authOptions,
|
|
||||||
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
await completeDocumentWithToken(payload);
|
|
||||||
|
|
||||||
analytics.capture('App: Recipient has completed signing', {
|
|
||||||
signerId: recipient.id,
|
|
||||||
documentId: document.id,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (redirectUrl) {
|
|
||||||
window.location.href = redirectUrl;
|
|
||||||
} else {
|
|
||||||
await navigate(`/sign/${recipient.token}/complete`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextRecipient = useMemo(() => {
|
|
||||||
if (
|
|
||||||
!document.documentMeta?.signingOrder ||
|
|
||||||
document.documentMeta.signingOrder !== 'SEQUENTIAL'
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedRecipients = allRecipients.sort((a, b) => {
|
|
||||||
// Sort by signingOrder first (nulls last), then by id
|
|
||||||
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
|
|
||||||
if (a.signingOrder === null) return 1;
|
|
||||||
if (b.signingOrder === null) return -1;
|
|
||||||
if (a.signingOrder === b.signingOrder) return a.id - b.id;
|
|
||||||
return a.signingOrder - b.signingOrder;
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
|
|
||||||
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
|
|
||||||
? sortedRecipients[currentIndex + 1]
|
|
||||||
: undefined;
|
|
||||||
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{validateUninsertedFields && uninsertedFields[0] && (
|
{validateUninsertedFields && uninsertedFields[0] && (
|
||||||
@ -205,7 +148,7 @@ export const DocumentSigningForm = ({
|
|||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
documentTitle={document.title}
|
documentTitle={document.title}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={localFieldsValidated}
|
||||||
onSignatureComplete={async (nextSigner) => {
|
onSignatureComplete={async (nextSigner) => {
|
||||||
await completeDocument(undefined, nextSigner);
|
await completeDocument(undefined, nextSigner);
|
||||||
}}
|
}}
|
||||||
@ -364,7 +307,7 @@ export const DocumentSigningForm = ({
|
|||||||
isSubmitting={isSubmitting || isAssistantSubmitting}
|
isSubmitting={isSubmitting || isAssistantSubmitting}
|
||||||
documentTitle={document.title}
|
documentTitle={document.title}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={localFieldsValidated}
|
||||||
disabled={!isRecipientsTurn}
|
disabled={!isRecipientsTurn}
|
||||||
onSignatureComplete={async (nextSigner) => {
|
onSignatureComplete={async (nextSigner) => {
|
||||||
await completeDocument(undefined, nextSigner);
|
await completeDocument(undefined, nextSigner);
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Field } from '@prisma/client';
|
import type { Field } from '@prisma/client';
|
||||||
import { FieldType, RecipientRole } from '@prisma/client';
|
import { FieldType, RecipientRole } from '@prisma/client';
|
||||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||||
import { match } from 'ts-pattern';
|
import { useNavigate } from 'react-router';
|
||||||
|
import { P, match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
import {
|
import {
|
||||||
ZCheckboxFieldMeta,
|
ZCheckboxFieldMeta,
|
||||||
ZDropdownFieldMeta,
|
ZDropdownFieldMeta,
|
||||||
@ -18,8 +21,11 @@ import {
|
|||||||
ZTextFieldMeta,
|
ZTextFieldMeta,
|
||||||
} from '@documenso/lib/types/field-meta';
|
} from '@documenso/lib/types/field-meta';
|
||||||
import type { CompletedField } from '@documenso/lib/types/fields';
|
import type { CompletedField } from '@documenso/lib/types/fields';
|
||||||
|
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
|
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
@ -40,6 +46,7 @@ import { DocumentSigningRejectDialog } from '~/components/general/document-signi
|
|||||||
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
||||||
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
||||||
|
|
||||||
|
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||||
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
||||||
|
|
||||||
export type DocumentSigningPageViewProps = {
|
export type DocumentSigningPageViewProps = {
|
||||||
@ -63,9 +70,56 @@ export const DocumentSigningPageView = ({
|
|||||||
}: DocumentSigningPageViewProps) => {
|
}: DocumentSigningPageViewProps) => {
|
||||||
const { documentData, documentMeta } = document;
|
const { documentData, documentMeta } = document;
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
mutateAsync: completeDocumentWithToken,
|
||||||
|
isPending,
|
||||||
|
isSuccess,
|
||||||
|
} = trpc.recipient.completeDocumentWithToken.useMutation();
|
||||||
|
|
||||||
|
// Keep the loading state going if successful since the redirect may take some time.
|
||||||
|
const isSubmitting = isPending || isSuccess;
|
||||||
|
|
||||||
|
const fieldsRequiringValidation = useMemo(
|
||||||
|
() => fields.filter(isFieldUnsignedAndRequired),
|
||||||
|
[fields],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fieldsValidated = () => {
|
||||||
|
validateFieldsInserted(fieldsRequiringValidation);
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeDocument = async (
|
||||||
|
authOptions?: TRecipientActionAuth,
|
||||||
|
nextSigner?: { email: string; name: string },
|
||||||
|
) => {
|
||||||
|
const payload = {
|
||||||
|
token: recipient.token,
|
||||||
|
documentId: document.id,
|
||||||
|
authOptions,
|
||||||
|
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await completeDocumentWithToken(payload);
|
||||||
|
|
||||||
|
analytics.capture('App: Recipient has completed signing', {
|
||||||
|
signerId: recipient.id,
|
||||||
|
documentId: document.id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (documentMeta?.redirectUrl) {
|
||||||
|
window.location.href = documentMeta.redirectUrl;
|
||||||
|
} else {
|
||||||
|
await navigate(`/sign/${recipient.token}/complete`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let senderName = document.user.name ?? '';
|
let senderName = document.user.name ?? '';
|
||||||
let senderEmail = `(${document.user.email})`;
|
let senderEmail = `(${document.user.email})`;
|
||||||
|
|
||||||
@ -78,8 +132,31 @@ export const DocumentSigningPageView = ({
|
|||||||
const targetSigner =
|
const targetSigner =
|
||||||
recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null;
|
recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null;
|
||||||
|
|
||||||
|
const nextRecipient = useMemo(() => {
|
||||||
|
if (!documentMeta?.signingOrder || documentMeta.signingOrder !== 'SEQUENTIAL') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedRecipients = allRecipients.sort((a, b) => {
|
||||||
|
// Sort by signingOrder first (nulls last), then by id
|
||||||
|
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
|
||||||
|
if (a.signingOrder === null) return 1;
|
||||||
|
if (b.signingOrder === null) return -1;
|
||||||
|
if (a.signingOrder === b.signingOrder) return a.id - b.id;
|
||||||
|
return a.signingOrder - b.signingOrder;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
|
||||||
|
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
|
||||||
|
? sortedRecipients[currentIndex + 1]
|
||||||
|
: undefined;
|
||||||
|
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
||||||
|
|
||||||
const highestPageNumber = Math.max(...fields.map((field) => field.page));
|
const highestPageNumber = Math.max(...fields.map((field) => field.page));
|
||||||
|
|
||||||
|
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
|
||||||
|
const hasPendingFields = pendingFields.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
|
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
|
||||||
<div className="mx-auto w-full max-w-screen-xl sm:px-6">
|
<div className="mx-auto w-full max-w-screen-xl sm:px-6">
|
||||||
@ -165,19 +242,55 @@ export const DocumentSigningPageView = ({
|
|||||||
.otherwise(() => null)}
|
.otherwise(() => null)}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
{match({ hasPendingFields, isExpanded, role: recipient.role })
|
||||||
{isExpanded ? (
|
.with(
|
||||||
<LucideChevronDown
|
{
|
||||||
className="text-muted-foreground h-5 w-5"
|
hasPendingFields: false,
|
||||||
|
role: P.not(RecipientRole.ASSISTANT),
|
||||||
|
isExpanded: false,
|
||||||
|
},
|
||||||
|
() => (
|
||||||
|
<div className="md:hidden">
|
||||||
|
<DocumentSigningCompleteDialog
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
documentTitle={document.title}
|
||||||
|
fields={fields}
|
||||||
|
fieldsValidated={fieldsValidated}
|
||||||
|
disabled={!isRecipientsTurn}
|
||||||
|
onSignatureComplete={async (nextSigner) => {
|
||||||
|
await completeDocument(undefined, nextSigner);
|
||||||
|
}}
|
||||||
|
role={recipient.role}
|
||||||
|
allowDictateNextSigner={
|
||||||
|
nextRecipient && documentMeta?.allowDictateNextSigner
|
||||||
|
}
|
||||||
|
defaultNextSigner={
|
||||||
|
nextRecipient
|
||||||
|
? { name: nextRecipient.name, email: nextRecipient.email }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with({ isExpanded: true }, () => (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||||
onClick={() => setIsExpanded(false)}
|
onClick={() => setIsExpanded(false)}
|
||||||
/>
|
>
|
||||||
) : (
|
<LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||||
<LucideChevronUp
|
</Button>
|
||||||
className="text-muted-foreground h-5 w-5"
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||||
onClick={() => setIsExpanded(true)}
|
onClick={() => setIsExpanded(true)}
|
||||||
/>
|
>
|
||||||
)}
|
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden group-data-[expanded]/document-widget:block md:block">
|
<div className="hidden group-data-[expanded]/document-widget:block md:block">
|
||||||
@ -206,10 +319,13 @@ export const DocumentSigningPageView = ({
|
|||||||
document={document}
|
document={document}
|
||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
redirectUrl={documentMeta?.redirectUrl}
|
|
||||||
isRecipientsTurn={isRecipientsTurn}
|
isRecipientsTurn={isRecipientsTurn}
|
||||||
allRecipients={allRecipients}
|
allRecipients={allRecipients}
|
||||||
setSelectedSignerId={setSelectedSignerId}
|
setSelectedSignerId={setSelectedSignerId}
|
||||||
|
completeDocument={completeDocument}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
fieldsValidated={fieldsValidated}
|
||||||
|
nextRecipient={nextRecipient}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -159,36 +159,37 @@ export const DocumentEditForm = ({
|
|||||||
return initialStep;
|
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) => {
|
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta;
|
await saveSettingsData(data);
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
setStep('signers');
|
setStep('signers');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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) => {
|
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await saveSignersData(data);
|
||||||
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 ?? [],
|
|
||||||
})),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
setStep('fields');
|
setStep('fields');
|
||||||
} catch (err) {
|
} 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) => {
|
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await addFields({
|
await saveFieldsData(data);
|
||||||
documentId: document.id,
|
|
||||||
fields: data.fields,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear all field data from localStorage
|
// Clear all field data from localStorage
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
@ -261,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 } =
|
const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } =
|
||||||
data.meta;
|
data.meta;
|
||||||
|
|
||||||
try {
|
return updateDocument({
|
||||||
await sendDocument({
|
documentId: document.id,
|
||||||
documentId: document.id,
|
meta: {
|
||||||
meta: {
|
subject,
|
||||||
subject,
|
message,
|
||||||
message,
|
distributionMethod,
|
||||||
distributionMethod,
|
emailId,
|
||||||
emailId,
|
emailReplyTo,
|
||||||
emailReplyTo: emailReplyTo || null,
|
emailSettings: emailSettings,
|
||||||
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({
|
toast({
|
||||||
title: _(msg`Document sent`),
|
title: _(msg`Document sent`),
|
||||||
description: _(msg`Your document has been sent successfully.`),
|
description: _(msg`Your document has been sent successfully.`),
|
||||||
@ -306,6 +388,21 @@ export const DocumentEditForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onAddSubjectFormAutoSave = async (data: TAddSubjectFormSchema) => {
|
||||||
|
try {
|
||||||
|
// Save form data without sending the document
|
||||||
|
await saveSubjectData(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Error`),
|
||||||
|
description: _(msg`An error occurred while auto-saving the subject form.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const currentDocumentFlow = documentFlow[step];
|
const currentDocumentFlow = documentFlow[step];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -351,25 +448,28 @@ export const DocumentEditForm = ({
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
onSubmit={onAddSettingsFormSubmit}
|
onSubmit={onAddSettingsFormSubmit}
|
||||||
|
onAutoSave={onAddSettingsFormAutoSave}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddSignersFormPartial
|
<AddSignersFormPartial
|
||||||
key={recipients.length}
|
key={document.id}
|
||||||
documentFlow={documentFlow.signers}
|
documentFlow={documentFlow.signers}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
signingOrder={document.documentMeta?.signingOrder}
|
signingOrder={document.documentMeta?.signingOrder}
|
||||||
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddSignersFormSubmit}
|
onSubmit={onAddSignersFormSubmit}
|
||||||
|
onAutoSave={onAddSignersFormAutoSave}
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddFieldsFormPartial
|
<AddFieldsFormPartial
|
||||||
key={fields.length}
|
key={document.id}
|
||||||
documentFlow={documentFlow.fields}
|
documentFlow={documentFlow.fields}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddFieldsFormSubmit}
|
onSubmit={onAddFieldsFormSubmit}
|
||||||
|
onAutoSave={onAddFieldsFormAutoSave}
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
teamId={team.id}
|
teamId={team.id}
|
||||||
/>
|
/>
|
||||||
@ -381,6 +481,7 @@ export const DocumentEditForm = ({
|
|||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddSubjectFormSubmit}
|
onSubmit={onAddSubjectFormSubmit}
|
||||||
|
onAutoSave={onAddSubjectFormAutoSave}
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
/>
|
/>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
|
|||||||
@ -124,32 +124,36 @@ export const TemplateEditForm = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
|
const saveSettingsData = async (data: TAddTemplateSettingsFormSchema) => {
|
||||||
const { signatureTypes } = data.meta;
|
const { signatureTypes } = data.meta;
|
||||||
|
|
||||||
const parsedGlobalAccessAuth = z
|
const parsedGlobalAccessAuth = z
|
||||||
.array(ZDocumentAccessAuthTypesSchema)
|
.array(ZDocumentAccessAuthTypesSchema)
|
||||||
.safeParse(data.globalAccessAuth);
|
.safeParse(data.globalAccessAuth);
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
await updateTemplateSettings({
|
await saveSettingsData(data);
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
setStep('signers');
|
setStep('signers');
|
||||||
} catch (err) {
|
} 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 (
|
const onAddTemplatePlaceholderFormSubmit = async (
|
||||||
data: TAddTemplatePlacholderRecipientsFormSchema,
|
data: TAddTemplatePlacholderRecipientsFormSchema,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await saveTemplatePlaceholderData(data);
|
||||||
updateTemplateSettings({
|
|
||||||
templateId: template.id,
|
|
||||||
meta: {
|
|
||||||
signingOrder: data.signingOrder,
|
|
||||||
allowDictateNextSigner: data.allowDictateNextSigner,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
setRecipients({
|
|
||||||
templateId: template.id,
|
|
||||||
recipients: data.signers,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
setStep('fields');
|
setStep('fields');
|
||||||
} catch (err) {
|
} 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) => {
|
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await addTemplateFields({
|
await saveFieldsData(data);
|
||||||
templateId: template.id,
|
|
||||||
fields: data.fields,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear all field data from localStorage
|
// Clear all field data from localStorage
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
@ -270,11 +326,12 @@ export const TemplateEditForm = ({
|
|||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddSettingsFormSubmit}
|
onSubmit={onAddSettingsFormSubmit}
|
||||||
|
onAutoSave={onAddSettingsFormAutoSave}
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddTemplatePlaceholderRecipientsFormPartial
|
<AddTemplatePlaceholderRecipientsFormPartial
|
||||||
key={recipients.length}
|
key={template.id}
|
||||||
documentFlow={documentFlow.signers}
|
documentFlow={documentFlow.signers}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
@ -282,15 +339,17 @@ export const TemplateEditForm = ({
|
|||||||
allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner}
|
allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner}
|
||||||
templateDirectLink={template.directLink}
|
templateDirectLink={template.directLink}
|
||||||
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
||||||
|
onAutoSave={onAddTemplatePlaceholderFormAutoSave}
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddTemplateFieldsFormPartial
|
<AddTemplateFieldsFormPartial
|
||||||
key={fields.length}
|
key={template.id}
|
||||||
documentFlow={documentFlow.fields}
|
documentFlow={documentFlow.fields}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddFieldsFormSubmit}
|
onSubmit={onAddFieldsFormSubmit}
|
||||||
|
onAutoSave={onAddFieldsFormAutoSave}
|
||||||
teamId={team?.id}
|
teamId={team?.id}
|
||||||
/>
|
/>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
|
|||||||
@ -50,10 +50,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
throw redirect(documentRootPath);
|
throw redirect(documentRootPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.folderId) {
|
|
||||||
throw redirect(documentRootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const recipients = await getRecipientsForDocument({
|
const recipients = await getRecipientsForDocument({
|
||||||
documentId,
|
documentId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@ -68,13 +64,13 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
document,
|
document,
|
||||||
documentRootPath,
|
|
||||||
recipients,
|
recipients,
|
||||||
|
documentRootPath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps) {
|
export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps) {
|
||||||
const { document, documentRootPath, recipients } = loaderData;
|
const { document, recipients, documentRootPath } = loaderData;
|
||||||
|
|
||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
|
|||||||
@ -9,10 +9,10 @@ import { trpc } from '@documenso/trpc/react';
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
|
|
||||||
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||||
|
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
|
||||||
import { TemplatesTable } from '~/components/tables/templates-table';
|
import { TemplatesTable } from '~/components/tables/templates-table';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
|
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return appMetaTags('Templates');
|
return appMetaTags('Templates');
|
||||||
|
|||||||
@ -34,6 +34,7 @@ import { createTemplate } from '@documenso/lib/server-only/template/create-templ
|
|||||||
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
|
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
|
||||||
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
|
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
|
||||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||||
|
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||||
import { extractDerivedDocumentEmailSettings } from '@documenso/lib/types/document-email';
|
import { extractDerivedDocumentEmailSettings } from '@documenso/lib/types/document-email';
|
||||||
import {
|
import {
|
||||||
ZCheckboxFieldMeta,
|
ZCheckboxFieldMeta,
|
||||||
@ -980,10 +981,12 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
recipients: [
|
recipients: [
|
||||||
...recipients.map(({ email, name }) => ({
|
...recipients.map((recipient) => ({
|
||||||
email,
|
email: recipient.email,
|
||||||
name,
|
name: recipient.name,
|
||||||
role,
|
role: recipient.role,
|
||||||
|
signingOrder: recipient.signingOrder,
|
||||||
|
actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? [],
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
email,
|
email,
|
||||||
|
|||||||
@ -33,7 +33,7 @@ export const ZNoBodyMutationSchema = null;
|
|||||||
*/
|
*/
|
||||||
export const ZGetDocumentsQuerySchema = z.object({
|
export const ZGetDocumentsQuerySchema = z.object({
|
||||||
page: z.coerce.number().min(1).optional().default(1),
|
page: z.coerce.number().min(1).optional().default(1),
|
||||||
perPage: z.coerce.number().min(1).optional().default(1),
|
perPage: z.coerce.number().min(1).optional().default(10),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TGetDocumentsQuerySchema = z.infer<typeof ZGetDocumentsQuerySchema>;
|
export type TGetDocumentsQuerySchema = z.infer<typeof ZGetDocumentsQuerySchema>;
|
||||||
@ -637,5 +637,5 @@ export const ZSuccessfulGetTemplatesResponseSchema = z.object({
|
|||||||
|
|
||||||
export const ZGetTemplatesQuerySchema = z.object({
|
export const ZGetTemplatesQuerySchema = z.object({
|
||||||
page: z.coerce.number().min(1).optional().default(1),
|
page: z.coerce.number().min(1).optional().default(1),
|
||||||
perPage: z.coerce.number().min(1).optional().default(1),
|
perPage: z.coerce.number().min(1).optional().default(10),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -534,9 +534,6 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
|
|||||||
await page.getByLabel('Title').fill(documentTitle);
|
await page.getByLabel('Title').fill(documentTitle);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
|
||||||
await page.getByLabel('Enable signing order').check();
|
|
||||||
|
|
||||||
for (let i = 1; i <= 3; i++) {
|
for (let i = 1; i <= 3; i++) {
|
||||||
if (i > 1) {
|
if (i > 1) {
|
||||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
@ -558,6 +555,9 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
|
|||||||
.fill(`User ${i}`);
|
.fill(`User ${i}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||||
|
await page.getByLabel('Enable signing order').check();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -17,7 +17,7 @@ export default defineConfig({
|
|||||||
testDir: './e2e',
|
testDir: './e2e',
|
||||||
/* Run tests in files in parallel */
|
/* Run tests in files in parallel */
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 4,
|
||||||
maxFailures: process.env.CI ? 1 : undefined,
|
maxFailures: process.env.CI ? 1 : undefined,
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
|
|||||||
31
packages/lib/client-only/hooks/use-autosave.ts
Normal file
31
packages/lib/client-only/hooks/use-autosave.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export const useAutoSave = <T>(onSave: (data: T) => Promise<void>) => {
|
||||||
|
const saveTimeoutRef = useRef<NodeJS.Timeout>();
|
||||||
|
|
||||||
|
const saveFormData = async (data: T) => {
|
||||||
|
try {
|
||||||
|
await onSave(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auto-save failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleSave = useCallback((data: T) => {
|
||||||
|
if (saveTimeoutRef.current) {
|
||||||
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTimeoutRef.current = setTimeout(() => void saveFormData(data), 2000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveTimeoutRef.current) {
|
||||||
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { scheduleSave };
|
||||||
|
};
|
||||||
@ -149,33 +149,6 @@ export const sendDocument = async ({
|
|||||||
// throw new Error('Some signers have not been assigned a signature field.');
|
// throw new Error('Some signers have not been assigned a signature field.');
|
||||||
// }
|
// }
|
||||||
|
|
||||||
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
|
|
||||||
document.documentMeta,
|
|
||||||
).recipientSigningRequest;
|
|
||||||
|
|
||||||
// Only send email if one of the following is true:
|
|
||||||
// - It is explicitly set
|
|
||||||
// - The email is enabled for signing requests AND sendEmail is undefined
|
|
||||||
if (sendEmail || (isRecipientSigningRequestEmailEnabled && sendEmail === undefined)) {
|
|
||||||
await Promise.all(
|
|
||||||
recipientsToNotify.map(async (recipient) => {
|
|
||||||
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await jobs.triggerJob({
|
|
||||||
name: 'send.signing.requested.email',
|
|
||||||
payload: {
|
|
||||||
userId,
|
|
||||||
documentId,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
requestMetadata: requestMetadata?.requestMetadata,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const allRecipientsHaveNoActionToTake = document.recipients.every(
|
const allRecipientsHaveNoActionToTake = document.recipients.every(
|
||||||
(recipient) =>
|
(recipient) =>
|
||||||
recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED,
|
recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED,
|
||||||
@ -246,6 +219,33 @@ export const sendDocument = async ({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||||
|
document.documentMeta,
|
||||||
|
).recipientSigningRequest;
|
||||||
|
|
||||||
|
// Only send email if one of the following is true:
|
||||||
|
// - It is explicitly set
|
||||||
|
// - The email is enabled for signing requests AND sendEmail is undefined
|
||||||
|
if (sendEmail || (isRecipientSigningRequestEmailEnabled && sendEmail === undefined)) {
|
||||||
|
await Promise.all(
|
||||||
|
recipientsToNotify.map(async (recipient) => {
|
||||||
|
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await jobs.triggerJob({
|
||||||
|
name: 'send.signing.requested.email',
|
||||||
|
payload: {
|
||||||
|
userId,
|
||||||
|
documentId,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
requestMetadata: requestMetadata?.requestMetadata,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await triggerWebhook({
|
await triggerWebhook({
|
||||||
event: WebhookTriggerEvents.DOCUMENT_SENT,
|
event: WebhookTriggerEvents.DOCUMENT_SENT,
|
||||||
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
|
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||||
|
|
||||||
export interface GetRecipientsForTemplateOptions {
|
export interface GetRecipientsForTemplateOptions {
|
||||||
templateId: number;
|
templateId: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
@ -14,21 +16,12 @@ export const getRecipientsForTemplate = async ({
|
|||||||
const recipients = await prisma.recipient.findMany({
|
const recipients = await prisma.recipient.findMany({
|
||||||
where: {
|
where: {
|
||||||
templateId,
|
templateId,
|
||||||
template: teamId
|
template: {
|
||||||
? {
|
team: buildTeamWhereQuery({
|
||||||
team: {
|
teamId,
|
||||||
id: teamId,
|
userId,
|
||||||
members: {
|
}),
|
||||||
some: {
|
},
|
||||||
userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
userId,
|
|
||||||
teamId: null,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
id: 'asc',
|
id: 'asc',
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
|||||||
import { prop, sortBy } from 'remeda';
|
import { prop, sortBy } from 'remeda';
|
||||||
|
|
||||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||||
|
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
|
||||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import {
|
import {
|
||||||
@ -83,6 +84,7 @@ export type AddFieldsFormProps = {
|
|||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
onSubmit: (_data: TAddFieldsFormSchema) => void;
|
onSubmit: (_data: TAddFieldsFormSchema) => void;
|
||||||
|
onAutoSave: (_data: TAddFieldsFormSchema) => Promise<void>;
|
||||||
canGoBack?: boolean;
|
canGoBack?: boolean;
|
||||||
isDocumentPdfLoaded: boolean;
|
isDocumentPdfLoaded: boolean;
|
||||||
teamId: number;
|
teamId: number;
|
||||||
@ -94,6 +96,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
recipients,
|
recipients,
|
||||||
fields,
|
fields,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
onAutoSave,
|
||||||
canGoBack = false,
|
canGoBack = false,
|
||||||
isDocumentPdfLoaded,
|
isDocumentPdfLoaded,
|
||||||
teamId,
|
teamId,
|
||||||
@ -590,6 +593,20 @@ export const AddFieldsFormPartial = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { scheduleSave } = useAutoSave(onAutoSave);
|
||||||
|
|
||||||
|
const handleAutoSave = async () => {
|
||||||
|
const isFormValid = await form.trigger();
|
||||||
|
|
||||||
|
if (!isFormValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = form.getValues();
|
||||||
|
|
||||||
|
scheduleSave(formData);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showAdvancedSettings && currentField ? (
|
{showAdvancedSettings && currentField ? (
|
||||||
@ -603,7 +620,14 @@ export const AddFieldsFormPartial = ({
|
|||||||
fields={localFields}
|
fields={localFields}
|
||||||
onAdvancedSettings={handleAdvancedSettings}
|
onAdvancedSettings={handleAdvancedSettings}
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
onSave={handleSavedFieldSettings}
|
onSave={(fieldState) => {
|
||||||
|
handleSavedFieldSettings(fieldState);
|
||||||
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
|
onAutoSave={async (fieldState) => {
|
||||||
|
handleSavedFieldSettings(fieldState);
|
||||||
|
await handleAutoSave();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@ -660,14 +684,26 @@ export const AddFieldsFormPartial = ({
|
|||||||
defaultWidth={DEFAULT_WIDTH_PX}
|
defaultWidth={DEFAULT_WIDTH_PX}
|
||||||
passive={isFieldWithinBounds && !!selectedField}
|
passive={isFieldWithinBounds && !!selectedField}
|
||||||
onFocus={() => setLastActiveField(field)}
|
onFocus={() => setLastActiveField(field)}
|
||||||
onBlur={() => setLastActiveField(null)}
|
onBlur={() => {
|
||||||
|
setLastActiveField(null);
|
||||||
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
onMouseEnter={() => setLastActiveField(field)}
|
onMouseEnter={() => setLastActiveField(field)}
|
||||||
onMouseLeave={() => setLastActiveField(null)}
|
onMouseLeave={() => setLastActiveField(null)}
|
||||||
onResize={(options) => onFieldResize(options, index)}
|
onResize={(options) => onFieldResize(options, index)}
|
||||||
onMove={(options) => onFieldMove(options, index)}
|
onMove={(options) => onFieldMove(options, index)}
|
||||||
onRemove={() => remove(index)}
|
onRemove={() => {
|
||||||
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
remove(index);
|
||||||
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
|
onDuplicate={() => {
|
||||||
|
onFieldCopy(null, { duplicate: true });
|
||||||
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
|
onDuplicateAllPages={() => {
|
||||||
|
onFieldCopy(null, { duplicateAll: true });
|
||||||
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
onAdvancedSettings={() => {
|
onAdvancedSettings={() => {
|
||||||
setCurrentField(field);
|
setCurrentField(field);
|
||||||
handleAdvancedSettings();
|
handleAdvancedSettings();
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { InfoIcon } from 'lucide-react';
|
|||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document';
|
import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document';
|
||||||
@ -92,6 +93,7 @@ export type AddSettingsFormProps = {
|
|||||||
document: TDocument;
|
document: TDocument;
|
||||||
currentTeamMemberRole?: TeamMemberRole;
|
currentTeamMemberRole?: TeamMemberRole;
|
||||||
onSubmit: (_data: TAddSettingsFormSchema) => void;
|
onSubmit: (_data: TAddSettingsFormSchema) => void;
|
||||||
|
onAutoSave: (_data: TAddSettingsFormSchema) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddSettingsFormPartial = ({
|
export const AddSettingsFormPartial = ({
|
||||||
@ -102,6 +104,7 @@ export const AddSettingsFormPartial = ({
|
|||||||
document,
|
document,
|
||||||
currentTeamMemberRole,
|
currentTeamMemberRole,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
onAutoSave,
|
||||||
}: AddSettingsFormProps) => {
|
}: AddSettingsFormProps) => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
@ -182,6 +185,28 @@ export const AddSettingsFormPartial = ({
|
|||||||
document.documentMeta?.timezone,
|
document.documentMeta?.timezone,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const { scheduleSave } = useAutoSave(onAutoSave);
|
||||||
|
|
||||||
|
const handleAutoSave = async () => {
|
||||||
|
const isFormValid = await form.trigger();
|
||||||
|
|
||||||
|
if (!isFormValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = form.getValues();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Parse the form data through the Zod schema to handle transformations
|
||||||
|
* (like -1 -> undefined for the Document Global Auth Access)
|
||||||
|
*/
|
||||||
|
const parseResult = ZAddSettingsFormSchema.safeParse(formData);
|
||||||
|
|
||||||
|
if (parseResult.success) {
|
||||||
|
scheduleSave(parseResult.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DocumentFlowFormContainerHeader
|
<DocumentFlowFormContainerHeader
|
||||||
@ -217,6 +242,7 @@ export const AddSettingsFormPartial = ({
|
|||||||
className="bg-background"
|
className="bg-background"
|
||||||
{...field}
|
{...field}
|
||||||
disabled={document.status !== DocumentStatus.DRAFT || field.disabled}
|
disabled={document.status !== DocumentStatus.DRAFT || field.disabled}
|
||||||
|
onBlur={handleAutoSave}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -248,9 +274,13 @@ export const AddSettingsFormPartial = ({
|
|||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Select
|
||||||
|
{...field}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
disabled={field.disabled}
|
disabled={field.disabled}
|
||||||
onValueChange={field.onChange}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-background">
|
<SelectTrigger className="bg-background">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@ -282,9 +312,13 @@ export const AddSettingsFormPartial = ({
|
|||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<DocumentGlobalAuthAccessSelect
|
<DocumentGlobalAuthAccessSelect
|
||||||
|
{...field}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
disabled={field.disabled}
|
disabled={field.disabled}
|
||||||
onValueChange={field.onChange}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@ -307,7 +341,10 @@ export const AddSettingsFormPartial = ({
|
|||||||
canUpdateVisibility={canUpdateVisibility}
|
canUpdateVisibility={canUpdateVisibility}
|
||||||
currentTeamMemberRole={currentTeamMemberRole}
|
currentTeamMemberRole={currentTeamMemberRole}
|
||||||
{...field}
|
{...field}
|
||||||
onValueChange={field.onChange}
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@ -328,9 +365,13 @@ export const AddSettingsFormPartial = ({
|
|||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<DocumentGlobalAuthActionSelect
|
<DocumentGlobalAuthActionSelect
|
||||||
|
{...field}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
disabled={field.disabled}
|
disabled={field.disabled}
|
||||||
onValueChange={field.onChange}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@ -368,7 +409,7 @@ export const AddSettingsFormPartial = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input className="bg-background" {...field} />
|
<Input className="bg-background" {...field} onBlur={handleAutoSave} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -393,7 +434,10 @@ export const AddSettingsFormPartial = ({
|
|||||||
value: option.value,
|
value: option.value,
|
||||||
}))}
|
}))}
|
||||||
selectedValues={field.value}
|
selectedValues={field.value}
|
||||||
onChange={field.onChange}
|
onChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
className="bg-background w-full"
|
className="bg-background w-full"
|
||||||
emptySelectionPlaceholder="Select signature types"
|
emptySelectionPlaceholder="Select signature types"
|
||||||
/>
|
/>
|
||||||
@ -415,8 +459,12 @@ export const AddSettingsFormPartial = ({
|
|||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Select
|
||||||
|
{...field}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onValueChange={field.onChange}
|
|
||||||
disabled={documentHasBeenSent}
|
disabled={documentHasBeenSent}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-background">
|
<SelectTrigger className="bg-background">
|
||||||
@ -451,8 +499,12 @@ export const AddSettingsFormPartial = ({
|
|||||||
<Combobox
|
<Combobox
|
||||||
className="bg-background"
|
className="bg-background"
|
||||||
options={TIME_ZONES}
|
options={TIME_ZONES}
|
||||||
|
{...field}
|
||||||
|
onChange={(value) => {
|
||||||
|
value && field.onChange(value);
|
||||||
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={(value) => value && field.onChange(value)}
|
|
||||||
disabled={documentHasBeenSent}
|
disabled={documentHasBeenSent}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -483,7 +535,7 @@ export const AddSettingsFormPartial = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input className="bg-background" {...field} />
|
<Input className="bg-background" {...field} onBlur={handleAutoSave} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { useFieldArray, useForm } from 'react-hook-form';
|
|||||||
import { prop, sortBy } from 'remeda';
|
import { prop, sortBy } from 'remeda';
|
||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
|
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||||
@ -55,6 +56,7 @@ export type AddSignersFormProps = {
|
|||||||
signingOrder?: DocumentSigningOrder | null;
|
signingOrder?: DocumentSigningOrder | null;
|
||||||
allowDictateNextSigner?: boolean;
|
allowDictateNextSigner?: boolean;
|
||||||
onSubmit: (_data: TAddSignersFormSchema) => void;
|
onSubmit: (_data: TAddSignersFormSchema) => void;
|
||||||
|
onAutoSave: (_data: TAddSignersFormSchema) => Promise<void>;
|
||||||
isDocumentPdfLoaded: boolean;
|
isDocumentPdfLoaded: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -65,6 +67,7 @@ export const AddSignersFormPartial = ({
|
|||||||
signingOrder,
|
signingOrder,
|
||||||
allowDictateNextSigner,
|
allowDictateNextSigner,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
onAutoSave,
|
||||||
isDocumentPdfLoaded,
|
isDocumentPdfLoaded,
|
||||||
}: AddSignersFormProps) => {
|
}: AddSignersFormProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
@ -166,6 +169,29 @@ export const AddSignersFormPartial = ({
|
|||||||
name: 'signers',
|
name: 'signers',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emptySigners = useCallback(
|
||||||
|
() => form.getValues('signers').filter((signer) => signer.email === ''),
|
||||||
|
[form],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { scheduleSave } = useAutoSave(onAutoSave);
|
||||||
|
|
||||||
|
const handleAutoSave = async () => {
|
||||||
|
if (emptySigners().length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFormValid = await form.trigger();
|
||||||
|
|
||||||
|
if (!isFormValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = form.getValues();
|
||||||
|
|
||||||
|
scheduleSave(formData);
|
||||||
|
};
|
||||||
|
|
||||||
const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email);
|
const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email);
|
||||||
const isUserAlreadyARecipient = watchedSigners.some(
|
const isUserAlreadyARecipient = watchedSigners.some(
|
||||||
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
|
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
|
||||||
@ -216,24 +242,47 @@ export const AddSignersFormPartial = ({
|
|||||||
const formStateIndex = form.getValues('signers').findIndex((s) => s.formId === signer.formId);
|
const formStateIndex = form.getValues('signers').findIndex((s) => s.formId === signer.formId);
|
||||||
if (formStateIndex !== -1) {
|
if (formStateIndex !== -1) {
|
||||||
removeSigner(formStateIndex);
|
removeSigner(formStateIndex);
|
||||||
|
|
||||||
const updatedSigners = form.getValues('signers').filter((s) => s.formId !== signer.formId);
|
const updatedSigners = form.getValues('signers').filter((s) => s.formId !== signer.formId);
|
||||||
form.setValue('signers', normalizeSigningOrders(updatedSigners));
|
|
||||||
|
form.setValue('signers', normalizeSigningOrders(updatedSigners), {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
void handleAutoSave();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAddSelfSigner = () => {
|
const onAddSelfSigner = () => {
|
||||||
if (emptySignerIndex !== -1) {
|
if (emptySignerIndex !== -1) {
|
||||||
setValue(`signers.${emptySignerIndex}.name`, user?.name ?? '');
|
setValue(`signers.${emptySignerIndex}.name`, user?.name ?? '', {
|
||||||
setValue(`signers.${emptySignerIndex}.email`, user?.email ?? '');
|
shouldValidate: true,
|
||||||
} else {
|
shouldDirty: true,
|
||||||
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}.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,
|
signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : index + 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
form.setValue('signers', updatedSigners);
|
form.setValue('signers', updatedSigners, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
|
||||||
const lastSigner = updatedSigners[updatedSigners.length - 1];
|
const lastSigner = updatedSigners[updatedSigners.length - 1];
|
||||||
if (lastSigner.role === RecipientRole.ASSISTANT) {
|
if (lastSigner.role === RecipientRole.ASSISTANT) {
|
||||||
@ -276,8 +328,10 @@ export const AddSignersFormPartial = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await form.trigger('signers');
|
await form.trigger('signers');
|
||||||
|
|
||||||
|
void handleAutoSave();
|
||||||
},
|
},
|
||||||
[form, canRecipientBeModified, watchedSigners, toast],
|
[form, canRecipientBeModified, watchedSigners, handleAutoSave, toast],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRoleChange = useCallback(
|
const handleRoleChange = useCallback(
|
||||||
@ -287,7 +341,10 @@ export const AddSignersFormPartial = ({
|
|||||||
|
|
||||||
// Handle parallel to sequential conversion for assistants
|
// Handle parallel to sequential conversion for assistants
|
||||||
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
|
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
|
||||||
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL);
|
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Signing order is enabled.`),
|
title: _(msg`Signing order is enabled.`),
|
||||||
description: _(msg`You cannot add assistants when signing order is disabled.`),
|
description: _(msg`You cannot add assistants when signing order is disabled.`),
|
||||||
@ -302,7 +359,10 @@ export const AddSignersFormPartial = ({
|
|||||||
signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : idx + 1,
|
signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : idx + 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
form.setValue('signers', updatedSigners);
|
form.setValue('signers', updatedSigners, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
|
||||||
if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
|
if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
|
||||||
toast({
|
toast({
|
||||||
@ -341,7 +401,10 @@ export const AddSignersFormPartial = ({
|
|||||||
signingOrder: !canRecipientBeModified(s.nativeId) ? s.signingOrder : idx + 1,
|
signingOrder: !canRecipientBeModified(s.nativeId) ? s.signingOrder : idx + 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
form.setValue('signers', updatedSigners);
|
form.setValue('signers', updatedSigners, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
|
||||||
if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
|
if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
|
||||||
toast({
|
toast({
|
||||||
@ -364,9 +427,20 @@ export const AddSignersFormPartial = ({
|
|||||||
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
|
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
form.setValue('signers', updatedSigners);
|
form.setValue('signers', updatedSigners, {
|
||||||
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL);
|
shouldValidate: true,
|
||||||
form.setValue('allowDictateNextSigner', false);
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
form.setValue('allowDictateNextSigner', false, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
void handleAutoSave();
|
||||||
}, [form]);
|
}, [form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -408,19 +482,39 @@ export const AddSignersFormPartial = ({
|
|||||||
|
|
||||||
// If sequential signing is turned off, disable dictate next signer
|
// If sequential signing is turned off, disable dictate next signer
|
||||||
if (!checked) {
|
if (!checked) {
|
||||||
form.setValue('allowDictateNextSigner', false);
|
form.setValue('allowDictateNextSigner', false, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void handleAutoSave();
|
||||||
}}
|
}}
|
||||||
disabled={isSubmitting || hasDocumentBeenSent}
|
disabled={isSubmitting || hasDocumentBeenSent || emptySigners().length !== 0}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormLabel
|
<div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||||
htmlFor="signingOrder"
|
<FormLabel
|
||||||
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
htmlFor="signingOrder"
|
||||||
>
|
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
<Trans>Enable signing order</Trans>
|
>
|
||||||
</FormLabel>
|
<Trans>Enable signing order</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="text-muted-foreground ml-1 cursor-help">
|
||||||
|
<HelpCircle className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-80 p-4">
|
||||||
|
<p>
|
||||||
|
<Trans>Add 2 or more signers to enable signing order.</Trans>
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -435,12 +529,15 @@ export const AddSignersFormPartial = ({
|
|||||||
{...field}
|
{...field}
|
||||||
id="allowDictateNextSigner"
|
id="allowDictateNextSigner"
|
||||||
checked={value}
|
checked={value}
|
||||||
onCheckedChange={field.onChange}
|
onCheckedChange={(checked) => {
|
||||||
|
field.onChange(checked);
|
||||||
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
disabled={isSubmitting || hasDocumentBeenSent || !isSigningOrderSequential}
|
disabled={isSubmitting || hasDocumentBeenSent || !isSigningOrderSequential}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<div className="flex items-center">
|
<div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||||
<FormLabel
|
<FormLabel
|
||||||
htmlFor="allowDictateNextSigner"
|
htmlFor="allowDictateNextSigner"
|
||||||
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
@ -533,6 +630,7 @@ export const AddSignersFormPartial = ({
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
max={signers.length}
|
max={signers.length}
|
||||||
|
data-testid="signing-order-input"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-center',
|
'w-full text-center',
|
||||||
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
|
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
|
||||||
@ -541,10 +639,12 @@ export const AddSignersFormPartial = ({
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
field.onChange(e);
|
field.onChange(e);
|
||||||
handleSigningOrderChange(index, e.target.value);
|
handleSigningOrderChange(index, e.target.value);
|
||||||
|
void handleAutoSave();
|
||||||
}}
|
}}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
field.onBlur();
|
field.onBlur();
|
||||||
handleSigningOrderChange(index, e.target.value);
|
handleSigningOrderChange(index, e.target.value);
|
||||||
|
void handleAutoSave();
|
||||||
}}
|
}}
|
||||||
disabled={
|
disabled={
|
||||||
snapshot.isDragging ||
|
snapshot.isDragging ||
|
||||||
@ -588,7 +688,9 @@ export const AddSignersFormPartial = ({
|
|||||||
isSubmitting ||
|
isSubmitting ||
|
||||||
!canRecipientBeModified(signer.nativeId)
|
!canRecipientBeModified(signer.nativeId)
|
||||||
}
|
}
|
||||||
|
data-testid="signer-email-input"
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
|
onBlur={handleAutoSave}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
@ -626,6 +728,7 @@ export const AddSignersFormPartial = ({
|
|||||||
!canRecipientBeModified(signer.nativeId)
|
!canRecipientBeModified(signer.nativeId)
|
||||||
}
|
}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
|
onBlur={handleAutoSave}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
@ -668,6 +771,7 @@ export const AddSignersFormPartial = ({
|
|||||||
|
|
||||||
<div className="col-span-2 flex gap-x-2">
|
<div className="col-span-2 flex gap-x-2">
|
||||||
<FormField
|
<FormField
|
||||||
|
control={form.control}
|
||||||
name={`signers.${index}.role`}
|
name={`signers.${index}.role`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem
|
<FormItem
|
||||||
@ -681,10 +785,11 @@ export const AddSignersFormPartial = ({
|
|||||||
<RecipientRoleSelect
|
<RecipientRoleSelect
|
||||||
{...field}
|
{...field}
|
||||||
isAssistantEnabled={isSigningOrderSequential}
|
isAssistantEnabled={isSigningOrderSequential}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
handleRoleChange(index, value as RecipientRole)
|
handleRoleChange(index, value as RecipientRole);
|
||||||
}
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
disabled={
|
disabled={
|
||||||
snapshot.isDragging ||
|
snapshot.isDragging ||
|
||||||
isSubmitting ||
|
isSubmitting ||
|
||||||
@ -706,6 +811,7 @@ export const AddSignersFormPartial = ({
|
|||||||
'mb-6': form.formState.errors.signers?.[index],
|
'mb-6': form.formState.errors.signers?.[index],
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
|
data-testid="remove-signer-button"
|
||||||
disabled={
|
disabled={
|
||||||
snapshot.isDragging ||
|
snapshot.isDragging ||
|
||||||
isSubmitting ||
|
isSubmitting ||
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
@ -8,6 +10,7 @@ import { AnimatePresence, motion } from 'framer-motion';
|
|||||||
import { InfoIcon } from 'lucide-react';
|
import { InfoIcon } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
import type { TDocument } from '@documenso/lib/types/document';
|
import type { TDocument } from '@documenso/lib/types/document';
|
||||||
@ -60,6 +63,7 @@ export type AddSubjectFormProps = {
|
|||||||
fields: Field[];
|
fields: Field[];
|
||||||
document: TDocument;
|
document: TDocument;
|
||||||
onSubmit: (_data: TAddSubjectFormSchema) => void;
|
onSubmit: (_data: TAddSubjectFormSchema) => void;
|
||||||
|
onAutoSave: (_data: TAddSubjectFormSchema) => Promise<void>;
|
||||||
isDocumentPdfLoaded: boolean;
|
isDocumentPdfLoaded: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -69,6 +73,7 @@ export const AddSubjectFormPartial = ({
|
|||||||
fields: fields,
|
fields: fields,
|
||||||
document,
|
document,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
onAutoSave,
|
||||||
isDocumentPdfLoaded,
|
isDocumentPdfLoaded,
|
||||||
}: AddSubjectFormProps) => {
|
}: AddSubjectFormProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
@ -95,6 +100,8 @@ export const AddSubjectFormPartial = ({
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
setValue,
|
setValue,
|
||||||
watch,
|
watch,
|
||||||
|
trigger,
|
||||||
|
getValues,
|
||||||
formState: { isSubmitting },
|
formState: { isSubmitting },
|
||||||
} = form;
|
} = form;
|
||||||
|
|
||||||
@ -129,6 +136,35 @@ export const AddSubjectFormPartial = ({
|
|||||||
const onFormSubmit = handleSubmit(onSubmit);
|
const onFormSubmit = handleSubmit(onSubmit);
|
||||||
const { currentStep, totalSteps, previousStep } = useStep();
|
const { currentStep, totalSteps, previousStep } = useStep();
|
||||||
|
|
||||||
|
const { scheduleSave } = useAutoSave(onAutoSave);
|
||||||
|
|
||||||
|
const handleAutoSave = async () => {
|
||||||
|
const isFormValid = await trigger();
|
||||||
|
|
||||||
|
if (!isFormValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = getValues();
|
||||||
|
|
||||||
|
scheduleSave(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = window.document.getElementById('document-flow-form-container');
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
void handleAutoSave();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (container) {
|
||||||
|
container.addEventListener('blur', handleBlur, true);
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener('blur', handleBlur, true);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DocumentFlowFormContainerHeader
|
<DocumentFlowFormContainerHeader
|
||||||
@ -185,7 +221,6 @@ export const AddSubjectFormPartial = ({
|
|||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Email Sender</Trans>
|
<Trans>Email Sender</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Select
|
||||||
{...field}
|
{...field}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { useLingui } from '@lingui/react';
|
|||||||
import { FieldType } from '@prisma/client';
|
import { FieldType } from '@prisma/client';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
|
||||||
import {
|
import {
|
||||||
type TBaseFieldMeta as BaseFieldMeta,
|
type TBaseFieldMeta as BaseFieldMeta,
|
||||||
type TCheckboxFieldMeta as CheckboxFieldMeta,
|
type TCheckboxFieldMeta as CheckboxFieldMeta,
|
||||||
@ -48,6 +49,7 @@ export type FieldAdvancedSettingsProps = {
|
|||||||
onAdvancedSettings?: () => void;
|
onAdvancedSettings?: () => void;
|
||||||
isDocumentPdfLoaded?: boolean;
|
isDocumentPdfLoaded?: boolean;
|
||||||
onSave?: (fieldState: FieldMeta) => void;
|
onSave?: (fieldState: FieldMeta) => void;
|
||||||
|
onAutoSave?: (fieldState: FieldMeta) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FieldMetaKeys =
|
export type FieldMetaKeys =
|
||||||
@ -146,7 +148,16 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => {
|
|||||||
|
|
||||||
export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSettingsProps>(
|
export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSettingsProps>(
|
||||||
(
|
(
|
||||||
{ title, description, field, fields, onAdvancedSettings, isDocumentPdfLoaded = true, onSave },
|
{
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
field,
|
||||||
|
fields,
|
||||||
|
onAdvancedSettings,
|
||||||
|
isDocumentPdfLoaded = true,
|
||||||
|
onSave,
|
||||||
|
onAutoSave,
|
||||||
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
@ -177,6 +188,24 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [fieldMeta]);
|
}, [fieldMeta]);
|
||||||
|
|
||||||
|
const { scheduleSave } = useAutoSave(onAutoSave || (async () => {}));
|
||||||
|
|
||||||
|
const handleAutoSave = () => {
|
||||||
|
if (errors.length === 0) {
|
||||||
|
scheduleSave(fieldState);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-save to localStorage and schedule remote save when fieldState changes
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(localStorageKey, JSON.stringify(fieldState));
|
||||||
|
handleAutoSave();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save to localStorage:', error);
|
||||||
|
}
|
||||||
|
}, [fieldState, localStorageKey, handleAutoSave]);
|
||||||
|
|
||||||
const handleFieldChange = (
|
const handleFieldChange = (
|
||||||
key: FieldMetaKeys,
|
key: FieldMetaKeys,
|
||||||
value:
|
value:
|
||||||
@ -325,7 +354,10 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
|
|||||||
)}
|
)}
|
||||||
</DocumentFlowFormContainerContent>
|
</DocumentFlowFormContainerContent>
|
||||||
|
|
||||||
<DocumentFlowFormContainerFooter className="mt-auto">
|
<DocumentFlowFormContainerFooter
|
||||||
|
className="mt-auto"
|
||||||
|
data-testid="field-advanced-settings-footer"
|
||||||
|
>
|
||||||
<DocumentFlowFormContainerActions
|
<DocumentFlowFormContainerActions
|
||||||
goNextLabel={msg`Save`}
|
goNextLabel={msg`Save`}
|
||||||
goBackLabel={msg`Cancel`}
|
goBackLabel={msg`Cancel`}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import { useFieldArray, useForm } from 'react-hook-form';
|
|||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
|
||||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||||
|
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
|
||||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
@ -73,6 +74,7 @@ export type AddTemplateFieldsFormProps = {
|
|||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
onSubmit: (_data: TAddTemplateFieldsFormSchema) => void;
|
onSubmit: (_data: TAddTemplateFieldsFormSchema) => void;
|
||||||
|
onAutoSave: (_data: TAddTemplateFieldsFormSchema) => Promise<void>;
|
||||||
teamId: number;
|
teamId: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -81,6 +83,7 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
recipients,
|
recipients,
|
||||||
fields,
|
fields,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
onAutoSave,
|
||||||
teamId,
|
teamId,
|
||||||
}: AddTemplateFieldsFormProps) => {
|
}: AddTemplateFieldsFormProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
@ -121,6 +124,20 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
|
|
||||||
const onFormSubmit = form.handleSubmit(onSubmit);
|
const onFormSubmit = form.handleSubmit(onSubmit);
|
||||||
|
|
||||||
|
const { scheduleSave } = useAutoSave(onAutoSave);
|
||||||
|
|
||||||
|
const handleAutoSave = async () => {
|
||||||
|
const isFormValid = await form.trigger();
|
||||||
|
|
||||||
|
if (!isFormValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = form.getValues();
|
||||||
|
|
||||||
|
scheduleSave(formData);
|
||||||
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
append,
|
append,
|
||||||
remove,
|
remove,
|
||||||
@ -160,6 +177,7 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
append(newField);
|
append(newField);
|
||||||
|
void handleAutoSave();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -187,6 +205,7 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
append(newField);
|
append(newField);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void handleAutoSave();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,7 +217,15 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[append, lastActiveField, selectedSigner?.email, selectedSigner?.id, toast],
|
[
|
||||||
|
append,
|
||||||
|
lastActiveField,
|
||||||
|
selectedSigner?.email,
|
||||||
|
selectedSigner?.id,
|
||||||
|
selectedSigner?.token,
|
||||||
|
toast,
|
||||||
|
handleAutoSave,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onFieldPaste = useCallback(
|
const onFieldPaste = useCallback(
|
||||||
@ -218,9 +245,18 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
pageX: copiedField.pageX + 3,
|
pageX: copiedField.pageX + 3,
|
||||||
pageY: copiedField.pageY + 3,
|
pageY: copiedField.pageY + 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void handleAutoSave();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[append, fieldClipboard, selectedSigner?.email, selectedSigner?.id, selectedSigner?.token],
|
[
|
||||||
|
append,
|
||||||
|
fieldClipboard,
|
||||||
|
selectedSigner?.email,
|
||||||
|
selectedSigner?.id,
|
||||||
|
selectedSigner?.token,
|
||||||
|
handleAutoSave,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt));
|
useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt));
|
||||||
@ -378,8 +414,10 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
pageWidth,
|
pageWidth,
|
||||||
pageHeight,
|
pageHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void handleAutoSave();
|
||||||
},
|
},
|
||||||
[getFieldPosition, localFields, update],
|
[getFieldPosition, localFields, update, handleAutoSave],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onFieldMove = useCallback(
|
const onFieldMove = useCallback(
|
||||||
@ -401,8 +439,10 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
pageX,
|
pageX,
|
||||||
pageY,
|
pageY,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void handleAutoSave();
|
||||||
},
|
},
|
||||||
[getFieldPosition, localFields, update],
|
[getFieldPosition, localFields, update, handleAutoSave],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -504,6 +544,7 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
form.setValue('fields', updatedFields);
|
form.setValue('fields', updatedFields);
|
||||||
|
void handleAutoSave();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -519,6 +560,10 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
fields={localFields}
|
fields={localFields}
|
||||||
onAdvancedSettings={handleAdvancedSettings}
|
onAdvancedSettings={handleAdvancedSettings}
|
||||||
onSave={handleSavedFieldSettings}
|
onSave={handleSavedFieldSettings}
|
||||||
|
onAutoSave={async (fieldState) => {
|
||||||
|
handleSavedFieldSettings(fieldState);
|
||||||
|
await handleAutoSave();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@ -566,12 +611,22 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
defaultWidth={DEFAULT_WIDTH_PX}
|
defaultWidth={DEFAULT_WIDTH_PX}
|
||||||
passive={isFieldWithinBounds && !!selectedField}
|
passive={isFieldWithinBounds && !!selectedField}
|
||||||
onFocus={() => setLastActiveField(field)}
|
onFocus={() => setLastActiveField(field)}
|
||||||
onBlur={() => setLastActiveField(null)}
|
onBlur={() => {
|
||||||
|
setLastActiveField(null);
|
||||||
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
onResize={(options) => onFieldResize(options, index)}
|
onResize={(options) => onFieldResize(options, index)}
|
||||||
onMove={(options) => onFieldMove(options, index)}
|
onMove={(options) => onFieldMove(options, index)}
|
||||||
onRemove={() => remove(index)}
|
onRemove={() => {
|
||||||
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
remove(index);
|
||||||
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
|
onDuplicate={() => {
|
||||||
|
onFieldCopy(null, { duplicate: true });
|
||||||
|
}}
|
||||||
|
onDuplicateAllPages={() => {
|
||||||
|
onFieldCopy(null, { duplicateAll: true });
|
||||||
|
}}
|
||||||
onAdvancedSettings={() => {
|
onAdvancedSettings={() => {
|
||||||
setCurrentField(field);
|
setCurrentField(field);
|
||||||
handleAdvancedSettings();
|
handleAdvancedSettings();
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
import { useCallback, useId, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import type { DropResult, SensorAPI } from '@hello-pangea/dnd';
|
import type { DropResult, SensorAPI } from '@hello-pangea/dnd';
|
||||||
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
|
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
|
||||||
@ -12,6 +12,7 @@ import { motion } from 'framer-motion';
|
|||||||
import { GripVerticalIcon, HelpCircle, Link2Icon, Plus, Trash } from 'lucide-react';
|
import { GripVerticalIcon, HelpCircle, Link2Icon, Plus, Trash } from 'lucide-react';
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
|
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
|
||||||
@ -55,6 +56,7 @@ export type AddTemplatePlaceholderRecipientsFormProps = {
|
|||||||
allowDictateNextSigner?: boolean;
|
allowDictateNextSigner?: boolean;
|
||||||
templateDirectLink?: TemplateDirectLink | null;
|
templateDirectLink?: TemplateDirectLink | null;
|
||||||
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
|
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
|
||||||
|
onAutoSave: (_data: TAddTemplatePlacholderRecipientsFormSchema) => Promise<void>;
|
||||||
isDocumentPdfLoaded: boolean;
|
isDocumentPdfLoaded: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -67,6 +69,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
allowDictateNextSigner,
|
allowDictateNextSigner,
|
||||||
isDocumentPdfLoaded,
|
isDocumentPdfLoaded,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
onAutoSave,
|
||||||
}: AddTemplatePlaceholderRecipientsFormProps) => {
|
}: AddTemplatePlaceholderRecipientsFormProps) => {
|
||||||
const initialId = useId();
|
const initialId = useId();
|
||||||
const $sensorApi = useRef<SensorAPI | null>(null);
|
const $sensorApi = useRef<SensorAPI | null>(null);
|
||||||
@ -123,15 +126,38 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const emptySigners = useCallback(
|
||||||
form.reset({
|
() => form.getValues('signers').filter((signer) => signer.email === ''),
|
||||||
signers: generateDefaultFormSigners(),
|
[form],
|
||||||
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
|
);
|
||||||
allowDictateNextSigner: allowDictateNextSigner ?? false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
const { scheduleSave } = useAutoSave(onAutoSave);
|
||||||
}, [recipients]);
|
|
||||||
|
const handleAutoSave = async () => {
|
||||||
|
if (emptySigners().length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFormValid = await form.trigger();
|
||||||
|
|
||||||
|
if (!isFormValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = form.getValues();
|
||||||
|
|
||||||
|
scheduleSave(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// form.reset({
|
||||||
|
// signers: generateDefaultFormSigners(),
|
||||||
|
// signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
|
||||||
|
// allowDictateNextSigner: allowDictateNextSigner ?? false,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// // eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
// }, [recipients]);
|
||||||
|
|
||||||
// Always show advanced settings if any recipient has auth options.
|
// Always show advanced settings if any recipient has auth options.
|
||||||
const alwaysShowAdvancedSettings = useMemo(() => {
|
const alwaysShowAdvancedSettings = useMemo(() => {
|
||||||
@ -204,7 +230,12 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
const onRemoveSigner = (index: number) => {
|
const onRemoveSigner = (index: number) => {
|
||||||
removeSigner(index);
|
removeSigner(index);
|
||||||
const updatedSigners = signers.filter((_, idx) => idx !== index);
|
const updatedSigners = signers.filter((_, idx) => idx !== index);
|
||||||
form.setValue('signers', normalizeSigningOrders(updatedSigners));
|
form.setValue('signers', normalizeSigningOrders(updatedSigners), {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
void handleAutoSave();
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSignerDirectRecipient = (
|
const isSignerDirectRecipient = (
|
||||||
@ -231,7 +262,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
signingOrder: index + 1,
|
signingOrder: index + 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
form.setValue('signers', updatedSigners);
|
form.setValue('signers', updatedSigners, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
|
||||||
const lastSigner = updatedSigners[updatedSigners.length - 1];
|
const lastSigner = updatedSigners[updatedSigners.length - 1];
|
||||||
if (lastSigner.role === RecipientRole.ASSISTANT) {
|
if (lastSigner.role === RecipientRole.ASSISTANT) {
|
||||||
@ -244,8 +278,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await form.trigger('signers');
|
await form.trigger('signers');
|
||||||
|
|
||||||
|
void handleAutoSave();
|
||||||
},
|
},
|
||||||
[form, watchedSigners, toast],
|
[form, watchedSigners, toast, handleAutoSave],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSigningOrderChange = useCallback(
|
const handleSigningOrderChange = useCallback(
|
||||||
@ -273,7 +309,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
signingOrder: idx + 1,
|
signingOrder: idx + 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
form.setValue('signers', updatedSigners);
|
form.setValue('signers', updatedSigners, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
|
||||||
if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
|
if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
|
||||||
toast({
|
toast({
|
||||||
@ -283,8 +322,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void handleAutoSave();
|
||||||
},
|
},
|
||||||
[form, toast],
|
[form, toast, handleAutoSave],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRoleChange = useCallback(
|
const handleRoleChange = useCallback(
|
||||||
@ -294,7 +335,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
|
|
||||||
// Handle parallel to sequential conversion for assistants
|
// Handle parallel to sequential conversion for assistants
|
||||||
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
|
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
|
||||||
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL);
|
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Signing order is enabled.`),
|
title: _(msg`Signing order is enabled.`),
|
||||||
description: _(msg`You cannot add assistants when signing order is disabled.`),
|
description: _(msg`You cannot add assistants when signing order is disabled.`),
|
||||||
@ -309,7 +353,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
signingOrder: idx + 1,
|
signingOrder: idx + 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
form.setValue('signers', updatedSigners);
|
form.setValue('signers', updatedSigners, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
|
||||||
if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
|
if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
|
||||||
toast({
|
toast({
|
||||||
@ -319,8 +366,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void handleAutoSave();
|
||||||
},
|
},
|
||||||
[form, toast],
|
[form, toast, handleAutoSave],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false);
|
const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false);
|
||||||
@ -334,10 +383,21 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
|
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
form.setValue('signers', updatedSigners);
|
form.setValue('signers', updatedSigners, {
|
||||||
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL);
|
shouldValidate: true,
|
||||||
form.setValue('allowDictateNextSigner', false);
|
shouldDirty: true,
|
||||||
}, [form]);
|
});
|
||||||
|
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
form.setValue('allowDictateNextSigner', false, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
void handleAutoSave();
|
||||||
|
}, [form, handleAutoSave]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -382,8 +442,13 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
|
|
||||||
// If sequential signing is turned off, disable dictate next signer
|
// If sequential signing is turned off, disable dictate next signer
|
||||||
if (!checked) {
|
if (!checked) {
|
||||||
form.setValue('allowDictateNextSigner', false);
|
form.setValue('allowDictateNextSigner', false, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void handleAutoSave();
|
||||||
}}
|
}}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
@ -409,7 +474,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
{...field}
|
{...field}
|
||||||
id="allowDictateNextSigner"
|
id="allowDictateNextSigner"
|
||||||
checked={value}
|
checked={value}
|
||||||
onCheckedChange={field.onChange}
|
onCheckedChange={(checked) => {
|
||||||
|
field.onChange(checked);
|
||||||
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
disabled={isSubmitting || !isSigningOrderSequential}
|
disabled={isSubmitting || !isSigningOrderSequential}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -500,6 +568,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
max={signers.length}
|
max={signers.length}
|
||||||
|
data-testid="placeholder-recipient-signing-order-input"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-center',
|
'w-full text-center',
|
||||||
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
|
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
|
||||||
@ -558,6 +627,8 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
signers[index].email === user?.email ||
|
signers[index].email === user?.email ||
|
||||||
isSignerDirectRecipient(signer)
|
isSignerDirectRecipient(signer)
|
||||||
}
|
}
|
||||||
|
onBlur={handleAutoSave}
|
||||||
|
data-testid="placeholder-recipient-email-input"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
@ -592,6 +663,8 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
signers[index].email === user?.email ||
|
signers[index].email === user?.email ||
|
||||||
isSignerDirectRecipient(signer)
|
isSignerDirectRecipient(signer)
|
||||||
}
|
}
|
||||||
|
onBlur={handleAutoSave}
|
||||||
|
data-testid="placeholder-recipient-name-input"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
@ -633,10 +706,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<RecipientRoleSelect
|
<RecipientRoleSelect
|
||||||
{...field}
|
{...field}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
handleRoleChange(index, value as RecipientRole)
|
handleRoleChange(index, value as RecipientRole);
|
||||||
}
|
}}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
hideCCRecipients={isSignerDirectRecipient(signer)}
|
hideCCRecipients={isSignerDirectRecipient(signer)}
|
||||||
/>
|
/>
|
||||||
@ -672,6 +745,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
disabled={isSubmitting || signers.length === 1}
|
disabled={isSubmitting || signers.length === 1}
|
||||||
onClick={() => onRemoveSigner(index)}
|
onClick={() => onRemoveSigner(index)}
|
||||||
|
data-testid="remove-placeholder-recipient-button"
|
||||||
>
|
>
|
||||||
<Trash className="h-5 w-5" />
|
<Trash className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { InfoIcon } from 'lucide-react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
import {
|
import {
|
||||||
@ -83,6 +84,7 @@ export type AddTemplateSettingsFormProps = {
|
|||||||
template: TTemplate;
|
template: TTemplate;
|
||||||
currentTeamMemberRole?: TeamMemberRole;
|
currentTeamMemberRole?: TeamMemberRole;
|
||||||
onSubmit: (_data: TAddTemplateSettingsFormSchema) => void;
|
onSubmit: (_data: TAddTemplateSettingsFormSchema) => void;
|
||||||
|
onAutoSave: (_data: TAddTemplateSettingsFormSchema) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddTemplateSettingsFormPartial = ({
|
export const AddTemplateSettingsFormPartial = ({
|
||||||
@ -93,6 +95,7 @@ export const AddTemplateSettingsFormPartial = ({
|
|||||||
template,
|
template,
|
||||||
currentTeamMemberRole,
|
currentTeamMemberRole,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
onAutoSave,
|
||||||
}: AddTemplateSettingsFormProps) => {
|
}: AddTemplateSettingsFormProps) => {
|
||||||
const { t, i18n } = useLingui();
|
const { t, i18n } = useLingui();
|
||||||
|
|
||||||
@ -160,6 +163,28 @@ export const AddTemplateSettingsFormPartial = ({
|
|||||||
}
|
}
|
||||||
}, [form, form.setValue, form.formState.touchedFields.meta?.timezone]);
|
}, [form, form.setValue, form.formState.touchedFields.meta?.timezone]);
|
||||||
|
|
||||||
|
const { scheduleSave } = useAutoSave(onAutoSave);
|
||||||
|
|
||||||
|
const handleAutoSave = async () => {
|
||||||
|
const isFormValid = await form.trigger();
|
||||||
|
|
||||||
|
if (!isFormValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = form.getValues();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Parse the form data through the Zod schema to handle transformations
|
||||||
|
* (like -1 -> undefined for the Document Global Auth Access)
|
||||||
|
*/
|
||||||
|
const parseResult = ZAddTemplateSettingsFormSchema.safeParse(formData);
|
||||||
|
|
||||||
|
if (parseResult.success) {
|
||||||
|
scheduleSave(parseResult.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DocumentFlowFormContainerHeader
|
<DocumentFlowFormContainerHeader
|
||||||
@ -191,7 +216,7 @@ export const AddTemplateSettingsFormPartial = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input className="bg-background" {...field} />
|
<Input className="bg-background" {...field} onBlur={handleAutoSave} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@ -219,7 +244,13 @@ export const AddTemplateSettingsFormPartial = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select {...field} onValueChange={field.onChange}>
|
<Select
|
||||||
|
{...field}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SelectTrigger className="bg-background">
|
<SelectTrigger className="bg-background">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@ -250,9 +281,13 @@ export const AddTemplateSettingsFormPartial = ({
|
|||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<DocumentGlobalAuthAccessSelect
|
<DocumentGlobalAuthAccessSelect
|
||||||
|
{...field}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
disabled={field.disabled}
|
disabled={field.disabled}
|
||||||
onValueChange={field.onChange}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@ -275,7 +310,10 @@ export const AddTemplateSettingsFormPartial = ({
|
|||||||
canUpdateVisibility={canUpdateVisibility}
|
canUpdateVisibility={canUpdateVisibility}
|
||||||
currentTeamMemberRole={currentTeamMemberRole}
|
currentTeamMemberRole={currentTeamMemberRole}
|
||||||
{...field}
|
{...field}
|
||||||
onValueChange={field.onChange}
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@ -334,7 +372,13 @@ export const AddTemplateSettingsFormPartial = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select {...field} onValueChange={field.onChange}>
|
<Select
|
||||||
|
{...field}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SelectTrigger className="bg-background text-muted-foreground">
|
<SelectTrigger className="bg-background text-muted-foreground">
|
||||||
<SelectValue data-testid="documentDistributionMethodSelectValue" />
|
<SelectValue data-testid="documentDistributionMethodSelectValue" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@ -371,7 +415,10 @@ export const AddTemplateSettingsFormPartial = ({
|
|||||||
value: option.value,
|
value: option.value,
|
||||||
}))}
|
}))}
|
||||||
selectedValues={field.value}
|
selectedValues={field.value}
|
||||||
onChange={field.onChange}
|
onChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
className="bg-background w-full"
|
className="bg-background w-full"
|
||||||
emptySelectionPlaceholder="Select signature types"
|
emptySelectionPlaceholder="Select signature types"
|
||||||
/>
|
/>
|
||||||
@ -395,9 +442,13 @@ export const AddTemplateSettingsFormPartial = ({
|
|||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<DocumentGlobalAuthActionSelect
|
<DocumentGlobalAuthActionSelect
|
||||||
|
{...field}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
disabled={field.disabled}
|
disabled={field.disabled}
|
||||||
onValueChange={field.onChange}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@ -488,7 +539,7 @@ export const AddTemplateSettingsFormPartial = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} onBlur={handleAutoSave} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -515,7 +566,11 @@ export const AddTemplateSettingsFormPartial = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea className="bg-background h-16 resize-none" {...field} />
|
<Textarea
|
||||||
|
className="bg-background h-16 resize-none"
|
||||||
|
{...field}
|
||||||
|
onBlur={handleAutoSave}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -525,7 +580,12 @@ export const AddTemplateSettingsFormPartial = ({
|
|||||||
|
|
||||||
<DocumentEmailCheckboxes
|
<DocumentEmailCheckboxes
|
||||||
value={emailSettings}
|
value={emailSettings}
|
||||||
onChange={(value) => form.setValue('meta.emailSettings', value)}
|
onChange={(value) => {
|
||||||
|
form.setValue('meta.emailSettings', value, {
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
@ -563,7 +623,7 @@ export const AddTemplateSettingsFormPartial = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input className="bg-background" {...field} />
|
<Input className="bg-background" {...field} onBlur={handleAutoSave} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -581,7 +641,13 @@ export const AddTemplateSettingsFormPartial = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select {...field} onValueChange={field.onChange}>
|
<Select
|
||||||
|
{...field}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SelectTrigger className="bg-background">
|
<SelectTrigger className="bg-background">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@ -615,7 +681,10 @@ export const AddTemplateSettingsFormPartial = ({
|
|||||||
className="bg-background time-zone-field"
|
className="bg-background time-zone-field"
|
||||||
options={TIME_ZONES}
|
options={TIME_ZONES}
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(value) => value && field.onChange(value)}
|
onChange={(value) => {
|
||||||
|
value && field.onChange(value);
|
||||||
|
void handleAutoSave();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
@ -645,7 +714,7 @@ export const AddTemplateSettingsFormPartial = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input className="bg-background" {...field} />
|
<Input className="bg-background" {...field} onBlur={handleAutoSave} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
Reference in New Issue
Block a user