'use client'; import React, { useCallback, useId, useMemo, useRef, useState } from 'react'; import type { DropResult, SensorAPI } from '@hello-pangea/dnd'; import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'; import { zodResolver } from '@hookform/resolvers/zod'; import { Trans, msg } from '@lingui/macro'; import { useLingui } from '@lingui/react'; import { motion } from 'framer-motion'; import { GripVerticalIcon, Plus, Trash } from 'lucide-react'; import { useSession } from 'next-auth/react'; import { useFieldArray, useForm } from 'react-hook-form'; import { prop, sortBy } from 'remeda'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import { nanoid } from '@documenso/lib/universal/id'; import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients'; import type { Field, Recipient } from '@documenso/prisma/client'; import { DocumentSigningOrder, RecipientRole, SendStatus } from '@documenso/prisma/client'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select'; import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '../button'; import { Checkbox } from '../checkbox'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form'; import { FormErrorMessage } from '../form/form-error-message'; import { Input } from '../input'; import { useStep } from '../stepper'; import { useToast } from '../use-toast'; import type { TAddSignersFormSchema } from './add-signers.types'; import { ZAddSignersFormSchema } from './add-signers.types'; import { DocumentFlowFormContainerActions, DocumentFlowFormContainerContent, DocumentFlowFormContainerFooter, DocumentFlowFormContainerHeader, DocumentFlowFormContainerStep, } from './document-flow-root'; import { ShowFieldItem } from './show-field-item'; import { SigningOrderConfirmation } from './signing-order-confirmation'; import type { DocumentFlowStep } from './types'; export type AddSignersFormProps = { documentFlow: DocumentFlowStep; recipients: Recipient[]; fields: Field[]; signingOrder?: DocumentSigningOrder | null; isDocumentEnterprise: boolean; onSubmit: (_data: TAddSignersFormSchema) => void; isDocumentPdfLoaded: boolean; }; export const AddSignersFormPartial = ({ documentFlow, recipients, fields, signingOrder, isDocumentEnterprise, onSubmit, isDocumentPdfLoaded, }: AddSignersFormProps) => { const { _ } = useLingui(); const { toast } = useToast(); const { remaining } = useLimits(); const { data: session } = useSession(); const user = session?.user; const initialId = useId(); const $sensorApi = useRef(null); const { currentStep, totalSteps, previousStep } = useStep(); const defaultRecipients = [ { formId: initialId, name: '', email: '', role: RecipientRole.SIGNER, signingOrder: 1, actionAuth: undefined, }, ]; const form = useForm({ resolver: zodResolver(ZAddSignersFormSchema), defaultValues: { signers: recipients.length > 0 ? sortBy( recipients.map((recipient, index) => ({ nativeId: recipient.id, formId: String(recipient.id), name: recipient.name, email: recipient.email, role: recipient.role, signingOrder: recipient.signingOrder ?? index + 1, actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined, })), [prop('signingOrder'), 'asc'], [prop('nativeId'), 'asc'], ) : defaultRecipients, signingOrder: signingOrder || DocumentSigningOrder.PARALLEL, }, }); // Always show advanced settings if any recipient has auth options. const alwaysShowAdvancedSettings = useMemo(() => { const recipientHasAuthOptions = recipients.find((recipient) => { const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); return recipientAuthOptions?.accessAuth || recipientAuthOptions?.actionAuth; }); const formHasActionAuth = form.getValues('signers').find((signer) => signer.actionAuth); return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined; }, [recipients, form]); const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings); const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false); const { setValue, formState: { errors, isSubmitting }, control, watch, } = form; const watchedSigners = watch('signers'); const isSigningOrderSequential = watch('signingOrder') === DocumentSigningOrder.SEQUENTIAL; const hasAssistantRole = useMemo(() => { return watchedSigners.some((signer) => signer.role === RecipientRole.ASSISTANT); }, [watchedSigners]); const normalizeSigningOrders = (signers: typeof watchedSigners) => { return signers .sort((a, b) => (a.signingOrder ?? 0) - (b.signingOrder ?? 0)) .map((signer, index) => ({ ...signer, signingOrder: index + 1 })); }; const onFormSubmit = form.handleSubmit(onSubmit); const { append: appendSigner, fields: signers, remove: removeSigner, } = useFieldArray({ control, name: 'signers', }); const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email); const isUserAlreadyARecipient = watchedSigners.some( (signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(), ); const hasDocumentBeenSent = recipients.some( (recipient) => recipient.sendStatus === SendStatus.SENT, ); const canRecipientBeModified = (recipientId?: number) => { if (recipientId === undefined) { return true; } const recipient = recipients.find((recipient) => recipient.id === recipientId); if (!recipient) { return false; } return utilCanRecipientBeModified(recipient, fields); }; const onAddSigner = () => { appendSigner({ formId: nanoid(12), name: '', email: '', role: RecipientRole.SIGNER, actionAuth: undefined, signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1, }); }; const onRemoveSigner = (index: number) => { const signer = signers[index]; if (!canRecipientBeModified(signer.nativeId)) { toast({ title: _(msg`Cannot remove signer`), description: _(msg`This signer has already signed the document.`), variant: 'destructive', }); return; } removeSigner(index); const updatedSigners = signers.filter((_, idx) => idx !== index); form.setValue('signers', normalizeSigningOrders(updatedSigners)); }; const onAddSelfSigner = () => { if (emptySignerIndex !== -1) { setValue(`signers.${emptySignerIndex}.name`, user?.name ?? ''); setValue(`signers.${emptySignerIndex}.email`, user?.email ?? ''); } else { appendSigner({ formId: nanoid(12), name: user?.name ?? '', email: user?.email ?? '', role: RecipientRole.SIGNER, actionAuth: undefined, signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1, }); } }; const onKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter' && event.target instanceof HTMLInputElement) { onAddSigner(); } }; const onDragEnd = useCallback( async (result: DropResult) => { if (!result.destination) return; const items = Array.from(watchedSigners); const [reorderedSigner] = items.splice(result.source.index, 1); // Find next valid position let insertIndex = result.destination.index; while (insertIndex < items.length && !canRecipientBeModified(items[insertIndex].nativeId)) { insertIndex++; } items.splice(insertIndex, 0, reorderedSigner); const updatedSigners = items.map((signer, index) => ({ ...signer, signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : index + 1, })); form.setValue('signers', updatedSigners); const lastSigner = updatedSigners[updatedSigners.length - 1]; if (lastSigner.role === RecipientRole.ASSISTANT) { toast({ title: _(msg`Warning: Assistant as last signer`), description: _( msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`, ), }); } await form.trigger('signers'); }, [form, canRecipientBeModified, watchedSigners, toast], ); const handleRoleChange = useCallback( (index: number, role: RecipientRole) => { const currentSigners = form.getValues('signers'); const signingOrder = form.getValues('signingOrder'); // Handle parallel to sequential conversion for assistants if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) { form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL); toast({ title: _(msg`Signing order is enabled.`), description: _(msg`You cannot add assistants when signing order is disabled.`), variant: 'destructive', }); return; } const updatedSigners = currentSigners.map((signer, idx) => ({ ...signer, role: idx === index ? role : signer.role, signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : idx + 1, })); form.setValue('signers', updatedSigners); if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) { toast({ title: _(msg`Warning: Assistant as last signer`), description: _( msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`, ), }); } }, [form, toast, canRecipientBeModified], ); const handleSigningOrderChange = useCallback( (index: number, newOrderString: string) => { const trimmedOrderString = newOrderString.trim(); if (!trimmedOrderString) { return; } const newOrder = Number(trimmedOrderString); if (!Number.isInteger(newOrder) || newOrder < 1) { return; } const currentSigners = form.getValues('signers'); const signer = currentSigners[index]; // Remove signer from current position and insert at new position const remainingSigners = currentSigners.filter((_, idx) => idx !== index); const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1); remainingSigners.splice(newPosition, 0, signer); const updatedSigners = remainingSigners.map((s, idx) => ({ ...s, signingOrder: !canRecipientBeModified(s.nativeId) ? s.signingOrder : idx + 1, })); form.setValue('signers', updatedSigners); if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) { toast({ title: _(msg`Warning: Assistant as last signer`), description: _( msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`, ), }); } }, [form, canRecipientBeModified, toast], ); const handleSigningOrderDisable = useCallback(() => { setShowSigningOrderConfirmation(false); const currentSigners = form.getValues('signers'); const updatedSigners = currentSigners.map((signer) => ({ ...signer, role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role, })); form.setValue('signers', updatedSigners); form.setValue('signingOrder', DocumentSigningOrder.PARALLEL); }, [form]); return ( <> {isDocumentPdfLoaded && fields.map((field, index) => ( ))}
( { if (!checked && hasAssistantRole) { setShowSigningOrderConfirmation(true); return; } field.onChange( checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL, ); }} disabled={isSubmitting || hasDocumentBeenSent} /> Enable signing order )} /> { $sensorApi.current = api; }, ]} > {(provided) => (
{signers.map((signer, index) => ( {(provided, snapshot) => (
{isSigningOrderSequential && ( ( { field.onChange(e); handleSigningOrderChange(index, e.target.value); }} onBlur={(e) => { field.onBlur(); handleSigningOrderChange(index, e.target.value); }} disabled={ snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.nativeId) } /> )} /> )} ( {!showAdvancedSettings && ( Email )} )} /> ( {!showAdvancedSettings && ( Name )} )} /> {showAdvancedSettings && isDocumentEnterprise && ( ( )} /> )}
( // eslint-disable-next-line @typescript-eslint/consistent-type-assertions handleRoleChange(index, value as RecipientRole) } disabled={ snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.nativeId) } /> )} />
)}
))} {provided.placeholder}
)}
{!alwaysShowAdvancedSettings && isDocumentEnterprise && (
setShowAdvancedSettings(Boolean(value))} />
)}
void onFormSubmit()} /> ); };