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 { TemplateDirectLink } from '@prisma/client'; import { DocumentSigningOrder, type Field, type Recipient, RecipientRole } from '@prisma/client'; import { motion } from 'framer-motion'; import { GripVerticalIcon, HelpCircle, Link2Icon, Plus, Trash } from 'lucide-react'; import { useFieldArray, useForm } from 'react-hook-form'; import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useSession } from '@documenso/lib/client-only/providers/session'; import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import { nanoid } from '@documenso/lib/universal/id'; import { generateRecipientPlaceholder } from '@documenso/lib/utils/templates'; 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 '@documenso/ui/primitives/button'; import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; import { Input } from '@documenso/ui/primitives/input'; import { toast } from '@documenso/ui/primitives/use-toast'; import { DocumentReadOnlyFields, mapFieldsWithRecipients, } from '../../components/document/document-read-only-fields'; import { Checkbox } from '../checkbox'; import { DocumentFlowFormContainerActions, DocumentFlowFormContainerContent, DocumentFlowFormContainerFooter, DocumentFlowFormContainerHeader, DocumentFlowFormContainerStep, } from '../document-flow/document-flow-root'; import { SigningOrderConfirmation } from '../document-flow/signing-order-confirmation'; import type { DocumentFlowStep } from '../document-flow/types'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form'; import { useStep } from '../stepper'; import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip'; import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; type AutoSaveResponse = { recipients: Recipient[]; }; export type AddTemplatePlaceholderRecipientsFormProps = { documentFlow: DocumentFlowStep; recipients: Recipient[]; fields: Field[]; signingOrder?: DocumentSigningOrder | null; allowDictateNextSigner?: boolean; templateDirectLink?: TemplateDirectLink | null; onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void; onAutoSave: (_data: TAddTemplatePlacholderRecipientsFormSchema) => Promise; isDocumentPdfLoaded: boolean; }; export const AddTemplatePlaceholderRecipientsFormPartial = ({ documentFlow, recipients, templateDirectLink, fields, signingOrder, allowDictateNextSigner, isDocumentPdfLoaded, onSubmit, onAutoSave, }: AddTemplatePlaceholderRecipientsFormProps) => { const initialId = useId(); const $sensorApi = useRef(null); const { _ } = useLingui(); const { user } = useSession(); const organisation = useCurrentOrganisation(); const [placeholderRecipientCount, setPlaceholderRecipientCount] = useState(() => recipients.length > 1 ? recipients.length + 1 : 2, ); const { currentStep, totalSteps, previousStep } = useStep(); const generateDefaultFormSigners = () => { if (recipients.length === 0) { return [ { formId: initialId, role: RecipientRole.SIGNER, actionAuth: [], ...generateRecipientPlaceholder(1), signingOrder: 1, }, ]; } let mappedRecipients = recipients.map((recipient, index) => ({ nativeId: recipient.id, formId: String(recipient.id), name: recipient.name, email: recipient.email, role: recipient.role, actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined, signingOrder: recipient.signingOrder ?? index + 1, })); if (signingOrder === DocumentSigningOrder.SEQUENTIAL) { mappedRecipients = mappedRecipients.sort( (a, b) => (a.signingOrder ?? 0) - (b.signingOrder ?? 0), ); } return mappedRecipients; }; const form = useForm({ resolver: zodResolver(ZAddTemplatePlacholderRecipientsFormSchema), defaultValues: { signers: generateDefaultFormSigners(), signingOrder: signingOrder || DocumentSigningOrder.PARALLEL, allowDictateNextSigner: allowDictateNextSigner ?? false, }, }); 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, (response) => { // Sync the response recipients back to form state to prevent duplicates if (response?.recipients) { const currentSigners = form.getValues('signers'); const updatedSigners = currentSigners.map((signer) => { // Find the matching recipient from the response using nativeId const matchingRecipient = response.recipients.find( (recipient) => recipient.id === signer.nativeId, ); if (matchingRecipient) { // Update the signer with the server-returned data, especially the ID return { ...signer, nativeId: matchingRecipient.id, }; } // For new signers without nativeId, match by email and update with server ID if (!signer.nativeId) { const newRecipient = response.recipients.find( (recipient) => recipient.email === signer.email, ); if (newRecipient) { return { ...signer, nativeId: newRecipient.id, }; } } return signer; }); // Update the form state with the synced data form.setValue('signers', updatedSigners, { shouldValidate: false }); } }); }; // useEffect(() => { // form.reset({ // signers: generateDefaultFormSigners(), // signingOrder: signingOrder || DocumentSigningOrder.PARALLEL, // allowDictateNextSigner: allowDictateNextSigner ?? false, // }); // // eslint-disable-next-line react-hooks/exhaustive-deps // }, [recipients]); // 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 { formState: { errors, isSubmitting }, control, watch, } = form; const watchedSigners = watch('signers'); const isSigningOrderSequential = watch('signingOrder') === DocumentSigningOrder.SEQUENTIAL; 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 onAddPlaceholderSelfRecipient = () => { appendSigner({ formId: nanoid(12), name: user.name ?? '', email: user.email ?? '', role: RecipientRole.SIGNER, signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1, actionAuth: [], }); }; const onAddPlaceholderRecipient = () => { appendSigner({ formId: nanoid(12), role: RecipientRole.SIGNER, ...generateRecipientPlaceholder(placeholderRecipientCount), signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1, actionAuth: [], }); setPlaceholderRecipientCount((count) => count + 1); }; const onRemoveSigner = (index: number) => { removeSigner(index); const updatedSigners = signers.filter((_, idx) => idx !== index); form.setValue('signers', normalizeSigningOrders(updatedSigners), { shouldValidate: true, shouldDirty: true, }); void handleAutoSave(); }; const isSignerDirectRecipient = ( signer: TAddTemplatePlacholderRecipientsFormSchema['signers'][number], ): boolean => { return ( templateDirectLink !== null && signer.nativeId === templateDirectLink?.directTemplateRecipientId ); }; const onDragEnd = useCallback( async (result: DropResult) => { if (!result.destination) return; const items = Array.from(watchedSigners); const [reorderedSigner] = items.splice(result.source.index, 1); const insertIndex = result.destination.index; items.splice(insertIndex, 0, reorderedSigner); const updatedSigners = items.map((signer, index) => ({ ...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, watchedSigners, toast, handleAutoSave], ); 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: 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.`, ), }); } void handleAutoSave(); }, [form, toast, handleAutoSave], ); 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: 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.`, ), }); } void handleAutoSave(); }, [form, toast, handleAutoSave], ); const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false); 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, handleAutoSave]); return ( <> {isDocumentPdfLoaded && ( recipient.id)} fields={mapFieldsWithRecipients(fields, recipients)} /> )}
{/* Enable sequential signing checkbox */} ( { if ( !checked && watchedSigners.some((s) => s.role === RecipientRole.ASSISTANT) ) { 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} /> Enable signing order )} /> ( { field.onChange(checked); void handleAutoSave(); }} disabled={isSubmitting || !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.

)} /> {/* Drag and drop context */} { $sensorApi.current = api; }, ]} > {(provided) => (
{/* todo */} {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 || isSignerDirectRecipient(signer) } /> )} /> )} ( {!showAdvancedSettings && index === 0 && ( Email )} )} /> ( {!showAdvancedSettings && index === 0 && ( Name )} )} /> {showAdvancedSettings && organisation.organisationClaim.flags.cfr21 && ( ( )} /> )}
( { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions handleRoleChange(index, value as RecipientRole); }} disabled={isSubmitting} hideCCRecipients={isSignerDirectRecipient(signer)} /> )} /> {isSignerDirectRecipient(signer) ? (

Direct link receiver

This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them.

) : ( )}
)}
))} {provided.placeholder}
)}
{!alwaysShowAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
setShowAdvancedSettings(Boolean(value))} />
)}
1} onGoBackClick={() => previousStep()} onGoNextClick={() => void onFormSubmit()} /> ); };