mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
feat: dictate next signer (#1719)
Adds next recipient dictation functionality to document signing flow, allowing assistants and signers to update the next recipient's information during the signing process. ## Related Issue N/A ## Changes Made - Added form handling for next recipient dictation in signing dialogs - Implemented UI for updating next recipient information - Added e2e tests covering dictation scenarios: - Regular signing with dictation enabled - Assistant role with dictation - Parallel signing flow - Disabled dictation state ## Testing Performed - Added comprehensive e2e tests covering: - Sequential signing with dictation - Assistant role dictation - Parallel signing without dictation - Form validation and state management - Tested on Chrome and Firefox - Verified recipient state updates in database
This commit is contained in:
@ -1,4 +1,9 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -9,64 +14,208 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
|
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
|
||||||
|
|
||||||
|
export type NextSigner = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
type ConfirmationDialogProps = {
|
type ConfirmationDialogProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: (nextSigner?: NextSigner) => void;
|
||||||
hasUninsertedFields: boolean;
|
hasUninsertedFields: boolean;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
|
allowDictateNextSigner?: boolean;
|
||||||
|
defaultNextSigner?: NextSigner;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ZNextSignerFormSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required'),
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
||||||
|
|
||||||
export function AssistantConfirmationDialog({
|
export function AssistantConfirmationDialog({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
hasUninsertedFields,
|
hasUninsertedFields,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
|
allowDictateNextSigner = false,
|
||||||
|
defaultNextSigner,
|
||||||
}: ConfirmationDialogProps) {
|
}: ConfirmationDialogProps) {
|
||||||
|
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<TNextSignerFormSchema>({
|
||||||
|
resolver: zodResolver(ZNextSignerFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: defaultNextSigner?.name ?? '',
|
||||||
|
email: defaultNextSigner?.email ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const onOpenChange = () => {
|
const onOpenChange = () => {
|
||||||
if (isSubmitting) {
|
if (form.formState.isSubmitting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
form.reset({
|
||||||
|
name: defaultNextSigner?.name ?? '',
|
||||||
|
email: defaultNextSigner?.email ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsEditingNextSigner(false);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
||||||
|
if (allowDictateNextSigner && data.name && data.email) {
|
||||||
|
await onConfirm({
|
||||||
|
name: data.name,
|
||||||
|
email: data.email,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await onConfirm();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<Form {...form}>
|
||||||
<DialogTitle>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<Trans>Complete Document</Trans>
|
<fieldset
|
||||||
</DialogTitle>
|
disabled={form.formState.isSubmitting || isSubmitting}
|
||||||
<DialogDescription>
|
className="border-none p-0"
|
||||||
<Trans>
|
>
|
||||||
Are you sure you want to complete the document? This action cannot be undone. Please
|
<DialogHeader>
|
||||||
ensure that you have completed prefilling all relevant fields before proceeding.
|
<DialogTitle>
|
||||||
</Trans>
|
<Trans>Complete Document</Trans>
|
||||||
</DialogDescription>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
<DialogDescription>
|
||||||
|
<Trans>
|
||||||
|
Are you sure you want to complete the document? This action cannot be undone.
|
||||||
|
Please ensure that you have completed prefilling all relevant fields before
|
||||||
|
proceeding.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
<DocumentSigningDisclosure />
|
{allowDictateNextSigner && (
|
||||||
</div>
|
<div className="space-y-4">
|
||||||
|
{!isEditingNextSigner && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
The next recipient to sign this document will be{' '}
|
||||||
|
<span className="font-semibold">{form.watch('name')}</span> (
|
||||||
|
<span className="font-semibold">{form.watch('email')}</span>).
|
||||||
|
</p>
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
<Button
|
||||||
<Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
type="button"
|
||||||
Cancel
|
className="mt-2"
|
||||||
</Button>
|
variant="outline"
|
||||||
<Button
|
size="sm"
|
||||||
variant={hasUninsertedFields ? 'destructive' : 'default'}
|
onClick={() => setIsEditingNextSigner((prev) => !prev)}
|
||||||
onClick={onConfirm}
|
>
|
||||||
disabled={isSubmitting}
|
<Trans>Update Recipient</Trans>
|
||||||
loading={isSubmitting}
|
</Button>
|
||||||
>
|
</div>
|
||||||
{isSubmitting ? 'Submitting...' : hasUninsertedFields ? 'Proceed' : 'Continue'}
|
)}
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
{isEditingNextSigner && (
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
className="mt-2"
|
||||||
|
placeholder="Enter the next signer's name"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Email</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="email"
|
||||||
|
className="mt-2"
|
||||||
|
placeholder="Enter the next signer's email"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DocumentSigningDisclosure className="mt-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant={hasUninsertedFields ? 'destructive' : 'default'}
|
||||||
|
disabled={form.formState.isSubmitting || !isNextSignerValid}
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
{form.formState.isSubmitting ? (
|
||||||
|
<Trans>Submitting...</Trans>
|
||||||
|
) : hasUninsertedFields ? (
|
||||||
|
<Trans>Proceed</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Continue</Trans>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
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 { RecipientRole } from '@prisma/client';
|
import { RecipientRole } from '@prisma/client';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -14,6 +17,15 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
||||||
|
|
||||||
@ -22,11 +34,23 @@ export type DocumentSigningCompleteDialogProps = {
|
|||||||
documentTitle: string;
|
documentTitle: string;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
fieldsValidated: () => void | Promise<void>;
|
fieldsValidated: () => void | Promise<void>;
|
||||||
onSignatureComplete: () => void | Promise<void>;
|
onSignatureComplete: (nextSigner?: { name: string; email: string }) => void | Promise<void>;
|
||||||
role: RecipientRole;
|
role: RecipientRole;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
allowDictateNextSigner?: boolean;
|
||||||
|
defaultNextSigner?: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ZNextSignerFormSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required'),
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
||||||
|
|
||||||
export const DocumentSigningCompleteDialog = ({
|
export const DocumentSigningCompleteDialog = ({
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
documentTitle,
|
documentTitle,
|
||||||
@ -35,19 +59,54 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
onSignatureComplete,
|
onSignatureComplete,
|
||||||
role,
|
role,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
allowDictateNextSigner = false,
|
||||||
|
defaultNextSigner,
|
||||||
}: DocumentSigningCompleteDialogProps) => {
|
}: DocumentSigningCompleteDialogProps) => {
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<TNextSignerFormSchema>({
|
||||||
|
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
||||||
|
defaultValues: {
|
||||||
|
name: defaultNextSigner?.name ?? '',
|
||||||
|
email: defaultNextSigner?.email ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
const handleOpenChange = (open: boolean) => {
|
||||||
if (isSubmitting || !isComplete) {
|
if (form.formState.isSubmitting || !isComplete) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
form.reset({
|
||||||
|
name: defaultNextSigner?.name ?? '',
|
||||||
|
email: defaultNextSigner?.email ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsEditingNextSigner(false);
|
||||||
setShowDialog(open);
|
setShowDialog(open);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
||||||
|
console.log('data', data);
|
||||||
|
console.log('form.formState.errors', form.formState.errors);
|
||||||
|
try {
|
||||||
|
if (allowDictateNextSigner && data.name && data.email) {
|
||||||
|
await onSignatureComplete({ name: data.name, email: data.email });
|
||||||
|
} else {
|
||||||
|
await onSignatureComplete();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error completing signature:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@ -71,110 +130,184 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogTitle>
|
<Form {...form}>
|
||||||
<div className="text-foreground text-xl font-semibold">
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
{match(role)
|
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
||||||
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
<DialogTitle>
|
||||||
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
<div className="text-foreground text-xl font-semibold">
|
||||||
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
{match(role)
|
||||||
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
|
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
||||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
|
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
||||||
.exhaustive()}
|
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
||||||
</div>
|
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
|
||||||
</DialogTitle>
|
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
|
||||||
|
.exhaustive()}
|
||||||
|
</div>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
<div className="text-muted-foreground max-w-[50ch]">
|
<div className="text-muted-foreground max-w-[50ch]">
|
||||||
{match(role)
|
{match(role)
|
||||||
.with(RecipientRole.VIEWER, () => (
|
.with(RecipientRole.VIEWER, () => (
|
||||||
<span>
|
<span>
|
||||||
<Trans>
|
<Trans>
|
||||||
<span className="inline-flex flex-wrap">
|
<span className="inline-flex flex-wrap">
|
||||||
You are about to complete viewing "
|
You are about to complete viewing "
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
{documentTitle}
|
{documentTitle}
|
||||||
|
</span>
|
||||||
|
".
|
||||||
|
</span>
|
||||||
|
<br /> Are you sure?
|
||||||
|
</Trans>
|
||||||
</span>
|
</span>
|
||||||
".
|
))
|
||||||
</span>
|
.with(RecipientRole.SIGNER, () => (
|
||||||
<br /> Are you sure?
|
<span>
|
||||||
</Trans>
|
<Trans>
|
||||||
</span>
|
<span className="inline-flex flex-wrap">
|
||||||
))
|
You are about to complete signing "
|
||||||
.with(RecipientRole.SIGNER, () => (
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
<span>
|
{documentTitle}
|
||||||
<Trans>
|
</span>
|
||||||
<span className="inline-flex flex-wrap">
|
".
|
||||||
You are about to complete signing "
|
</span>
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
<br /> Are you sure?
|
||||||
{documentTitle}
|
</Trans>
|
||||||
</span>
|
</span>
|
||||||
".
|
))
|
||||||
</span>
|
.with(RecipientRole.APPROVER, () => (
|
||||||
<br /> Are you sure?
|
<span>
|
||||||
</Trans>
|
<Trans>
|
||||||
</span>
|
<span className="inline-flex flex-wrap">
|
||||||
))
|
You are about to complete approving{' '}
|
||||||
.with(RecipientRole.APPROVER, () => (
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
<span>
|
"{documentTitle}"
|
||||||
<Trans>
|
</span>
|
||||||
<span className="inline-flex flex-wrap">
|
.
|
||||||
You are about to complete approving{' '}
|
</span>
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
<br /> Are you sure?
|
||||||
"{documentTitle}"
|
</Trans>
|
||||||
</span>
|
</span>
|
||||||
.
|
))
|
||||||
</span>
|
.otherwise(() => (
|
||||||
<br /> Are you sure?
|
<span>
|
||||||
</Trans>
|
<Trans>
|
||||||
</span>
|
<span className="inline-flex flex-wrap">
|
||||||
))
|
You are about to complete viewing "
|
||||||
.otherwise(() => (
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
<span>
|
{documentTitle}
|
||||||
<Trans>
|
</span>
|
||||||
<span className="inline-flex flex-wrap">
|
".
|
||||||
You are about to complete viewing "
|
</span>
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
<br /> Are you sure?
|
||||||
{documentTitle}
|
</Trans>
|
||||||
</span>
|
</span>
|
||||||
".
|
))}
|
||||||
</span>
|
</div>
|
||||||
<br /> Are you sure?
|
|
||||||
</Trans>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DocumentSigningDisclosure className="mt-4" />
|
{allowDictateNextSigner && (
|
||||||
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
|
{!isEditingNextSigner && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
The next recipient to sign this document will be{' '}
|
||||||
|
<span className="font-semibold">{form.watch('name')}</span> (
|
||||||
|
<span className="font-semibold">{form.watch('email')}</span>).
|
||||||
|
</p>
|
||||||
|
|
||||||
<DialogFooter>
|
<Button
|
||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
type="button"
|
||||||
<Button
|
className="mt-2"
|
||||||
type="button"
|
variant="outline"
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
size="sm"
|
||||||
variant="secondary"
|
onClick={() => setIsEditingNextSigner((prev) => !prev)}
|
||||||
onClick={() => {
|
>
|
||||||
setShowDialog(false);
|
<Trans>Update Recipient</Trans>
|
||||||
}}
|
</Button>
|
||||||
>
|
</div>
|
||||||
<Trans>Cancel</Trans>
|
)}
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
{isEditingNextSigner && (
|
||||||
type="button"
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
className="flex-1"
|
<FormField
|
||||||
disabled={!isComplete}
|
control={form.control}
|
||||||
loading={isSubmitting}
|
name="name"
|
||||||
onClick={onSignatureComplete}
|
render={({ field }) => (
|
||||||
>
|
<FormItem className="flex-1">
|
||||||
{match(role)
|
<FormLabel>
|
||||||
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
|
<Trans>Name</Trans>
|
||||||
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
|
</FormLabel>
|
||||||
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
|
<FormControl>
|
||||||
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
|
<Input
|
||||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
{...field}
|
||||||
.exhaustive()}
|
className="mt-2"
|
||||||
</Button>
|
placeholder="Enter the next signer's name"
|
||||||
</div>
|
/>
|
||||||
</DialogFooter>
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Email</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="email"
|
||||||
|
className="mt-2"
|
||||||
|
placeholder="Enter the next signer's email"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DocumentSigningDisclosure className="mt-4" />
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowDialog(false)}
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!isComplete || !isNextSignerValid}
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
{match(role)
|
||||||
|
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
|
||||||
|
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
|
||||||
|
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
|
||||||
|
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
|
||||||
|
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
||||||
|
.exhaustive()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
import { useId, useMemo, useState } from 'react';
|
import { useId, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
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';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client';
|
import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client';
|
||||||
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 { z } from 'zod';
|
||||||
|
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||||
@ -25,10 +27,20 @@ import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group
|
|||||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { AssistantConfirmationDialog } from '../../dialogs/assistant-confirmation-dialog';
|
import {
|
||||||
|
AssistantConfirmationDialog,
|
||||||
|
type NextSigner,
|
||||||
|
} from '../../dialogs/assistant-confirmation-dialog';
|
||||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||||
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
||||||
|
|
||||||
|
export const ZSigningFormSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required').optional(),
|
||||||
|
email: z.string().email('Invalid email address').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSigningFormSchema = z.infer<typeof ZSigningFormSchema>;
|
||||||
|
|
||||||
export type DocumentSigningFormProps = {
|
export type DocumentSigningFormProps = {
|
||||||
document: DocumentAndSender;
|
document: DocumentAndSender;
|
||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
@ -75,7 +87,9 @@ export const DocumentSigningForm = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { handleSubmit, formState } = useForm();
|
const { handleSubmit, formState } = useForm<TSigningFormSchema>({
|
||||||
|
resolver: zodResolver(ZSigningFormSchema),
|
||||||
|
});
|
||||||
|
|
||||||
// Keep the loading state going if successful since the redirect may take some time.
|
// Keep the loading state going if successful since the redirect may take some time.
|
||||||
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
|
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
|
||||||
@ -100,20 +114,36 @@ export const DocumentSigningForm = ({
|
|||||||
validateFieldsInserted(fieldsRequiringValidation);
|
validateFieldsInserted(fieldsRequiringValidation);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFormSubmit = async () => {
|
const onFormSubmit = async (data: TSigningFormSchema) => {
|
||||||
setValidateUninsertedFields(true);
|
try {
|
||||||
|
setValidateUninsertedFields(true);
|
||||||
|
|
||||||
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
|
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
|
||||||
|
|
||||||
if (hasSignatureField && !signatureValid) {
|
if (hasSignatureField && !signatureValid) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFieldsValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSigner =
|
||||||
|
data.email && data.name
|
||||||
|
? {
|
||||||
|
email: data.email,
|
||||||
|
name: data.name,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await completeDocument(undefined, nextSigner);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: error instanceof Error ? error.message : 'An error occurred while signing',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isFieldsValid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await completeDocument();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAssistantFormSubmit = () => {
|
const onAssistantFormSubmit = () => {
|
||||||
@ -124,11 +154,11 @@ export const DocumentSigningForm = ({
|
|||||||
setIsConfirmationDialogOpen(true);
|
setIsConfirmationDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAssistantConfirmDialogSubmit = async () => {
|
const handleAssistantConfirmDialogSubmit = async (nextSigner?: NextSigner) => {
|
||||||
setIsAssistantSubmitting(true);
|
setIsAssistantSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await completeDocument();
|
await completeDocument(undefined, nextSigner);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
@ -141,12 +171,18 @@ export const DocumentSigningForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
|
const completeDocument = async (
|
||||||
await completeDocumentWithToken({
|
authOptions?: TRecipientActionAuth,
|
||||||
|
nextSigner?: { email: string; name: string },
|
||||||
|
) => {
|
||||||
|
const payload = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
authOptions,
|
authOptions,
|
||||||
});
|
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await completeDocumentWithToken(payload);
|
||||||
|
|
||||||
analytics.capture('App: Recipient has completed signing', {
|
analytics.capture('App: Recipient has completed signing', {
|
||||||
signerId: recipient.id,
|
signerId: recipient.id,
|
||||||
@ -161,6 +197,31 @@ export const DocumentSigningForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
console.log('nextRecipient', nextRecipient);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -210,12 +271,19 @@ export const DocumentSigningForm = ({
|
|||||||
|
|
||||||
<DocumentSigningCompleteDialog
|
<DocumentSigningCompleteDialog
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
|
||||||
documentTitle={document.title}
|
documentTitle={document.title}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
|
onSignatureComplete={async (nextSigner) => {
|
||||||
|
await completeDocument(undefined, nextSigner);
|
||||||
|
}}
|
||||||
role={recipient.role}
|
role={recipient.role}
|
||||||
disabled={!isRecipientsTurn}
|
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||||
|
defaultNextSigner={
|
||||||
|
nextRecipient
|
||||||
|
? { name: nextRecipient.name, email: nextRecipient.email }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -306,6 +374,12 @@ export const DocumentSigningForm = ({
|
|||||||
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
|
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
|
||||||
onConfirm={handleAssistantConfirmDialogSubmit}
|
onConfirm={handleAssistantConfirmDialogSubmit}
|
||||||
isSubmitting={isAssistantSubmitting}
|
isSubmitting={isAssistantSubmitting}
|
||||||
|
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||||
|
defaultNextSigner={
|
||||||
|
nextRecipient
|
||||||
|
? { name: nextRecipient.name, email: nextRecipient.email }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
@ -376,30 +450,38 @@ export const DocumentSigningForm = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
|
||||||
variant="secondary"
|
|
||||||
size="lg"
|
|
||||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
|
||||||
onClick={async () => navigate(-1)}
|
|
||||||
>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<DocumentSigningCompleteDialog
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
|
||||||
documentTitle={document.title}
|
|
||||||
fields={fields}
|
|
||||||
fieldsValidated={fieldsValidated}
|
|
||||||
role={recipient.role}
|
|
||||||
disabled={!isRecipientsTurn}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-col gap-4 md:flex-row">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||||
|
onClick={async () => navigate(-1)}
|
||||||
|
>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DocumentSigningCompleteDialog
|
||||||
|
isSubmitting={isSubmitting || isAssistantSubmitting}
|
||||||
|
documentTitle={document.title}
|
||||||
|
fields={fields}
|
||||||
|
fieldsValidated={fieldsValidated}
|
||||||
|
disabled={!isRecipientsTurn}
|
||||||
|
onSignatureComplete={async (nextSigner) => {
|
||||||
|
await completeDocument(undefined, nextSigner);
|
||||||
|
}}
|
||||||
|
role={recipient.role}
|
||||||
|
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||||
|
defaultNextSigner={
|
||||||
|
nextRecipient
|
||||||
|
? { name: nextRecipient.name, email: nextRecipient.email }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -40,9 +40,9 @@ import { DocumentReadOnlyFields } from '~/components/general/document/document-r
|
|||||||
|
|
||||||
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
||||||
|
|
||||||
export type SigningPageViewProps = {
|
export type DocumentSigningPageViewProps = {
|
||||||
document: DocumentAndSender;
|
|
||||||
recipient: RecipientWithFields;
|
recipient: RecipientWithFields;
|
||||||
|
document: DocumentAndSender;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
completedFields: CompletedField[];
|
completedFields: CompletedField[];
|
||||||
isRecipientsTurn: boolean;
|
isRecipientsTurn: boolean;
|
||||||
@ -50,13 +50,13 @@ export type SigningPageViewProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentSigningPageView = ({
|
export const DocumentSigningPageView = ({
|
||||||
document,
|
|
||||||
recipient,
|
recipient,
|
||||||
|
document,
|
||||||
fields,
|
fields,
|
||||||
completedFields,
|
completedFields,
|
||||||
isRecipientsTurn,
|
isRecipientsTurn,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
}: SigningPageViewProps) => {
|
}: DocumentSigningPageViewProps) => {
|
||||||
const { documentData, documentMeta } = document;
|
const { documentData, documentMeta } = document;
|
||||||
|
|
||||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
||||||
|
|||||||
@ -71,7 +71,7 @@ export const DocumentEditForm = ({
|
|||||||
|
|
||||||
const { recipients, fields } = document;
|
const { recipients, fields } = document;
|
||||||
|
|
||||||
const { mutateAsync: updateDocument } = trpc.document.setSettingsForDocument.useMutation({
|
const { mutateAsync: updateDocumentSettings } = trpc.document.setSettingsForDocument.useMutation({
|
||||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
onSuccess: (newData) => {
|
onSuccess: (newData) => {
|
||||||
utils.document.getDocumentWithDetailsById.setData(
|
utils.document.getDocumentWithDetailsById.setData(
|
||||||
@ -176,7 +176,7 @@ export const DocumentEditForm = ({
|
|||||||
try {
|
try {
|
||||||
const { timezone, dateFormat, redirectUrl, language } = data.meta;
|
const { timezone, dateFormat, redirectUrl, language } = data.meta;
|
||||||
|
|
||||||
await updateDocument({
|
await updateDocumentSettings({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
data: {
|
data: {
|
||||||
title: data.title,
|
title: data.title,
|
||||||
@ -213,6 +213,13 @@ export const DocumentEditForm = ({
|
|||||||
signingOrder: data.signingOrder,
|
signingOrder: data.signingOrder,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
updateDocumentSettings({
|
||||||
|
documentId: document.id,
|
||||||
|
meta: {
|
||||||
|
allowDictateNextSigner: data.allowDictateNextSigner,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
setRecipients({
|
setRecipients({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
recipients: data.signers.map((signer) => ({
|
recipients: data.signers.map((signer) => ({
|
||||||
@ -242,7 +249,7 @@ export const DocumentEditForm = ({
|
|||||||
fields: data.fields,
|
fields: data.fields,
|
||||||
});
|
});
|
||||||
|
|
||||||
await updateDocument({
|
await updateDocumentSettings({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
|
|
||||||
meta: {
|
meta: {
|
||||||
@ -365,6 +372,7 @@ export const DocumentEditForm = ({
|
|||||||
documentFlow={documentFlow.signers}
|
documentFlow={documentFlow.signers}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
signingOrder={document.documentMeta?.signingOrder}
|
signingOrder={document.documentMeta?.signingOrder}
|
||||||
|
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
isDocumentEnterprise={isDocumentEnterprise}
|
isDocumentEnterprise={isDocumentEnterprise}
|
||||||
onSubmit={onAddSignersFormSubmit}
|
onSubmit={onAddSignersFormSubmit}
|
||||||
|
|||||||
@ -161,6 +161,7 @@ export const TemplateEditForm = ({
|
|||||||
templateId: template.id,
|
templateId: template.id,
|
||||||
meta: {
|
meta: {
|
||||||
signingOrder: data.signingOrder,
|
signingOrder: data.signingOrder,
|
||||||
|
allowDictateNextSigner: data.allowDictateNextSigner,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@ -271,6 +272,7 @@ export const TemplateEditForm = ({
|
|||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
signingOrder={template.templateMeta?.signingOrder}
|
signingOrder={template.templateMeta?.signingOrder}
|
||||||
|
allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner}
|
||||||
templateDirectLink={template.directLink}
|
templateDirectLink={template.directLink}
|
||||||
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
||||||
isEnterprise={isEnterprise}
|
isEnterprise={isEnterprise}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
import { DocumentSigningOrder, DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
import { Clock8 } from 'lucide-react';
|
import { Clock8 } from 'lucide-react';
|
||||||
import { Link, redirect } from 'react-router';
|
import { Link, redirect } from 'react-router';
|
||||||
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
|
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
|
||||||
@ -13,6 +13,7 @@ import { viewedDocument } from '@documenso/lib/server-only/document/viewed-docum
|
|||||||
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
||||||
|
import { getNextPendingRecipient } from '@documenso/lib/server-only/recipient/get-next-pending-recipient';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||||
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
||||||
@ -72,7 +73,24 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
? await getRecipientsForAssistant({
|
? await getRecipientsForAssistant({
|
||||||
token,
|
token,
|
||||||
})
|
})
|
||||||
: [];
|
: [recipient];
|
||||||
|
|
||||||
|
if (
|
||||||
|
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL &&
|
||||||
|
recipient.role !== RecipientRole.ASSISTANT
|
||||||
|
) {
|
||||||
|
const nextPendingRecipient = await getNextPendingRecipient({
|
||||||
|
documentId: document.id,
|
||||||
|
currentRecipientId: recipient.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nextPendingRecipient) {
|
||||||
|
allRecipients.push({
|
||||||
|
...nextPendingRecipient,
|
||||||
|
fields: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
documentAuth: document.authOptions,
|
documentAuth: document.authOptions,
|
||||||
|
|||||||
@ -323,6 +323,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
dateFormat: dateFormat?.value,
|
dateFormat: dateFormat?.value,
|
||||||
redirectUrl: body.meta.redirectUrl,
|
redirectUrl: body.meta.redirectUrl,
|
||||||
signingOrder: body.meta.signingOrder,
|
signingOrder: body.meta.signingOrder,
|
||||||
|
allowDictateNextSigner: body.meta.allowDictateNextSigner,
|
||||||
language: body.meta.language,
|
language: body.meta.language,
|
||||||
typedSignatureEnabled: body.meta.typedSignatureEnabled,
|
typedSignatureEnabled: body.meta.typedSignatureEnabled,
|
||||||
distributionMethod: body.meta.distributionMethod,
|
distributionMethod: body.meta.distributionMethod,
|
||||||
|
|||||||
@ -155,6 +155,7 @@ export const ZCreateDocumentMutationSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
redirectUrl: z.string(),
|
redirectUrl: z.string(),
|
||||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||||
|
allowDictateNextSigner: z.boolean().optional(),
|
||||||
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
|
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
|
||||||
typedSignatureEnabled: z.boolean().optional().default(true),
|
typedSignatureEnabled: z.boolean().optional().default(true),
|
||||||
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(),
|
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(),
|
||||||
@ -218,6 +219,7 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
|
|||||||
dateFormat: z.string(),
|
dateFormat: z.string(),
|
||||||
redirectUrl: z.string(),
|
redirectUrl: z.string(),
|
||||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||||
|
allowDictateNextSigner: z.boolean().optional(),
|
||||||
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
|
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
|
||||||
})
|
})
|
||||||
.partial()
|
.partial()
|
||||||
@ -285,6 +287,7 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
|||||||
dateFormat: z.string(),
|
dateFormat: z.string(),
|
||||||
redirectUrl: ZUrlSchema,
|
redirectUrl: ZUrlSchema,
|
||||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||||
|
allowDictateNextSigner: z.boolean(),
|
||||||
language: z.enum(SUPPORTED_LANGUAGE_CODES),
|
language: z.enum(SUPPORTED_LANGUAGE_CODES),
|
||||||
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
|
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
|
||||||
typedSignatureEnabled: z.boolean(),
|
typedSignatureEnabled: z.boolean(),
|
||||||
|
|||||||
@ -210,7 +210,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
fields: [FieldType.DATE],
|
fields: [FieldType.DATE, FieldType.SIGNATURE],
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const recipient of recipients) {
|
for (const recipient of recipients) {
|
||||||
@ -307,7 +307,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
fields: [FieldType.DATE],
|
fields: [FieldType.DATE, FieldType.SIGNATURE],
|
||||||
updateDocumentOptions: {
|
updateDocumentOptions: {
|
||||||
authOptions: createDocumentAuthOptions({
|
authOptions: createDocumentAuthOptions({
|
||||||
globalAccessAuth: null,
|
globalAccessAuth: null,
|
||||||
|
|||||||
@ -0,0 +1,390 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
DocumentSigningOrder,
|
||||||
|
DocumentStatus,
|
||||||
|
FieldType,
|
||||||
|
RecipientRole,
|
||||||
|
SigningStatus,
|
||||||
|
} from '@prisma/client';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { signSignaturePad } from '../fixtures/signature';
|
||||||
|
|
||||||
|
test('[NEXT_RECIPIENT_DICTATION]: should allow updating next recipient when dictation is enabled', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const firstSigner = await seedUser();
|
||||||
|
const secondSigner = await seedUser();
|
||||||
|
const thirdSigner = await seedUser();
|
||||||
|
|
||||||
|
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
recipients: [firstSigner, secondSigner, thirdSigner],
|
||||||
|
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }, { signingOrder: 3 }],
|
||||||
|
updateDocumentOptions: {
|
||||||
|
documentMeta: {
|
||||||
|
upsert: {
|
||||||
|
create: {
|
||||||
|
allowDictateNextSigner: true,
|
||||||
|
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
allowDictateNextSigner: true,
|
||||||
|
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstRecipient = recipients[0];
|
||||||
|
const { token, fields } = firstRecipient;
|
||||||
|
|
||||||
|
const signUrl = `/sign/${token}`;
|
||||||
|
|
||||||
|
await page.goto(signUrl);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
|
||||||
|
await signSignaturePad(page);
|
||||||
|
|
||||||
|
// Fill in all fields
|
||||||
|
for (const field of fields) {
|
||||||
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
|
||||||
|
if (field.type === FieldType.TEXT) {
|
||||||
|
await page.locator('#custom-text').fill('TEXT');
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete signing and update next recipient
|
||||||
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
|
||||||
|
// Verify next recipient info is shown
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await expect(page.getByText('The next recipient to sign this document will be')).toBeVisible();
|
||||||
|
|
||||||
|
// Update next recipient
|
||||||
|
await page.locator('button').filter({ hasText: 'Update Recipient' }).click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Use dialog context to ensure we're targeting the correct form fields
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await dialog.getByLabel('Name').fill('New Recipient');
|
||||||
|
await dialog.getByLabel('Email').fill('new.recipient@example.com');
|
||||||
|
|
||||||
|
// Submit and verify completion
|
||||||
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
await page.waitForURL(`${signUrl}/complete`);
|
||||||
|
|
||||||
|
// Verify document and recipient states
|
||||||
|
const updatedDocument = await prisma.document.findUniqueOrThrow({
|
||||||
|
where: { id: document.id },
|
||||||
|
include: {
|
||||||
|
recipients: {
|
||||||
|
orderBy: { signingOrder: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Document should still be pending as there are more recipients
|
||||||
|
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
|
||||||
|
|
||||||
|
// First recipient should be completed
|
||||||
|
const updatedFirstRecipient = updatedDocument.recipients[0];
|
||||||
|
expect(updatedFirstRecipient.signingStatus).toBe(SigningStatus.SIGNED);
|
||||||
|
|
||||||
|
// Second recipient should be the new recipient
|
||||||
|
const updatedSecondRecipient = updatedDocument.recipients[1];
|
||||||
|
expect(updatedSecondRecipient.name).toBe('New Recipient');
|
||||||
|
expect(updatedSecondRecipient.email).toBe('new.recipient@example.com');
|
||||||
|
expect(updatedSecondRecipient.signingOrder).toBe(2);
|
||||||
|
expect(updatedSecondRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[NEXT_RECIPIENT_DICTATION]: should not show dictation UI when disabled', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const firstSigner = await seedUser();
|
||||||
|
const secondSigner = await seedUser();
|
||||||
|
|
||||||
|
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
recipients: [firstSigner, secondSigner],
|
||||||
|
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }],
|
||||||
|
updateDocumentOptions: {
|
||||||
|
documentMeta: {
|
||||||
|
upsert: {
|
||||||
|
create: {
|
||||||
|
allowDictateNextSigner: false,
|
||||||
|
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
allowDictateNextSigner: false,
|
||||||
|
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstRecipient = recipients[0];
|
||||||
|
const { token, fields } = firstRecipient;
|
||||||
|
|
||||||
|
const signUrl = `/sign/${token}`;
|
||||||
|
|
||||||
|
await page.goto(signUrl);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
|
||||||
|
await signSignaturePad(page);
|
||||||
|
|
||||||
|
// Fill in all fields
|
||||||
|
for (const field of fields) {
|
||||||
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
|
||||||
|
if (field.type === FieldType.TEXT) {
|
||||||
|
await page.locator('#custom-text').fill('TEXT');
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete signing
|
||||||
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
|
||||||
|
// Verify next recipient UI is not shown
|
||||||
|
await expect(
|
||||||
|
page.getByText('The next recipient to sign this document will be'),
|
||||||
|
).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Update Recipient' })).not.toBeVisible();
|
||||||
|
|
||||||
|
// Submit and verify completion
|
||||||
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
await page.waitForURL(`${signUrl}/complete`);
|
||||||
|
|
||||||
|
// Verify document and recipient states
|
||||||
|
|
||||||
|
const updatedDocument = await prisma.document.findUniqueOrThrow({
|
||||||
|
where: { id: document.id },
|
||||||
|
include: {
|
||||||
|
recipients: {
|
||||||
|
orderBy: { signingOrder: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Document should still be pending as there are more recipients
|
||||||
|
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
|
||||||
|
|
||||||
|
// First recipient should be completed
|
||||||
|
const updatedFirstRecipient = updatedDocument.recipients[0];
|
||||||
|
expect(updatedFirstRecipient.signingStatus).toBe(SigningStatus.SIGNED);
|
||||||
|
|
||||||
|
// Second recipient should remain unchanged
|
||||||
|
const updatedSecondRecipient = updatedDocument.recipients[1];
|
||||||
|
expect(updatedSecondRecipient.email).toBe(secondSigner.email);
|
||||||
|
expect(updatedSecondRecipient.signingOrder).toBe(2);
|
||||||
|
expect(updatedSecondRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[NEXT_RECIPIENT_DICTATION]: should work with parallel signing flow', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const firstSigner = await seedUser();
|
||||||
|
const secondSigner = await seedUser();
|
||||||
|
|
||||||
|
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
recipients: [firstSigner, secondSigner],
|
||||||
|
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }],
|
||||||
|
updateDocumentOptions: {
|
||||||
|
documentMeta: {
|
||||||
|
upsert: {
|
||||||
|
create: {
|
||||||
|
allowDictateNextSigner: false,
|
||||||
|
signingOrder: DocumentSigningOrder.PARALLEL,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
allowDictateNextSigner: false,
|
||||||
|
signingOrder: DocumentSigningOrder.PARALLEL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test both recipients can sign in parallel
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const { token, fields } = recipient;
|
||||||
|
const signUrl = `/sign/${token}`;
|
||||||
|
|
||||||
|
await page.goto(signUrl);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
|
||||||
|
await signSignaturePad(page);
|
||||||
|
|
||||||
|
// Fill in all fields
|
||||||
|
for (const field of fields) {
|
||||||
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
|
||||||
|
if (field.type === FieldType.TEXT) {
|
||||||
|
await page.locator('#custom-text').fill('TEXT');
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete signing
|
||||||
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
|
||||||
|
// Verify next recipient UI is not shown in parallel flow
|
||||||
|
await expect(
|
||||||
|
page.getByText('The next recipient to sign this document will be'),
|
||||||
|
).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Update Recipient' })).not.toBeVisible();
|
||||||
|
|
||||||
|
// Submit and verify completion
|
||||||
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
await page.waitForURL(`${signUrl}/complete`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify final document and recipient states
|
||||||
|
await expect(async () => {
|
||||||
|
const updatedDocument = await prisma.document.findUniqueOrThrow({
|
||||||
|
where: { id: document.id },
|
||||||
|
include: {
|
||||||
|
recipients: {
|
||||||
|
orderBy: { signingOrder: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Document should be completed since all recipients have signed
|
||||||
|
expect(updatedDocument.status).toBe(DocumentStatus.COMPLETED);
|
||||||
|
|
||||||
|
// All recipients should be completed
|
||||||
|
for (const recipient of updatedDocument.recipients) {
|
||||||
|
expect(recipient.signingStatus).toBe(SigningStatus.SIGNED);
|
||||||
|
}
|
||||||
|
}).toPass();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[NEXT_RECIPIENT_DICTATION]: should allow assistant to dictate next signer', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const assistant = await seedUser();
|
||||||
|
const signer = await seedUser();
|
||||||
|
const thirdSigner = await seedUser();
|
||||||
|
|
||||||
|
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
recipients: [assistant, signer, thirdSigner],
|
||||||
|
recipientsCreateOptions: [
|
||||||
|
{ signingOrder: 1, role: RecipientRole.ASSISTANT },
|
||||||
|
{ signingOrder: 2, role: RecipientRole.SIGNER },
|
||||||
|
{ signingOrder: 3, role: RecipientRole.SIGNER },
|
||||||
|
],
|
||||||
|
updateDocumentOptions: {
|
||||||
|
documentMeta: {
|
||||||
|
upsert: {
|
||||||
|
create: {
|
||||||
|
allowDictateNextSigner: true,
|
||||||
|
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
allowDictateNextSigner: true,
|
||||||
|
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistantRecipient = recipients[0];
|
||||||
|
const { token, fields } = assistantRecipient;
|
||||||
|
|
||||||
|
const signUrl = `/sign/${token}`;
|
||||||
|
|
||||||
|
await page.goto(signUrl);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Assist Document' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('radio', { name: assistantRecipient.name }).click();
|
||||||
|
|
||||||
|
// Fill in all fields
|
||||||
|
for (const field of fields) {
|
||||||
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
|
||||||
|
if (field.type === FieldType.SIGNATURE) {
|
||||||
|
await signSignaturePad(page);
|
||||||
|
await page.getByRole('button', { name: 'Sign', exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === FieldType.TEXT) {
|
||||||
|
await page.locator('#custom-text').fill('TEXT');
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete assisting and update next recipient
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
// Verify next recipient info is shown
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await expect(page.getByText('The next recipient to sign this document will be')).toBeVisible();
|
||||||
|
|
||||||
|
// Update next recipient
|
||||||
|
await page.locator('button').filter({ hasText: 'Update Recipient' }).click();
|
||||||
|
|
||||||
|
// Use dialog context to ensure we're targeting the correct form fields
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await dialog.getByLabel('Name').fill('New Signer');
|
||||||
|
await dialog.getByLabel('Email').fill('new.signer@example.com');
|
||||||
|
|
||||||
|
// Submit and verify completion
|
||||||
|
await page.getByRole('button', { name: /Continue|Proceed/i }).click();
|
||||||
|
await page.waitForURL(`${signUrl}/complete`);
|
||||||
|
|
||||||
|
// Verify document and recipient states
|
||||||
|
await expect(async () => {
|
||||||
|
const updatedDocument = await prisma.document.findUniqueOrThrow({
|
||||||
|
where: { id: document.id },
|
||||||
|
include: {
|
||||||
|
recipients: {
|
||||||
|
orderBy: { signingOrder: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Document should still be pending as there are more recipients
|
||||||
|
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
|
||||||
|
|
||||||
|
// Assistant should be completed
|
||||||
|
const updatedAssistant = updatedDocument.recipients[0];
|
||||||
|
expect(updatedAssistant.signingStatus).toBe(SigningStatus.SIGNED);
|
||||||
|
expect(updatedAssistant.role).toBe(RecipientRole.ASSISTANT);
|
||||||
|
|
||||||
|
// Second recipient should be the new signer
|
||||||
|
const updatedSigner = updatedDocument.recipients[1];
|
||||||
|
expect(updatedSigner.name).toBe('New Signer');
|
||||||
|
expect(updatedSigner.email).toBe('new.signer@example.com');
|
||||||
|
expect(updatedSigner.signingOrder).toBe(2);
|
||||||
|
expect(updatedSigner.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||||
|
expect(updatedSigner.role).toBe(RecipientRole.SIGNER);
|
||||||
|
|
||||||
|
// Third recipient should remain unchanged
|
||||||
|
const thirdRecipient = updatedDocument.recipients[2];
|
||||||
|
expect(thirdRecipient.email).toBe(thirdSigner.email);
|
||||||
|
expect(thirdRecipient.signingOrder).toBe(3);
|
||||||
|
expect(thirdRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||||
|
expect(thirdRecipient.role).toBe(RecipientRole.SIGNER);
|
||||||
|
}).toPass();
|
||||||
|
});
|
||||||
@ -56,6 +56,7 @@ test('[PUBLIC_PROFILE]: create profile', async ({ page }) => {
|
|||||||
// Go back to public profile page.
|
// Go back to public profile page.
|
||||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/settings/public-profile`);
|
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/settings/public-profile`);
|
||||||
await page.getByRole('switch').click();
|
await page.getByRole('switch').click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
// Assert values.
|
// Assert values.
|
||||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
|
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
|
||||||
@ -127,6 +128,7 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
|
|||||||
// Go back to public profile page.
|
// Go back to public profile page.
|
||||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/settings/public-profile`);
|
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/settings/public-profile`);
|
||||||
await page.getByRole('switch').click();
|
await page.getByRole('switch').click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
// Assert values.
|
// Assert values.
|
||||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
|
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
|
||||||
|
|||||||
@ -24,6 +24,7 @@ export type CreateDocumentMetaOptions = {
|
|||||||
redirectUrl?: string;
|
redirectUrl?: string;
|
||||||
emailSettings?: TDocumentEmailSettings;
|
emailSettings?: TDocumentEmailSettings;
|
||||||
signingOrder?: DocumentSigningOrder;
|
signingOrder?: DocumentSigningOrder;
|
||||||
|
allowDictateNextSigner?: boolean;
|
||||||
distributionMethod?: DocumentDistributionMethod;
|
distributionMethod?: DocumentDistributionMethod;
|
||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
language?: SupportedLanguageCodes;
|
language?: SupportedLanguageCodes;
|
||||||
@ -41,6 +42,7 @@ export const upsertDocumentMeta = async ({
|
|||||||
password,
|
password,
|
||||||
redirectUrl,
|
redirectUrl,
|
||||||
signingOrder,
|
signingOrder,
|
||||||
|
allowDictateNextSigner,
|
||||||
emailSettings,
|
emailSettings,
|
||||||
distributionMethod,
|
distributionMethod,
|
||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
@ -93,6 +95,7 @@ export const upsertDocumentMeta = async ({
|
|||||||
documentId,
|
documentId,
|
||||||
redirectUrl,
|
redirectUrl,
|
||||||
signingOrder,
|
signingOrder,
|
||||||
|
allowDictateNextSigner,
|
||||||
emailSettings,
|
emailSettings,
|
||||||
distributionMethod,
|
distributionMethod,
|
||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
@ -106,6 +109,7 @@ export const upsertDocumentMeta = async ({
|
|||||||
timezone,
|
timezone,
|
||||||
redirectUrl,
|
redirectUrl,
|
||||||
signingOrder,
|
signingOrder,
|
||||||
|
allowDictateNextSigner,
|
||||||
emailSettings,
|
emailSettings,
|
||||||
distributionMethod,
|
distributionMethod,
|
||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
|
|||||||
@ -7,7 +7,10 @@ import {
|
|||||||
WebhookTriggerEvents,
|
WebhookTriggerEvents,
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
|
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
import {
|
||||||
|
DOCUMENT_AUDIT_LOG_TYPE,
|
||||||
|
RECIPIENT_DIFF_TYPE,
|
||||||
|
} from '@documenso/lib/types/document-audit-logs';
|
||||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||||
@ -30,6 +33,10 @@ export type CompleteDocumentWithTokenOptions = {
|
|||||||
userId?: number;
|
userId?: number;
|
||||||
authOptions?: TRecipientActionAuth;
|
authOptions?: TRecipientActionAuth;
|
||||||
requestMetadata?: RequestMetadata;
|
requestMetadata?: RequestMetadata;
|
||||||
|
nextSigner?: {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => {
|
const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => {
|
||||||
@ -57,6 +64,7 @@ export const completeDocumentWithToken = async ({
|
|||||||
token,
|
token,
|
||||||
documentId,
|
documentId,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
|
nextSigner,
|
||||||
}: CompleteDocumentWithTokenOptions) => {
|
}: CompleteDocumentWithTokenOptions) => {
|
||||||
const document = await getDocument({ token, documentId });
|
const document = await getDocument({ token, documentId });
|
||||||
|
|
||||||
@ -146,7 +154,6 @@ export const completeDocumentWithToken = async ({
|
|||||||
recipientName: recipient.name,
|
recipientName: recipient.name,
|
||||||
recipientId: recipient.id,
|
recipientId: recipient.id,
|
||||||
recipientRole: recipient.role,
|
recipientRole: recipient.role,
|
||||||
// actionAuth: derivedRecipientActionAuth || undefined,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@ -164,6 +171,9 @@ export const completeDocumentWithToken = async ({
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
signingOrder: true,
|
signingOrder: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
@ -186,9 +196,49 @@ export const completeDocumentWithToken = async ({
|
|||||||
const [nextRecipient] = pendingRecipients;
|
const [nextRecipient] = pendingRecipients;
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
|
if (nextSigner && document.documentMeta?.allowDictateNextSigner) {
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
||||||
|
documentId: document.id,
|
||||||
|
user: {
|
||||||
|
name: recipient.name,
|
||||||
|
email: recipient.email,
|
||||||
|
},
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
recipientEmail: nextRecipient.email,
|
||||||
|
recipientName: nextRecipient.name,
|
||||||
|
recipientId: nextRecipient.id,
|
||||||
|
recipientRole: nextRecipient.role,
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
type: RECIPIENT_DIFF_TYPE.NAME,
|
||||||
|
from: nextRecipient.name,
|
||||||
|
to: nextSigner.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: RECIPIENT_DIFF_TYPE.EMAIL,
|
||||||
|
from: nextRecipient.email,
|
||||||
|
to: nextSigner.email,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await tx.recipient.update({
|
await tx.recipient.update({
|
||||||
where: { id: nextRecipient.id },
|
where: { id: nextRecipient.id },
|
||||||
data: { sendStatus: SendStatus.SENT },
|
data: {
|
||||||
|
sendStatus: SendStatus.SENT,
|
||||||
|
...(nextSigner && document.documentMeta?.allowDictateNextSigner
|
||||||
|
? {
|
||||||
|
name: nextSigner.name,
|
||||||
|
email: nextSigner.email,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await jobs.triggerJob({
|
await jobs.triggerJob({
|
||||||
|
|||||||
@ -0,0 +1,37 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export const getNextPendingRecipient = async ({
|
||||||
|
documentId,
|
||||||
|
currentRecipientId,
|
||||||
|
}: {
|
||||||
|
documentId: number;
|
||||||
|
currentRecipientId: number;
|
||||||
|
}) => {
|
||||||
|
const recipients = await prisma.recipient.findMany({
|
||||||
|
where: {
|
||||||
|
documentId,
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{
|
||||||
|
signingOrder: {
|
||||||
|
sort: 'asc',
|
||||||
|
nulls: 'last',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'asc',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentIndex = recipients.findIndex((r) => r.id === currentRecipientId);
|
||||||
|
|
||||||
|
if (currentIndex === -1 || currentIndex === recipients.length - 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...recipients[currentIndex + 1],
|
||||||
|
token: '',
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -83,6 +83,7 @@ export type CreateDocumentFromTemplateOptions = {
|
|||||||
language?: SupportedLanguageCodes;
|
language?: SupportedLanguageCodes;
|
||||||
distributionMethod?: DocumentDistributionMethod;
|
distributionMethod?: DocumentDistributionMethod;
|
||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
|
allowDictateNextSigner?: boolean;
|
||||||
emailSettings?: TDocumentEmailSettings;
|
emailSettings?: TDocumentEmailSettings;
|
||||||
};
|
};
|
||||||
requestMetadata: ApiRequestMetadata;
|
requestMetadata: ApiRequestMetadata;
|
||||||
@ -404,6 +405,10 @@ export const createDocumentFromTemplate = async ({
|
|||||||
template.team?.teamGlobalSettings?.documentLanguage,
|
template.team?.teamGlobalSettings?.documentLanguage,
|
||||||
typedSignatureEnabled:
|
typedSignatureEnabled:
|
||||||
override?.typedSignatureEnabled ?? template.templateMeta?.typedSignatureEnabled,
|
override?.typedSignatureEnabled ?? template.templateMeta?.typedSignatureEnabled,
|
||||||
|
allowDictateNextSigner:
|
||||||
|
override?.allowDictateNextSigner ??
|
||||||
|
template.templateMeta?.allowDictateNextSigner ??
|
||||||
|
false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
recipients: {
|
recipients: {
|
||||||
|
|||||||
@ -51,6 +51,7 @@ export const ZDocumentSchema = DocumentSchema.pick({
|
|||||||
documentId: true,
|
documentId: true,
|
||||||
redirectUrl: true,
|
redirectUrl: true,
|
||||||
typedSignatureEnabled: true,
|
typedSignatureEnabled: true,
|
||||||
|
allowDictateNextSigner: true,
|
||||||
language: true,
|
language: true,
|
||||||
emailSettings: true,
|
emailSettings: true,
|
||||||
}).nullable(),
|
}).nullable(),
|
||||||
|
|||||||
@ -45,6 +45,7 @@ export const ZTemplateSchema = TemplateSchema.pick({
|
|||||||
dateFormat: true,
|
dateFormat: true,
|
||||||
signingOrder: true,
|
signingOrder: true,
|
||||||
typedSignatureEnabled: true,
|
typedSignatureEnabled: true,
|
||||||
|
allowDictateNextSigner: true,
|
||||||
distributionMethod: true,
|
distributionMethod: true,
|
||||||
templateId: true,
|
templateId: true,
|
||||||
redirectUrl: true,
|
redirectUrl: true,
|
||||||
|
|||||||
@ -46,6 +46,7 @@ export const ZWebhookDocumentMetaSchema = z.object({
|
|||||||
dateFormat: z.string(),
|
dateFormat: z.string(),
|
||||||
redirectUrl: z.string().nullable(),
|
redirectUrl: z.string().nullable(),
|
||||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||||
|
allowDictateNextSigner: z.boolean(),
|
||||||
typedSignatureEnabled: z.boolean(),
|
typedSignatureEnabled: z.boolean(),
|
||||||
language: z.string(),
|
language: z.string(),
|
||||||
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
|
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DocumentMeta" ADD COLUMN "allowDictateNextSigner" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "TemplateMeta" ADD COLUMN "allowDictateNextSigner" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@ -390,20 +390,21 @@ enum DocumentDistributionMethod {
|
|||||||
|
|
||||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';"])
|
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';"])
|
||||||
model DocumentMeta {
|
model DocumentMeta {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
subject String?
|
subject String?
|
||||||
message String?
|
message String?
|
||||||
timezone String? @default("Etc/UTC") @db.Text
|
timezone String? @default("Etc/UTC") @db.Text
|
||||||
password String?
|
password String?
|
||||||
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
|
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
|
||||||
documentId Int @unique
|
documentId Int @unique
|
||||||
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||||
redirectUrl String?
|
redirectUrl String?
|
||||||
signingOrder DocumentSigningOrder @default(PARALLEL)
|
signingOrder DocumentSigningOrder @default(PARALLEL)
|
||||||
typedSignatureEnabled Boolean @default(true)
|
allowDictateNextSigner Boolean @default(false)
|
||||||
language String @default("en")
|
typedSignatureEnabled Boolean @default(true)
|
||||||
distributionMethod DocumentDistributionMethod @default(EMAIL)
|
language String @default("en")
|
||||||
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
|
distributionMethod DocumentDistributionMethod @default(EMAIL)
|
||||||
|
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ReadStatus {
|
enum ReadStatus {
|
||||||
@ -660,15 +661,16 @@ enum TemplateType {
|
|||||||
|
|
||||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';"])
|
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';"])
|
||||||
model TemplateMeta {
|
model TemplateMeta {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
subject String?
|
subject String?
|
||||||
message String?
|
message String?
|
||||||
timezone String? @default("Etc/UTC") @db.Text
|
timezone String? @default("Etc/UTC") @db.Text
|
||||||
password String?
|
password String?
|
||||||
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
|
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
|
||||||
signingOrder DocumentSigningOrder? @default(PARALLEL)
|
signingOrder DocumentSigningOrder? @default(PARALLEL)
|
||||||
typedSignatureEnabled Boolean @default(true)
|
allowDictateNextSigner Boolean @default(false)
|
||||||
distributionMethod DocumentDistributionMethod @default(EMAIL)
|
typedSignatureEnabled Boolean @default(true)
|
||||||
|
distributionMethod DocumentDistributionMethod @default(EMAIL)
|
||||||
|
|
||||||
templateId Int @unique
|
templateId Int @unique
|
||||||
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@ -371,6 +371,7 @@ export const documentRouter = router({
|
|||||||
redirectUrl: meta.redirectUrl,
|
redirectUrl: meta.redirectUrl,
|
||||||
distributionMethod: meta.distributionMethod,
|
distributionMethod: meta.distributionMethod,
|
||||||
signingOrder: meta.signingOrder,
|
signingOrder: meta.signingOrder,
|
||||||
|
allowDictateNextSigner: meta.allowDictateNextSigner,
|
||||||
emailSettings: meta.emailSettings,
|
emailSettings: meta.emailSettings,
|
||||||
requestMetadata: ctx.metadata,
|
requestMetadata: ctx.metadata,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -268,6 +268,7 @@ export const ZUpdateDocumentRequestSchema = z.object({
|
|||||||
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
||||||
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
|
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
|
||||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||||
|
allowDictateNextSigner: z.boolean().optional(),
|
||||||
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
|
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
|
||||||
language: ZDocumentMetaLanguageSchema.optional(),
|
language: ZDocumentMetaLanguageSchema.optional(),
|
||||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||||
|
|||||||
@ -436,12 +436,13 @@ export const recipientRouter = router({
|
|||||||
completeDocumentWithToken: procedure
|
completeDocumentWithToken: procedure
|
||||||
.input(ZCompleteDocumentWithTokenMutationSchema)
|
.input(ZCompleteDocumentWithTokenMutationSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { token, documentId, authOptions } = input;
|
const { token, documentId, authOptions, nextSigner } = input;
|
||||||
|
|
||||||
return await completeDocumentWithToken({
|
return await completeDocumentWithToken({
|
||||||
token,
|
token,
|
||||||
documentId,
|
documentId,
|
||||||
authOptions,
|
authOptions,
|
||||||
|
nextSigner,
|
||||||
userId: ctx.user?.id,
|
userId: ctx.user?.id,
|
||||||
requestMetadata: ctx.metadata.requestMetadata,
|
requestMetadata: ctx.metadata.requestMetadata,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -212,6 +212,12 @@ export const ZCompleteDocumentWithTokenMutationSchema = z.object({
|
|||||||
token: z.string(),
|
token: z.string(),
|
||||||
documentId: z.number(),
|
documentId: z.number(),
|
||||||
authOptions: ZRecipientActionAuthSchema.optional(),
|
authOptions: ZRecipientActionAuthSchema.optional(),
|
||||||
|
nextSigner: z
|
||||||
|
.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
name: z.string().min(1),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TCompleteDocumentWithTokenMutationSchema = z.infer<
|
export type TCompleteDocumentWithTokenMutationSchema = z.infer<
|
||||||
|
|||||||
@ -165,6 +165,7 @@ export const ZUpdateTemplateRequestSchema = z.object({
|
|||||||
language: ZDocumentMetaLanguageSchema.optional(),
|
language: ZDocumentMetaLanguageSchema.optional(),
|
||||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||||
|
allowDictateNextSigner: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { Trans } from '@lingui/react/macro';
|
|||||||
import type { Field, Recipient } from '@prisma/client';
|
import type { Field, Recipient } from '@prisma/client';
|
||||||
import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client';
|
import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { GripVerticalIcon, Plus, Trash } from 'lucide-react';
|
import { GripVerticalIcon, HelpCircle, Plus, Trash } from 'lucide-react';
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
import { prop, sortBy } from 'remeda';
|
import { prop, sortBy } from 'remeda';
|
||||||
|
|
||||||
@ -29,6 +29,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '
|
|||||||
import { FormErrorMessage } from '../form/form-error-message';
|
import { FormErrorMessage } from '../form/form-error-message';
|
||||||
import { Input } from '../input';
|
import { Input } from '../input';
|
||||||
import { useStep } from '../stepper';
|
import { useStep } from '../stepper';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
|
||||||
import { useToast } from '../use-toast';
|
import { useToast } from '../use-toast';
|
||||||
import type { TAddSignersFormSchema } from './add-signers.types';
|
import type { TAddSignersFormSchema } from './add-signers.types';
|
||||||
import { ZAddSignersFormSchema } from './add-signers.types';
|
import { ZAddSignersFormSchema } from './add-signers.types';
|
||||||
@ -48,6 +49,7 @@ export type AddSignersFormProps = {
|
|||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
signingOrder?: DocumentSigningOrder | null;
|
signingOrder?: DocumentSigningOrder | null;
|
||||||
|
allowDictateNextSigner?: boolean;
|
||||||
isDocumentEnterprise: boolean;
|
isDocumentEnterprise: boolean;
|
||||||
onSubmit: (_data: TAddSignersFormSchema) => void;
|
onSubmit: (_data: TAddSignersFormSchema) => void;
|
||||||
isDocumentPdfLoaded: boolean;
|
isDocumentPdfLoaded: boolean;
|
||||||
@ -58,6 +60,7 @@ export const AddSignersFormPartial = ({
|
|||||||
recipients,
|
recipients,
|
||||||
fields,
|
fields,
|
||||||
signingOrder,
|
signingOrder,
|
||||||
|
allowDictateNextSigner,
|
||||||
isDocumentEnterprise,
|
isDocumentEnterprise,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isDocumentPdfLoaded,
|
isDocumentPdfLoaded,
|
||||||
@ -104,6 +107,7 @@ export const AddSignersFormPartial = ({
|
|||||||
)
|
)
|
||||||
: defaultRecipients,
|
: defaultRecipients,
|
||||||
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
|
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
|
||||||
|
allowDictateNextSigner: allowDictateNextSigner ?? false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -354,6 +358,7 @@ export const AddSignersFormPartial = ({
|
|||||||
|
|
||||||
form.setValue('signers', updatedSigners);
|
form.setValue('signers', updatedSigners);
|
||||||
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL);
|
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL);
|
||||||
|
form.setValue('allowDictateNextSigner', false);
|
||||||
}, [form]);
|
}, [form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -389,6 +394,11 @@ export const AddSignersFormPartial = ({
|
|||||||
field.onChange(
|
field.onChange(
|
||||||
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
|
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If sequential signing is turned off, disable dictate next signer
|
||||||
|
if (!checked) {
|
||||||
|
form.setValue('allowDictateNextSigner', false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isSubmitting || hasDocumentBeenSent}
|
disabled={isSubmitting || hasDocumentBeenSent}
|
||||||
/>
|
/>
|
||||||
@ -403,6 +413,50 @@ export const AddSignersFormPartial = ({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="allowDictateNextSigner"
|
||||||
|
render={({ field: { value, ...field } }) => (
|
||||||
|
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
{...field}
|
||||||
|
id="allowDictateNextSigner"
|
||||||
|
checked={value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
disabled={isSubmitting || hasDocumentBeenSent || !isSigningOrderSequential}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<FormLabel
|
||||||
|
htmlFor="allowDictateNextSigner"
|
||||||
|
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
<Trans>Allow signers to dictate next signer</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>
|
||||||
|
When enabled, signers can choose who should sign next in the sequence
|
||||||
|
instead of following the predefined order.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<DragDropContext
|
<DragDropContext
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
sensors={[
|
sensors={[
|
||||||
|
|||||||
@ -25,6 +25,7 @@ export const ZAddSignersFormSchema = z
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||||
|
allowDictateNextSigner: z.boolean().default(false),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(schema) => {
|
(schema) => {
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { Trans } from '@lingui/react/macro';
|
|||||||
import type { TemplateDirectLink } from '@prisma/client';
|
import type { TemplateDirectLink } from '@prisma/client';
|
||||||
import { DocumentSigningOrder, type Field, type Recipient, RecipientRole } from '@prisma/client';
|
import { DocumentSigningOrder, type Field, type Recipient, RecipientRole } from '@prisma/client';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { GripVerticalIcon, 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 { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
@ -47,10 +47,11 @@ export type AddTemplatePlaceholderRecipientsFormProps = {
|
|||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
signingOrder?: DocumentSigningOrder | null;
|
signingOrder?: DocumentSigningOrder | null;
|
||||||
templateDirectLink: TemplateDirectLink | null;
|
allowDictateNextSigner?: boolean;
|
||||||
|
templateDirectLink?: TemplateDirectLink | null;
|
||||||
isEnterprise: boolean;
|
isEnterprise: boolean;
|
||||||
isDocumentPdfLoaded: boolean;
|
|
||||||
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
|
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
|
||||||
|
isDocumentPdfLoaded: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||||
@ -60,6 +61,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
templateDirectLink,
|
templateDirectLink,
|
||||||
fields,
|
fields,
|
||||||
signingOrder,
|
signingOrder,
|
||||||
|
allowDictateNextSigner,
|
||||||
isDocumentPdfLoaded,
|
isDocumentPdfLoaded,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: AddTemplatePlaceholderRecipientsFormProps) => {
|
}: AddTemplatePlaceholderRecipientsFormProps) => {
|
||||||
@ -112,6 +114,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
signers: generateDefaultFormSigners(),
|
signers: generateDefaultFormSigners(),
|
||||||
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
|
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
|
||||||
|
allowDictateNextSigner: allowDictateNextSigner ?? false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -119,6 +122,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
form.reset({
|
form.reset({
|
||||||
signers: generateDefaultFormSigners(),
|
signers: generateDefaultFormSigners(),
|
||||||
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
|
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
|
||||||
|
allowDictateNextSigner: allowDictateNextSigner ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@ -377,6 +381,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
|
|
||||||
form.setValue('signers', updatedSigners);
|
form.setValue('signers', updatedSigners);
|
||||||
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL);
|
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL);
|
||||||
|
form.setValue('allowDictateNextSigner', false);
|
||||||
}, [form]);
|
}, [form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -416,6 +421,11 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
field.onChange(
|
field.onChange(
|
||||||
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
|
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If sequential signing is turned off, disable dictate next signer
|
||||||
|
if (!checked) {
|
||||||
|
form.setValue('allowDictateNextSigner', false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
@ -431,6 +441,49 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="allowDictateNextSigner"
|
||||||
|
render={({ field: { value, ...field } }) => (
|
||||||
|
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
{...field}
|
||||||
|
id="allowDictateNextSigner"
|
||||||
|
checked={value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
disabled={isSubmitting || !isSigningOrderSequential}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<FormLabel
|
||||||
|
htmlFor="allowDictateNextSigner"
|
||||||
|
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
<Trans>Allow signers to dictate next signer</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>
|
||||||
|
When enabled, signers can choose who should sign next in the sequence
|
||||||
|
instead of following the predefined order.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Drag and drop context */}
|
{/* Drag and drop context */}
|
||||||
<DragDropContext
|
<DragDropContext
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||||
|
allowDictateNextSigner: z.boolean().default(false),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(schema) => {
|
(schema) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user