diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx index e18571e33..6a03d43f8 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -1,12 +1,15 @@ 'use client'; +import { useMemo, useState } from 'react'; + import { useRouter } from 'next/navigation'; -import { Loader } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token'; +import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import { Document, Field, Recipient } from '@documenso/prisma/client'; +import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; @@ -27,15 +30,22 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) = const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext(); + const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); + const { handleSubmit, formState: { isSubmitting }, } = useForm(); - const isComplete = fields.every((f) => f.inserted); + const uninsertedFields = useMemo(() => { + return sortFieldsByPosition(fields.filter((field) => !field.inserted)); + }, [fields]); const onFormSubmit = async () => { - if (!isComplete) { + setValidateUninsertedFields(true); + const isFieldsValid = validateFieldsInserted(fields); + + if (!isFieldsValid) { return; } @@ -54,7 +64,16 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) = )} onSubmit={handleSubmit(onFormSubmit)} > -
+ {validateUninsertedFields && uninsertedFields[0] && ( + + Click to insert field + + )} + +

Sign Document

@@ -105,19 +124,13 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) = Cancel -
- + ); }; diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx index 749ab660f..72e4e7a70 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx @@ -2,10 +2,8 @@ import React from 'react'; -import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords'; import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; -import { cn } from '@documenso/ui/lib/utils'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { FieldRootContainer } from '@documenso/ui/components/field/field'; export type SignatureFieldProps = { field: FieldWithSignature; @@ -22,8 +20,6 @@ export const SigningFieldContainer = ({ onRemove, children, }: SignatureFieldProps) => { - const coords = useFieldPageCoords(field); - const onSignFieldClick = async () => { if (field.inserted) { return; @@ -41,40 +37,21 @@ export const SigningFieldContainer = ({ }; return ( -
- - + {!field.inserted && !loading && ( + + )} - {field.inserted && !loading && ( - - )} - - {children} - - -
+ {children} + ); }; diff --git a/packages/lib/utils/fields.ts b/packages/lib/utils/fields.ts new file mode 100644 index 000000000..b88fed3e9 --- /dev/null +++ b/packages/lib/utils/fields.ts @@ -0,0 +1,41 @@ +import { Field } from '@documenso/prisma/client'; + +/** + * Sort the fields by the Y position on the document. + */ +export const sortFieldsByPosition = (fields: Field[]): Field[] => { + const clonedFields: Field[] = JSON.parse(JSON.stringify(fields)); + + // Sort by page first, then position on page second. + return clonedFields.sort((a, b) => a.page - b.page || Number(a.positionY) - Number(b.positionY)); +}; + +/** + * Validate whether all the provided fields are inserted. + * + * If there are any non-inserted fields it will be highlighted and scrolled into view. + * + * @returns `true` if all fields are inserted, `false` otherwise. + */ +export const validateFieldsInserted = (fields: Field[]): boolean => { + const fieldCardElements = document.getElementsByClassName('field-card-container'); + + // Attach validate attribute on all fields. + Array.from(fieldCardElements).forEach((element) => { + element.setAttribute('data-validate', 'true'); + }); + + const uninsertedFields = sortFieldsByPosition(fields.filter((field) => !field.inserted)); + + const firstUninsertedField = uninsertedFields[0]; + + const firstUninsertedFieldElement = + firstUninsertedField && document.getElementById(`field-${firstUninsertedField.id}`); + + if (firstUninsertedFieldElement) { + firstUninsertedFieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + return false; + } + + return uninsertedFields.length === 0; +}; diff --git a/packages/ui/primitives/field/field-tooltip.tsx b/packages/ui/components/field/field-tooltip.tsx similarity index 92% rename from packages/ui/primitives/field/field-tooltip.tsx rename to packages/ui/components/field/field-tooltip.tsx index c2fa9c580..446b14d2d 100644 --- a/packages/ui/primitives/field/field-tooltip.tsx +++ b/packages/ui/components/field/field-tooltip.tsx @@ -39,7 +39,7 @@ export function FieldToolTip({ children, color, className = '', field }: FieldTo return createPortal(
- + {children} diff --git a/packages/ui/components/field/field.tsx b/packages/ui/components/field/field.tsx new file mode 100644 index 000000000..054cc6376 --- /dev/null +++ b/packages/ui/components/field/field.tsx @@ -0,0 +1,90 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; + +import { createPortal } from 'react-dom'; + +import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords'; +import { Field } from '@documenso/prisma/client'; +import { cn } from '@documenso/ui/lib/utils'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; + +export type FieldRootContainerProps = { + field: Field; + children: React.ReactNode; +}; + +export type FieldContainerPortalProps = { + field: Field; + className?: string; + children: React.ReactNode; +}; + +export function FieldContainerPortal({ + field, + children, + className = '', +}: FieldContainerPortalProps) { + const coords = useFieldPageCoords(field); + + return createPortal( +
+ {children} +
, + document.body, + ); +} + +export function FieldRootContainer({ field, children }: FieldContainerPortalProps) { + const [isValidating, setIsValidating] = useState(false); + + const ref = React.useRef(null); + + useEffect(() => { + if (!ref.current) { + return; + } + + const observer = new MutationObserver((_mutations) => { + if (ref.current) { + setIsValidating(ref.current.getAttribute('data-validate') === 'true'); + } + }); + + observer.observe(ref.current, { + attributes: true, + }); + + return () => { + observer.disconnect(); + }; + }, []); + + return ( + + + + {children} + + + + ); +} diff --git a/packages/ui/primitives/document-flow/add-signature.tsx b/packages/ui/primitives/document-flow/add-signature.tsx index 98164a506..aed252083 100644 --- a/packages/ui/primitives/document-flow/add-signature.tsx +++ b/packages/ui/primitives/document-flow/add-signature.tsx @@ -8,8 +8,10 @@ import { useForm } from 'react-hook-form'; import { match } from 'ts-pattern'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import { Field, FieldType } from '@documenso/prisma/client'; import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; +import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { cn } from '@documenso/ui/lib/utils'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types'; @@ -24,7 +26,6 @@ import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { Input } from '@documenso/ui/primitives/input'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; -import { FieldToolTip } from '../field/field-tooltip'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form'; import { ZAddSignatureFormSchema } from './add-signature.types'; import { @@ -89,40 +90,14 @@ export const AddSignatureFormPartial = ({ const uninsertedFields = useMemo(() => { const fields = localFields.filter((field) => !field.inserted); - return fields.sort((a, b) => { - if (a.page < b.page) { - return -1; - } - - if (a.page > b.page) { - return 1; - } - - const aTop = a.positionY; - const bTop = b.positionY; - - if (aTop < bTop) { - return -1; - } - - if (aTop > bTop) { - return 1; - } - - return 0; - }); + return sortFieldsByPosition(fields); }, [localFields]); const onValidateFields = async (values: TAddSignatureFormSchema) => { setValidateUninsertedFields(true); + const isFieldsValid = validateFieldsInserted(localFields); - const firstUninsertedField = uninsertedFields[0]; - - const firstUninsertedFieldElement = - firstUninsertedField && document.getElementById(`field-${firstUninsertedField.id}`); - - if (firstUninsertedFieldElement) { - firstUninsertedFieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + if (!isFieldsValid) { return; } @@ -212,6 +187,24 @@ export const AddSignatureFormPartial = ({ ); }; + /** + * When a form value changes, reset all the corresponding fields to be uninserted. + */ + const onFormValueChange = (fieldType: FieldType) => { + setLocalFields((fields) => + fields.map((field) => { + if (field.type !== fieldType) { + return field; + } + + return { + ...field, + inserted: false, + }; + }), + ); + }; + return (
@@ -224,7 +217,16 @@ export const AddSignatureFormPartial = ({ Email - + { + onFormValueChange(FieldType.EMAIL); + field.onChange(value); + }} + /> @@ -239,7 +241,14 @@ export const AddSignatureFormPartial = ({ Name - + { + onFormValueChange(FieldType.NAME); + field.onChange(value); + }} + /> @@ -267,7 +276,11 @@ export const AddSignatureFormPartial = ({ { + onFormValueChange(FieldType.SIGNATURE); + field.onChange(value); + }} /> @@ -309,7 +322,6 @@ export const AddSignatureFormPartial = ({ return ( @@ -318,7 +330,6 @@ export const AddSignatureFormPartial = ({ .with(FieldType.SIGNATURE, () => ( diff --git a/packages/ui/primitives/document-flow/single-player-mode-fields.tsx b/packages/ui/primitives/document-flow/single-player-mode-fields.tsx index 9c4090cd7..7ad9a90bc 100644 --- a/packages/ui/primitives/document-flow/single-player-mode-fields.tsx +++ b/packages/ui/primitives/document-flow/single-player-mode-fields.tsx @@ -3,7 +3,6 @@ import React, { useRef } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; -import { createPortal } from 'react-dom'; import { match } from 'ts-pattern'; import { useElementScaleSize } from '@documenso/lib/client-only/hooks/use-element-scale-size'; @@ -16,92 +15,46 @@ import { } from '@documenso/lib/constants/pdf'; import { Field, FieldType } from '@documenso/prisma/client'; import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; -import { cn } from '@documenso/ui/lib/utils'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; - -export type FieldContainerPortalProps = { - field: FieldWithSignature; - className?: string; - children: React.ReactNode; -}; +import { FieldRootContainer } from '@documenso/ui/components/field/field'; export type SinglePlayerModeFieldContainerProps = { field: FieldWithSignature; children: React.ReactNode; - validateUninsertedField?: boolean; }; export type SinglePlayerModeFieldProps = { field: T; onClick?: () => void; - validateUninsertedField?: boolean; }; -export function FieldContainerPortal({ - field, - children, - className = '', -}: FieldContainerPortalProps) { - const coords = useFieldPageCoords(field); - - return createPortal( -
- {children} -
, - document.body, - ); -} - export function SinglePlayerModeFieldCardContainer({ field, children, - validateUninsertedField = false, }: SinglePlayerModeFieldContainerProps) { return ( - - - + + - - - - {children} - - - - - - + {children} + + + ); } export function SinglePlayerModeSignatureField({ field, - validateUninsertedField, onClick, }: SinglePlayerModeFieldProps) { const fontVariable = '--font-signature'; @@ -136,10 +89,7 @@ export function SinglePlayerModeSignatureField({ const insertedTypeSignature = field.inserted && field.Signature?.typedSignature; return ( - + {insertedBase64Signature ? ( ) { const fontVariable = '--font-sans'; @@ -203,10 +152,7 @@ export function SinglePlayerModeCustomTextField({ const fontSize = maxFontSize * scalingFactor; return ( - + {field.inserted ? (