fix: duplicate webhook calls on document complete (#1721)

Fix webhooks being sent twice due to duplicate frontend calls

Updated the assistant confirmation dialog so the next signer is always
visible (if dictate is enabled). Because if the form is invalid (due to
no name) there is no visual queue that the form is invalid (since it's
hidden)

## Notes

Didn't bother to remove the weird assistants form since it currently
works for now


![image](https://github.com/user-attachments/assets/47910fec-e05e-486a-a61d-16078d948893)

## Tests

- Currently running locally
- Tested webhooks via network tab and via webhook.site
This commit is contained in:
David Nguyen
2025-03-25 21:59:13 +11:00
committed by GitHub
parent 063fd32f18
commit 31be548939
12 changed files with 76 additions and 189 deletions

View File

@ -1,5 +1,3 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
@ -57,8 +55,6 @@ export function AssistantConfirmationDialog({
allowDictateNextSigner = false,
defaultNextSigner,
}: ConfirmationDialogProps) {
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
const form = useForm<TNextSignerFormSchema>({
resolver: zodResolver(ZNextSignerFormSchema),
defaultValues: {
@ -68,7 +64,7 @@ export function AssistantConfirmationDialog({
});
const onOpenChange = () => {
if (form.formState.isSubmitting) {
if (isSubmitting) {
return;
}
@ -77,32 +73,25 @@ export function AssistantConfirmationDialog({
email: defaultNextSigner?.email ?? '',
});
setIsEditingNextSigner(false);
onClose();
};
const onFormSubmit = async (data: TNextSignerFormSchema) => {
if (allowDictateNextSigner && data.name && data.email) {
await onConfirm({
name: data.name,
email: data.email,
});
} else {
await onConfirm();
const handleSubmit = () => {
// Validate the form and submit it if dictate signer is enabled.
if (allowDictateNextSigner) {
void form.handleSubmit(onConfirm)();
return;
}
};
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
onConfirm();
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
disabled={form.formState.isSubmitting || isSubmitting}
className="border-none p-0"
>
<form>
<fieldset disabled={isSubmitting} className="border-none p-0">
<DialogHeader>
<DialogTitle>
<Trans>Complete Document</Trans>
@ -118,71 +107,53 @@ export function AssistantConfirmationDialog({
<div className="mt-4 flex flex-col gap-4">
{allowDictateNextSigner && (
<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>
<div className="my-2">
<p className="text-muted-foreground mb-1 text-sm font-semibold">
The next recipient to sign this document will be{' '}
</p>
<Button
type="button"
className="mt-2"
variant="outline"
size="sm"
onClick={() => setIsEditingNextSigner((prev) => !prev)}
>
<Trans>Update Recipient</Trans>
</Button>
</div>
)}
<div className="flex flex-col gap-4 rounded-xl border p-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>
)}
/>
{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>
)}
<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>
)}
@ -190,27 +161,17 @@ export function AssistantConfirmationDialog({
</div>
<DialogFooter className="mt-4">
<Button
type="button"
variant="secondary"
onClick={onClose}
disabled={form.formState.isSubmitting}
>
<Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
type="button"
variant={hasUninsertedFields ? 'destructive' : 'default'}
disabled={form.formState.isSubmitting || !isNextSignerValid}
loading={form.formState.isSubmitting}
disabled={isSubmitting}
onClick={handleSubmit}
loading={isSubmitting}
>
{form.formState.isSubmitting ? (
<Trans>Submitting...</Trans>
) : hasUninsertedFields ? (
<Trans>Proceed</Trans>
) : (
<Trans>Continue</Trans>
)}
{hasUninsertedFields ? <Trans>Proceed</Trans> : <Trans>Continue</Trans>}
</Button>
</DialogFooter>
</fieldset>

View File

@ -1,13 +1,11 @@
import { useId, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
@ -33,13 +31,6 @@ import {
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
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 = {
document: DocumentAndSender;
recipient: Recipient;
@ -76,8 +67,11 @@ export const DocumentSigningForm = ({
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
const { mutateAsync: completeDocumentWithToken } =
trpc.recipient.completeDocumentWithToken.useMutation();
const {
mutateAsync: completeDocumentWithToken,
isPending,
isSuccess,
} = trpc.recipient.completeDocumentWithToken.useMutation();
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
defaultValues: {
@ -85,12 +79,8 @@ export const DocumentSigningForm = ({
},
});
const { handleSubmit, formState } = useForm<TSigningFormSchema>({
resolver: zodResolver(ZSigningFormSchema),
});
// Keep the loading state going if successful since the redirect may take some time.
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
const isSubmitting = isPending || isSuccess;
const fieldsRequiringValidation = useMemo(
() => fields.filter(isFieldUnsignedAndRequired),
@ -112,34 +102,6 @@ export const DocumentSigningForm = ({
validateFieldsInserted(fieldsRequiringValidation);
};
const onFormSubmit = async (data: TSigningFormSchema) => {
try {
setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
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',
});
}
};
const onAssistantFormSubmit = () => {
if (uninsertedRecipientFields.length > 0) {
return;
@ -214,8 +176,6 @@ export const DocumentSigningForm = ({
: undefined;
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
console.log('nextRecipient', nextRecipient);
return (
<div
className={cn(
@ -356,9 +316,8 @@ export const DocumentSigningForm = ({
className="w-full"
size="lg"
loading={isAssistantSubmitting}
disabled={isAssistantSubmitting || uninsertedRecipientFields.length > 0}
>
{isAssistantSubmitting ? <Trans>Submitting...</Trans> : <Trans>Continue</Trans>}
<Trans>Continue</Trans>
</Button>
</div>
@ -381,7 +340,7 @@ export const DocumentSigningForm = ({
</>
) : (
<>
<form onSubmit={handleSubmit(onFormSubmit)}>
<div>
<p className="text-muted-foreground mt-2 text-sm">
{recipient.role === RecipientRole.APPROVER && !hasSignatureField ? (
<Trans>Please review the document before approving.</Trans>
@ -463,7 +422,7 @@ export const DocumentSigningForm = ({
}
/>
</div>
</form>
</div>
</>
)}
</div>