'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Caveat } from 'next/font/google'; import { Trans, msg } from '@lingui/macro'; import { useLingui } from '@lingui/react'; import { Prisma } from '@prisma/client'; import { CalendarDays, Check, CheckSquare, ChevronDown, ChevronsUpDown, Contact, Disc, Hash, Info, Mail, Type, User, } from 'lucide-react'; import { useFieldArray, useForm } from 'react-hook-form'; import { useHotkeys } from 'react-hotkeys-hook'; import { prop, sortBy } from 'remeda'; import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { type TFieldMetaSchema as FieldMeta, ZFieldMetaSchema, } from '@documenso/lib/types/field-meta'; import { nanoid } from '@documenso/lib/universal/id'; import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers'; import { validateFieldsUninserted } from '@documenso/lib/utils/fields'; import { parseMessageDescriptor } from '@documenso/lib/utils/i18n'; import { canRecipientBeModified, canRecipientFieldsBeModified, } from '@documenso/lib/utils/recipients'; import type { Field, Recipient } from '@documenso/prisma/client'; import { FieldType, RecipientRole, SendStatus } from '@documenso/prisma/client'; import { FieldToolTip } from '../../components/field/field-tooltip'; import { getSignerColorStyles, useSignerColors } from '../../lib/signer-colors'; import { cn } from '../../lib/utils'; import { Alert, AlertDescription } from '../alert'; import { Button } from '../button'; import { Card, CardContent } from '../card'; import { Checkbox } from '../checkbox'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from '../command'; import { Form, FormControl, FormField, FormItem, FormLabel } from '../form/form'; import { Popover, PopoverContent, PopoverTrigger } from '../popover'; import { useStep } from '../stepper'; import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip'; import { useToast } from '../use-toast'; import type { TAddFieldsFormSchema } from './add-fields.types'; import { DocumentFlowFormContainerActions, DocumentFlowFormContainerContent, DocumentFlowFormContainerFooter, DocumentFlowFormContainerHeader, DocumentFlowFormContainerStep, } from './document-flow-root'; import { FieldItem } from './field-item'; import { FieldAdvancedSettings } from './field-item-advanced-settings'; import { MissingSignatureFieldDialog } from './missing-signature-field-dialog'; import { type DocumentFlowStep, FRIENDLY_FIELD_TYPE } from './types'; const fontCaveat = Caveat({ weight: ['500'], subsets: ['latin'], display: 'swap', variable: '--font-caveat', }); const MIN_HEIGHT_PX = 12; const MIN_WIDTH_PX = 36; const DEFAULT_HEIGHT_PX = MIN_HEIGHT_PX * 2.5; const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5; export type FieldFormType = { nativeId?: number; formId: string; pageNumber: number; type: FieldType; pageX: number; pageY: number; pageWidth: number; pageHeight: number; signerEmail: string; fieldMeta?: FieldMeta; }; export type AddFieldsFormProps = { documentFlow: DocumentFlowStep; hideRecipients?: boolean; recipients: Recipient[]; fields: Field[]; onSubmit: (_data: TAddFieldsFormSchema) => void; canGoBack?: boolean; isDocumentPdfLoaded: boolean; typedSignatureEnabled?: boolean; teamId?: number; }; export const AddFieldsFormPartial = ({ documentFlow, hideRecipients = false, recipients, fields, onSubmit, canGoBack = false, isDocumentPdfLoaded, typedSignatureEnabled, teamId, }: AddFieldsFormProps) => { const { toast } = useToast(); const { _ } = useLingui(); const [isMissingSignatureDialogVisible, setIsMissingSignatureDialogVisible] = useState(false); const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement(); const { currentStep, totalSteps, previousStep } = useStep(); const canRenderBackButtonAsRemove = currentStep === 1 && typeof documentFlow.onBackStep === 'function' && canGoBack; const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); const [currentField, setCurrentField] = useState(); const [activeFieldId, setActiveFieldId] = useState(null); const form = useForm({ defaultValues: { fields: fields.map((field) => ({ nativeId: field.id, formId: `${field.id}-${field.documentId}`, pageNumber: field.page, type: field.type, pageX: Number(field.positionX), pageY: Number(field.positionY), pageWidth: Number(field.width), pageHeight: Number(field.height), signerEmail: recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '', fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined, })), typedSignatureEnabled: typedSignatureEnabled ?? false, }, }); useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt)); useHotkeys(['ctrl+v', 'meta+v'], (evt) => onFieldPaste(evt)); useHotkeys(['ctrl+d', 'meta+d'], (evt) => onFieldCopy(evt, { duplicate: true })); const onFormSubmit = form.handleSubmit(onSubmit); const handleSavedFieldSettings = (fieldState: FieldMeta) => { const initialValues = form.getValues(); const updatedFields = initialValues.fields.map((field) => { if (field.formId === currentField?.formId) { const parsedFieldMeta = ZFieldMetaSchema.parse(fieldState); return { ...field, fieldMeta: parsedFieldMeta, }; } return field; }); form.setValue('fields', updatedFields); }; const { append, remove, update, fields: localFields, } = useFieldArray({ control: form.control, name: 'fields', }); const [selectedField, setSelectedField] = useState(null); const [selectedSigner, setSelectedSigner] = useState(null); const [showRecipientsSelector, setShowRecipientsSelector] = useState(false); const [lastActiveField, setLastActiveField] = useState( null, ); const [fieldClipboard, setFieldClipboard] = useState( null, ); const selectedSignerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id); const selectedSignerStyles = useSignerColors( selectedSignerIndex === -1 ? 0 : selectedSignerIndex, ); const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); const filterFieldsWithEmptyValues = (fields: typeof localFields, fieldType: string) => fields .filter((field) => field.type === fieldType) .filter((field) => { if (field.fieldMeta && 'values' in field.fieldMeta) { return field.fieldMeta.values?.length === 0; } return true; }); const emptyCheckboxFields = useMemo( () => filterFieldsWithEmptyValues(localFields, FieldType.CHECKBOX), // eslint-disable-next-line react-hooks/exhaustive-deps [localFields], ); const emptyRadioFields = useMemo( () => filterFieldsWithEmptyValues(localFields, FieldType.RADIO), // eslint-disable-next-line react-hooks/exhaustive-deps [localFields], ); const emptySelectFields = useMemo( () => filterFieldsWithEmptyValues(localFields, FieldType.DROPDOWN), // eslint-disable-next-line react-hooks/exhaustive-deps [localFields], ); const hasErrors = emptyCheckboxFields.length > 0 || emptyRadioFields.length > 0 || emptySelectFields.length > 0; const fieldsWithError = useMemo(() => { const fields = localFields.filter((field) => { const hasError = ((field.type === FieldType.CHECKBOX || field.type === FieldType.RADIO || field.type === FieldType.DROPDOWN) && field.fieldMeta === undefined) || (field.fieldMeta && 'values' in field.fieldMeta && field?.fieldMeta?.values?.length === 0); return hasError; }); const mappedFields = fields.map((field) => ({ id: field.nativeId ?? 0, secondaryId: field.formId, documentId: null, templateId: null, recipientId: 0, type: field.type, page: field.pageNumber, positionX: new Prisma.Decimal(field.pageX), positionY: new Prisma.Decimal(field.pageY), width: new Prisma.Decimal(field.pageWidth), height: new Prisma.Decimal(field.pageHeight), customText: '', inserted: true, fieldMeta: field.fieldMeta ?? null, })); return mappedFields; }, [localFields]); const isFieldsDisabled = useMemo(() => { if (!selectedSigner) { return true; } return !canRecipientFieldsBeModified(selectedSigner, fields); }, [selectedSigner, fields]); const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false); const [coords, setCoords] = useState({ x: 0, y: 0, }); const fieldBounds = useRef({ height: 0, width: 0, }); const onMouseMove = useCallback( (event: MouseEvent) => { setIsFieldWithinBounds( isWithinPageBounds( event, PDF_VIEWER_PAGE_SELECTOR, fieldBounds.current.width, fieldBounds.current.height, ), ); setCoords({ x: event.clientX - fieldBounds.current.width / 2, y: event.clientY - fieldBounds.current.height / 2, }); }, [isWithinPageBounds], ); const onMouseClick = useCallback( (event: MouseEvent) => { if (!selectedField || !selectedSigner) { return; } const $page = getPage(event, PDF_VIEWER_PAGE_SELECTOR); if ( !$page || !isWithinPageBounds( event, PDF_VIEWER_PAGE_SELECTOR, fieldBounds.current.width, fieldBounds.current.height, ) ) { setSelectedField(null); return; } const { top, left, height, width } = getBoundingClientRect($page); const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10); // Calculate x and y as a percentage of the page width and height let pageX = ((event.pageX - left) / width) * 100; let pageY = ((event.pageY - top) / height) * 100; // Get the bounds as a percentage of the page width and height const fieldPageWidth = (fieldBounds.current.width / width) * 100; const fieldPageHeight = (fieldBounds.current.height / height) * 100; // And center it based on the bounds pageX -= fieldPageWidth / 2; pageY -= fieldPageHeight / 2; const field = { formId: nanoid(12), type: selectedField, pageNumber, pageX, pageY, pageWidth: fieldPageWidth, pageHeight: fieldPageHeight, signerEmail: selectedSigner.email, fieldMeta: undefined, }; append(field); // Only open fields with significant amount of settings (instead of just a font setting) to // reduce friction when adding fields. if (ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING.includes(selectedField)) { setCurrentField(field); setShowAdvancedSettings(true); } setIsFieldWithinBounds(false); setSelectedField(null); }, [append, isWithinPageBounds, selectedField, selectedSigner, getPage], ); const onFieldResize = useCallback( (node: HTMLElement, index: number) => { const field = localFields[index]; const $page = window.document.querySelector( `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`, ); if (!$page) { return; } const { x: pageX, y: pageY, width: pageWidth, height: pageHeight, } = getFieldPosition($page, node); update(index, { ...field, pageX, pageY, pageWidth, pageHeight, }); }, [getFieldPosition, localFields, update], ); const onFieldMove = useCallback( (node: HTMLElement, index: number) => { const field = localFields[index]; const $page = window.document.querySelector( `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`, ); if (!$page) { return; } const { x: pageX, y: pageY } = getFieldPosition($page, node); update(index, { ...field, pageX, pageY, }); }, [getFieldPosition, localFields, update], ); const onFieldCopy = useCallback( (event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => { const { duplicate = false } = options ?? {}; if (lastActiveField) { event?.preventDefault(); if (!duplicate) { setFieldClipboard(lastActiveField); toast({ title: 'Copied field', description: 'Copied field to clipboard', }); return; } const newField: TAddFieldsFormSchema['fields'][0] = { ...structuredClone(lastActiveField), formId: nanoid(12), signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail, pageX: lastActiveField.pageX + 3, pageY: lastActiveField.pageY + 3, }; append(newField); } }, [append, lastActiveField, selectedSigner?.email, toast], ); const onFieldPaste = useCallback( (event: KeyboardEvent) => { if (fieldClipboard) { event.preventDefault(); const copiedField = structuredClone(fieldClipboard); append({ ...copiedField, formId: nanoid(12), signerEmail: selectedSigner?.email ?? copiedField.signerEmail, pageX: copiedField.pageX + 3, pageY: copiedField.pageY + 3, }); } }, [append, fieldClipboard, selectedSigner?.email], ); useEffect(() => { if (selectedField) { window.addEventListener('mousemove', onMouseMove); window.addEventListener('mouseup', onMouseClick); } return () => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseClick); }; }, [onMouseClick, onMouseMove, selectedField]); useEffect(() => { const observer = new MutationObserver((_mutations) => { const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR); if (!$page) { return; } fieldBounds.current = { height: Math.max(DEFAULT_HEIGHT_PX), width: Math.max(DEFAULT_WIDTH_PX), }; }); observer.observe(document.body, { childList: true, subtree: true, }); return () => { observer.disconnect(); }; }, []); useEffect(() => { const recipientsByRoleToDisplay = recipients.filter( (recipient) => recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT, ); setSelectedSigner( recipientsByRoleToDisplay.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipientsByRoleToDisplay[0], ); }, [recipients]); const recipientsByRole = useMemo(() => { const recipientsByRole: Record = { CC: [], VIEWER: [], SIGNER: [], APPROVER: [], ASSISTANT: [], }; recipients.forEach((recipient) => { recipientsByRole[recipient.role].push(recipient); }); return recipientsByRole; }, [recipients]); const recipientsByRoleToDisplay = useMemo(() => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][]) .filter( ([role]) => role !== RecipientRole.CC && role !== RecipientRole.VIEWER && role !== RecipientRole.ASSISTANT, ) .map( ([role, roleRecipients]) => // eslint-disable-next-line @typescript-eslint/consistent-type-assertions [ role, sortBy( roleRecipients, [(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'], [prop('id'), 'asc'], ), ] as [RecipientRole, Recipient[]], ); }, [recipientsByRole]); const handleAdvancedSettings = () => { setShowAdvancedSettings((prev) => !prev); }; const handleGoNextClick = () => { const everySignerHasSignature = recipientsByRole.SIGNER.every((signer) => localFields.some( (field) => (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) && field.signerEmail === signer.email, ), ); if (!everySignerHasSignature) { setIsMissingSignatureDialogVisible(true); return; } setValidateUninsertedFields(true); const isFieldsValid = validateFieldsUninserted(); if (!isFieldsValid) { return; } else { void onFormSubmit(); } }; return ( <> {showAdvancedSettings && currentField ? ( ) : ( <>
{selectedField && (
{parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[selectedField])}
)} {isDocumentPdfLoaded && localFields.map((field, index) => { const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail); const hasFieldError = emptyCheckboxFields.find((f) => f.formId === field.formId) || emptyRadioFields.find((f) => f.formId === field.formId) || emptySelectFields.find((f) => f.formId === field.formId); return ( setLastActiveField(field)} onBlur={() => setLastActiveField(null)} onResize={(options) => onFieldResize(options, index)} onMove={(options) => onFieldMove(options, index)} onRemove={() => remove(index)} onDuplicate={() => onFieldCopy(null, { duplicate: true })} onAdvancedSettings={() => { setCurrentField(field); handleAdvancedSettings(); }} hideRecipients={hideRecipients} hasErrors={!!hasFieldError} active={activeFieldId === field.formId} onFieldActivate={() => setActiveFieldId(field.formId)} onFieldDeactivate={() => setActiveFieldId(null)} /> ); })} {!hideRecipients && ( No recipient matching this description was found. {recipientsByRoleToDisplay.map(([role, roleRecipients], roleIndex) => (
{_(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
{roleRecipients.length === 0 && (
No recipients with this role
)} {roleRecipients.map((recipient) => ( r.id === recipient.id), 0, ), ).default.comboxBoxItem, { 'text-muted-foreground': recipient.sendStatus === SendStatus.SENT, }, )} onSelect={() => { setSelectedSigner(recipient); setShowRecipientsSelector(false); }} > {recipient.name && ( {recipient.name} ({recipient.email}) )} {!recipient.name && ( {recipient.email} )}
{recipient.sendStatus !== SendStatus.SENT ? ( ) : ( This document has already been sent to this recipient. You can no longer edit this recipient. )}
))}
))}
)}
( field.onChange(checked)} disabled={form.formState.isSubmitting} /> Enable Typed Signatures )} />
{hasErrors && (
  • To proceed further, please set at least one value for the{' '} {emptyCheckboxFields.length > 0 ? 'Checkbox' : emptyRadioFields.length > 0 ? 'Radio' : 'Select'}{' '} field.
)} {selectedSigner && !canRecipientFieldsBeModified(selectedSigner, fields) && ( This recipient can no longer be modified as they have signed a field, or completed the document. )} { previousStep(); remove(); documentFlow.onBackStep?.(); }} goBackLabel={canRenderBackButtonAsRemove ? msg`Remove` : undefined} onGoNextClick={handleGoNextClick} /> setIsMissingSignatureDialogVisible(value)} /> )} {validateUninsertedFields && fieldsWithError[0] && ( Empty field )} ); };