import { 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 { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import type { Field, Recipient } from '@prisma/client'; import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client'; import { motion } from 'framer-motion'; import { GripVerticalIcon, HelpCircle, Plus, Trash } from 'lucide-react'; import { useFieldArray, useForm } from 'react-hook-form'; import { prop, sortBy } from 'remeda'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave'; import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useSession } from '@documenso/lib/client-only/providers/session'; 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 { trpc } from '@documenso/trpc/react'; 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 { DocumentReadOnlyFields, mapFieldsWithRecipients, } from '../../components/document/document-read-only-fields'; import type { RecipientAutoCompleteOption } from '../../components/recipient/recipient-autocomplete-input'; import { RecipientAutoCompleteInput } from '../../components/recipient/recipient-autocomplete-input'; 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 { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip'; 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 { SigningOrderConfirmation } from './signing-order-confirmation'; import type { DocumentFlowStep } from './types'; export type AddSignersFormProps = { documentFlow: DocumentFlowStep; recipients: Recipient[]; fields: Field[]; signingOrder?: DocumentSigningOrder | null; allowDictateNextSigner?: boolean; onSubmit: (_data: TAddSignersFormSchema) => void; onAutoSave: (_data: TAddSignersFormSchema) => Promise; isDocumentPdfLoaded: boolean; }; export const AddSignersFormPartial = ({ documentFlow, recipients, fields, signingOrder, allowDictateNextSigner, onSubmit, onAutoSave, isDocumentPdfLoaded, }: AddSignersFormProps) => { const { _ } = useLingui(); const { toast } = useToast(); const { remaining } = useLimits(); const { user } = useSession(); const [recipientSearchQuery, setRecipientSearchQuery] = useState(''); const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500); const initialId = useId(); const $sensorApi = useRef(null); const { currentStep, totalSteps, previousStep } = useStep(); const organisation = useCurrentOrganisation(); const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery( { query: debouncedRecipientSearchQuery, }, { enabled: debouncedRecipientSearchQuery.length > 1, }, ); const recipientSuggestions = recipientSuggestionsData?.results || []; const defaultRecipients = [ { formId: initialId, name: '', email: '', role: RecipientRole.SIGNER, signingOrder: 1, actionAuth: [], }, ]; 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, allowDictateNextSigner: allowDictateNextSigner ?? false, }, }); // 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.length > 0 || recipientAuthOptions.actionAuth.length > 0 ); }); const formHasActionAuth = form .getValues('signers') .find((signer) => signer.actionAuth.length > 0); 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 emptySigners = useCallback( () => form.getValues('signers').filter((signer) => signer.email === ''), [form], ); const { scheduleSave } = useAutoSave(onAutoSave); const handleAutoSave = async () => { if (emptySigners().length > 0) { return; } const isFormValid = await form.trigger(); if (!isFormValid) { return; } const formData = form.getValues(); scheduleSave(formData); }; const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email); const 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: [], 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; } const formStateIndex = form.getValues('signers').findIndex((s) => s.formId === signer.formId); if (formStateIndex !== -1) { removeSigner(formStateIndex); const updatedSigners = form.getValues('signers').filter((s) => s.formId !== signer.formId); form.setValue('signers', normalizeSigningOrders(updatedSigners), { shouldValidate: true, shouldDirty: true, }); void handleAutoSave(); } }; const onAddSelfSigner = () => { if (emptySignerIndex !== -1) { setValue(`signers.${emptySignerIndex}.name`, user?.name ?? '', { shouldValidate: true, shouldDirty: true, }); setValue(`signers.${emptySignerIndex}.email`, user?.email ?? '', { shouldValidate: true, shouldDirty: true, }); form.setFocus(`signers.${emptySignerIndex}.email`); } else { appendSigner( { formId: nanoid(12), name: user?.name ?? '', email: user?.email ?? '', role: RecipientRole.SIGNER, actionAuth: [], signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1, }, { shouldFocus: true, }, ); void form.trigger('signers'); } }; const handleRecipientAutoCompleteSelect = ( index: number, suggestion: RecipientAutoCompleteOption, ) => { setValue(`signers.${index}.email`, suggestion.email); setValue(`signers.${index}.name`, suggestion.name || ''); }; 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, { shouldValidate: true, shouldDirty: true, }); 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'); void handleAutoSave(); }, [form, canRecipientBeModified, watchedSigners, handleAutoSave, 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, { shouldValidate: true, shouldDirty: true, }); 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, { shouldValidate: true, shouldDirty: true, }); 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, { shouldValidate: true, shouldDirty: true, }); 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, { shouldValidate: true, shouldDirty: true, }); form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, { shouldValidate: true, shouldDirty: true, }); form.setValue('allowDictateNextSigner', false, { shouldValidate: true, shouldDirty: true, }); void handleAutoSave(); }, [form]); return ( <> {isDocumentPdfLoaded && ( recipient.id)} fields={mapFieldsWithRecipients(fields, recipients)} /> )}
( { if (!checked && hasAssistantRole) { setShowSigningOrderConfirmation(true); return; } field.onChange( checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL, ); // If sequential signing is turned off, disable dictate next signer if (!checked) { form.setValue('allowDictateNextSigner', false, { shouldValidate: true, shouldDirty: true, }); } void handleAutoSave(); }} disabled={isSubmitting || hasDocumentBeenSent || emptySigners().length !== 0} />
Enable signing order

Add 2 or more signers to enable signing order.

)} /> ( { field.onChange(checked); void handleAutoSave(); }} disabled={isSubmitting || hasDocumentBeenSent || !isSigningOrderSequential} />
Allow signers to dictate next signer

When enabled, signers can choose who should sign next in the sequence instead of following the predefined order.

)} /> { $sensorApi.current = api; }, ]} > {(provided) => (
{signers.map((signer, index) => ( {(provided, snapshot) => (
{isSigningOrderSequential && ( ( { field.onChange(e); handleSigningOrderChange(index, e.target.value); void handleAutoSave(); }} onBlur={(e) => { field.onBlur(); handleSigningOrderChange(index, e.target.value); void handleAutoSave(); }} disabled={ snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.nativeId) } /> )} /> )} ( {!showAdvancedSettings && ( Email )} handleRecipientAutoCompleteSelect(index, suggestion) } onSearchQueryChange={(query) => { console.log('onSearchQueryChange', query); field.onChange(query); setRecipientSearchQuery(query); }} loading={isLoading} data-testid="signer-email-input" maxLength={254} onBlur={handleAutoSave} /> )} /> ( {!showAdvancedSettings && ( Name )} handleRecipientAutoCompleteSelect(index, suggestion) } onSearchQueryChange={(query) => { field.onChange(query); setRecipientSearchQuery(query); }} loading={isLoading} maxLength={255} onBlur={handleAutoSave} /> )} /> {showAdvancedSettings && organisation.organisationClaim.flags.cfr21 && ( ( )} /> )}
( { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions handleRoleChange(index, value as RecipientRole); void handleAutoSave(); }} disabled={ snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.nativeId) } /> )} />
)}
))} {provided.placeholder}
)}
{!alwaysShowAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
setShowAdvancedSettings(Boolean(value))} />
)}
void onFormSubmit()} /> ); };