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>

View File

@ -341,9 +341,6 @@ test('[NEXT_RECIPIENT_DICTATION]: should allow assistant to dictate next signer'
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');

View File

@ -148,6 +148,10 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
await page.getByRole('button', { name: 'Update' }).first().click();
// Wait for finish
await page.getByText('Document preferences updated').waitFor({ state: 'visible' });
await page.getByTestId('toast-close').click();
const template = await seedTeamTemplateWithMeta(team);
await page.goto(`/t/${team.url}/templates/${template.id}`);

View File

@ -5049,11 +5049,6 @@ msgstr "Schritt <0>{step} von {maxStep}</0>"
msgid "Subject <0>(Optional)</0>"
msgstr "Betreff <0>(Optional)</0>"
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
msgid "Submitting..."
msgstr ""
#: apps/remix/app/components/general/billing-plans.tsx
msgid "Subscribe"
msgstr ""
@ -6121,7 +6116,6 @@ msgstr "Profil aktualisieren"
#: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
msgid "Update Recipient"
msgstr "Empfänger aktualisieren"

View File

@ -5044,11 +5044,6 @@ msgstr "Step <0>{step} of {maxStep}</0>"
msgid "Subject <0>(Optional)</0>"
msgstr "Subject <0>(Optional)</0>"
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
msgid "Submitting..."
msgstr "Submitting..."
#: apps/remix/app/components/general/billing-plans.tsx
msgid "Subscribe"
msgstr "Subscribe"
@ -6118,7 +6113,6 @@ msgstr "Update profile"
#: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
msgid "Update Recipient"
msgstr "Update Recipient"

View File

@ -5049,11 +5049,6 @@ msgstr "Paso <0>{step} de {maxStep}</0>"
msgid "Subject <0>(Optional)</0>"
msgstr "Asunto <0>(Opcional)</0>"
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
msgid "Submitting..."
msgstr ""
#: apps/remix/app/components/general/billing-plans.tsx
msgid "Subscribe"
msgstr ""
@ -6121,7 +6116,6 @@ msgstr "Actualizar perfil"
#: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
msgid "Update Recipient"
msgstr "Actualizar destinatario"

View File

@ -5049,11 +5049,6 @@ msgstr "Étape <0>{step} sur {maxStep}</0>"
msgid "Subject <0>(Optional)</0>"
msgstr "Objet <0>(Optionnel)</0>"
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
msgid "Submitting..."
msgstr ""
#: apps/remix/app/components/general/billing-plans.tsx
msgid "Subscribe"
msgstr ""
@ -6121,7 +6116,6 @@ msgstr "Mettre à jour le profil"
#: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
msgid "Update Recipient"
msgstr "Mettre à jour le destinataire"

View File

@ -5049,11 +5049,6 @@ msgstr "Passo <0>{step} di {maxStep}</0>"
msgid "Subject <0>(Optional)</0>"
msgstr "Oggetto <0>(Opzionale)</0>"
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
msgid "Submitting..."
msgstr ""
#: apps/remix/app/components/general/billing-plans.tsx
msgid "Subscribe"
msgstr ""
@ -6121,7 +6116,6 @@ msgstr "Aggiorna profilo"
#: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
msgid "Update Recipient"
msgstr "Aggiorna destinatario"

View File

@ -5049,11 +5049,6 @@ msgstr "Krok <0>{step} z {maxStep}</0>"
msgid "Subject <0>(Optional)</0>"
msgstr "Temat <0>(Opcjonalnie)</0>"
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
msgid "Submitting..."
msgstr ""
#: apps/remix/app/components/general/billing-plans.tsx
msgid "Subscribe"
msgstr ""
@ -6121,7 +6116,6 @@ msgstr "Zaktualizuj profil"
#: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
msgid "Update Recipient"
msgstr "Zaktualizuj odbiorcę"

View File

@ -37,6 +37,7 @@ export const updateDocumentRoute = authenticatedProcedure
redirectUrl: meta.redirectUrl,
distributionMethod: meta.distributionMethod,
signingOrder: meta.signingOrder,
allowDictateNextSigner: meta.allowDictateNextSigner,
emailSettings: meta.emailSettings,
requestMetadata: ctx.metadata,
});

View File

@ -78,6 +78,7 @@ const ToastClose = React.forwardRef<
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
data-testid="toast-close"
className={cn(
'text-foreground/50 hover:text-foreground absolute right-2 top-2 rounded-md p-1 opacity-100 transition-opacity focus:opacity-100 focus:outline-none focus:ring-2 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600 md:opacity-0 group-hover:md:opacity-100',
className,