feat: add uninserted field validation

This commit is contained in:
David Nguyen
2023-09-22 16:25:09 +10:00
committed by Mythie
parent ebebceea1f
commit d20ad4217c
7 changed files with 239 additions and 161 deletions

View File

@ -1,12 +1,15 @@
'use client'; 'use client';
import { useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token'; 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 { Document, Field, Recipient } from '@documenso/prisma/client';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; 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 { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const { const {
handleSubmit, handleSubmit,
formState: { isSubmitting }, formState: { isSubmitting },
} = useForm(); } = useForm();
const isComplete = fields.every((f) => f.inserted); const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(fields.filter((field) => !field.inserted));
}, [fields]);
const onFormSubmit = async () => { const onFormSubmit = async () => {
if (!isComplete) { setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fields);
if (!isFieldsValid) {
return; return;
} }
@ -54,7 +64,16 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
)} )}
onSubmit={handleSubmit(onFormSubmit)} onSubmit={handleSubmit(onFormSubmit)}
> >
<div className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}> {validateUninsertedFields && uninsertedFields[0] && (
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
Click to insert field
</FieldToolTip>
)}
<fieldset
disabled={isSubmitting}
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
>
<div className={cn('flex flex-1 flex-col')}> <div className={cn('flex flex-1 flex-col')}>
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3> <h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
@ -106,19 +125,13 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
Cancel Cancel
</Button> </Button>
<Button <Button className="w-full" type="submit" size="lg" loading={isSubmitting}>
className="w-full"
type="submit"
size="lg"
disabled={!isComplete || isSubmitting}
>
{isSubmitting && <Loader className="mr-2 h-5 w-5 animate-spin" />}
Complete Complete
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
</div> </fieldset>
</form> </form>
); );
}; };

View File

@ -2,10 +2,8 @@
import React from 'react'; 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 { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { cn } from '@documenso/ui/lib/utils'; import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { Card, CardContent } from '@documenso/ui/primitives/card';
export type SignatureFieldProps = { export type SignatureFieldProps = {
field: FieldWithSignature; field: FieldWithSignature;
@ -22,8 +20,6 @@ export const SigningFieldContainer = ({
onRemove, onRemove,
children, children,
}: SignatureFieldProps) => { }: SignatureFieldProps) => {
const coords = useFieldPageCoords(field);
const onSignFieldClick = async () => { const onSignFieldClick = async () => {
if (field.inserted) { if (field.inserted) {
return; return;
@ -41,40 +37,21 @@ export const SigningFieldContainer = ({
}; };
return ( return (
<div <FieldRootContainer field={field}>
className="absolute" {!field.inserted && !loading && (
style={{ <button type="submit" className="absolute inset-0 z-10" onClick={onSignFieldClick} />
top: `${coords.y}px`, )}
left: `${coords.x}px`,
height: `${coords.height}px`, {field.inserted && !loading && (
width: `${coords.width}px`, <button
}} className="text-destructive bg-background/40 absolute inset-0 z-10 flex items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
> onClick={onRemoveSignedFieldClick}
<Card
className="bg-background relative h-full w-full"
data-inserted={field.inserted ? 'true' : 'false'}
>
<CardContent
className={cn(
'text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2',
)}
> >
{!field.inserted && !loading && ( Remove
<button type="submit" className="absolute inset-0 z-10" onClick={onSignFieldClick} /> </button>
)} )}
{field.inserted && !loading && ( {children}
<button </FieldRootContainer>
className="text-destructive bg-background/40 absolute inset-0 z-10 flex items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
Remove
</button>
)}
{children}
</CardContent>
</Card>
</div>
); );
}; };

View File

@ -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;
};

View File

@ -39,7 +39,7 @@ export function FieldToolTip({ children, color, className = '', field }: FieldTo
return createPortal( return createPortal(
<div <div
className={cn('absolute', className)} className={cn('absolute')}
style={{ style={{
top: `${coords.y}px`, top: `${coords.y}px`,
left: `${coords.x}px`, left: `${coords.x}px`,
@ -49,7 +49,7 @@ export function FieldToolTip({ children, color, className = '', field }: FieldTo
> >
<TooltipProvider> <TooltipProvider>
<Tooltip delayDuration={0} open={!field.inserted}> <Tooltip delayDuration={0} open={!field.inserted}>
<TooltipTrigger className="absolute top-0 w-full"></TooltipTrigger> <TooltipTrigger className="absolute inset-0 w-full"></TooltipTrigger>
<TooltipContent className={tooltipVariants({ color, className })} sideOffset={2}> <TooltipContent className={tooltipVariants({ color, className })} sideOffset={2}>
{children} {children}

View File

@ -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(
<div
className={cn('absolute', className)}
style={{
top: `${coords.y}px`,
left: `${coords.x}px`,
height: `${coords.height}px`,
width: `${coords.width}px`,
}}
>
{children}
</div>,
document.body,
);
}
export function FieldRootContainer({ field, children }: FieldContainerPortalProps) {
const [isValidating, setIsValidating] = useState(false);
const ref = React.useRef<HTMLDivElement>(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 (
<FieldContainerPortal field={field}>
<Card
id={`field-${field.id}`}
className={cn(
'field-card-container bg-background relative z-20 h-full w-full transition-all',
{
'border-orange-300 ring-1 ring-orange-300': !field.inserted && isValidating,
},
)}
ref={ref}
data-inserted={field.inserted ? 'true' : 'false'}
>
<CardContent className="text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2">
{children}
</CardContent>
</Card>
</FieldContainerPortal>
);
}

View File

@ -8,8 +8,10 @@ import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; 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 { Field, FieldType } from '@documenso/prisma/client';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; 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 { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types'; 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 { Input } from '@documenso/ui/primitives/input';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; 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 { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
import { ZAddSignatureFormSchema } from './add-signature.types'; import { ZAddSignatureFormSchema } from './add-signature.types';
import { import {
@ -89,40 +90,14 @@ export const AddSignatureFormPartial = ({
const uninsertedFields = useMemo(() => { const uninsertedFields = useMemo(() => {
const fields = localFields.filter((field) => !field.inserted); const fields = localFields.filter((field) => !field.inserted);
return fields.sort((a, b) => { return sortFieldsByPosition(fields);
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;
});
}, [localFields]); }, [localFields]);
const onValidateFields = async (values: TAddSignatureFormSchema) => { const onValidateFields = async (values: TAddSignatureFormSchema) => {
setValidateUninsertedFields(true); setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(localFields);
const firstUninsertedField = uninsertedFields[0]; if (!isFieldsValid) {
const firstUninsertedFieldElement =
firstUninsertedField && document.getElementById(`field-${firstUninsertedField.id}`);
if (firstUninsertedFieldElement) {
firstUninsertedFieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
return; 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 ( return (
<Form {...form}> <Form {...form}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}> <fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
@ -224,7 +217,16 @@ export const AddSignatureFormPartial = ({
<FormItem> <FormItem>
<FormLabel required>Email</FormLabel> <FormLabel required>Email</FormLabel>
<FormControl> <FormControl>
<Input className="bg-background" type="email" autoComplete="email" {...field} /> <Input
className="bg-background"
type="email"
autoComplete="email"
{...field}
onChange={(value) => {
onFormValueChange(FieldType.EMAIL);
field.onChange(value);
}}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -239,7 +241,14 @@ export const AddSignatureFormPartial = ({
<FormItem> <FormItem>
<FormLabel required={requireName}>Name</FormLabel> <FormLabel required={requireName}>Name</FormLabel>
<FormControl> <FormControl>
<Input className="bg-background" {...field} /> <Input
className="bg-background"
{...field}
onChange={(value) => {
onFormValueChange(FieldType.NAME);
field.onChange(value);
}}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -267,7 +276,11 @@ export const AddSignatureFormPartial = ({
<SignaturePad <SignaturePad
className="h-44 w-full" className="h-44 w-full"
defaultValue={field.value} defaultValue={field.value}
{...field} onBlur={field.onBlur}
onChange={(value) => {
onFormValueChange(FieldType.SIGNATURE);
field.onChange(value);
}}
/> />
</CardContent> </CardContent>
</Card> </Card>
@ -309,7 +322,6 @@ export const AddSignatureFormPartial = ({
return ( return (
<SinglePlayerModeCustomTextField <SinglePlayerModeCustomTextField
onClick={insertField(field)} onClick={insertField(field)}
validateUninsertedField={validateUninsertedFields}
key={field.id} key={field.id}
field={field} field={field}
/> />
@ -318,7 +330,6 @@ export const AddSignatureFormPartial = ({
.with(FieldType.SIGNATURE, () => ( .with(FieldType.SIGNATURE, () => (
<SinglePlayerModeSignatureField <SinglePlayerModeSignatureField
onClick={insertField(field)} onClick={insertField(field)}
validateUninsertedField={validateUninsertedFields}
key={field.id} key={field.id}
field={field} field={field}
/> />

View File

@ -3,7 +3,6 @@
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { createPortal } from 'react-dom';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { useElementScaleSize } from '@documenso/lib/client-only/hooks/use-element-scale-size'; import { useElementScaleSize } from '@documenso/lib/client-only/hooks/use-element-scale-size';
@ -16,92 +15,46 @@ import {
} from '@documenso/lib/constants/pdf'; } from '@documenso/lib/constants/pdf';
import { Field, FieldType } from '@documenso/prisma/client'; import { Field, FieldType } from '@documenso/prisma/client';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { cn } from '@documenso/ui/lib/utils'; import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { Card, CardContent } from '@documenso/ui/primitives/card';
export type FieldContainerPortalProps = {
field: FieldWithSignature;
className?: string;
children: React.ReactNode;
};
export type SinglePlayerModeFieldContainerProps = { export type SinglePlayerModeFieldContainerProps = {
field: FieldWithSignature; field: FieldWithSignature;
children: React.ReactNode; children: React.ReactNode;
validateUninsertedField?: boolean;
}; };
export type SinglePlayerModeFieldProps<T> = { export type SinglePlayerModeFieldProps<T> = {
field: T; field: T;
onClick?: () => void; onClick?: () => void;
validateUninsertedField?: boolean;
}; };
export function FieldContainerPortal({
field,
children,
className = '',
}: FieldContainerPortalProps) {
const coords = useFieldPageCoords(field);
return createPortal(
<div
className={cn('absolute', className)}
style={{
top: `${coords.y}px`,
left: `${coords.x}px`,
height: `${coords.height}px`,
width: `${coords.width}px`,
}}
>
{children}
</div>,
document.body,
);
}
export function SinglePlayerModeFieldCardContainer({ export function SinglePlayerModeFieldCardContainer({
field, field,
children, children,
validateUninsertedField = false,
}: SinglePlayerModeFieldContainerProps) { }: SinglePlayerModeFieldContainerProps) {
return ( return (
<FieldContainerPortal field={field}> <FieldRootContainer field={field}>
<motion.div className="h-full w-full" animate={{ opacity: 1 }} exit={{ opacity: 0 }}> <AnimatePresence mode="wait" initial={false}>
<Card <motion.div
id={`field-${field.id}`} key={field.inserted ? 'inserted' : 'not-inserted'}
className={cn('bg-background relative z-20 h-full w-full transition-all', { initial={{ opacity: 0 }}
'border-orange-300 ring-1 ring-orange-300': !field.inserted && validateUninsertedField, animate={{
})} opacity: 1,
data-inserted={field.inserted ? 'true' : 'false'} }}
exit={{ opacity: 0 }}
transition={{
duration: 0.2,
ease: 'easeIn',
}}
> >
<CardContent className="text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2"> {children}
<AnimatePresence mode="wait" initial={false}> </motion.div>
<motion.div </AnimatePresence>
key={field.inserted ? 'inserted' : 'not-inserted'} </FieldRootContainer>
initial={{ opacity: 0 }}
animate={{
opacity: 1,
}}
exit={{ opacity: 0 }}
transition={{
duration: 0.2,
ease: 'easeIn',
}}
>
{children}
</motion.div>
</AnimatePresence>
</CardContent>
</Card>
</motion.div>
</FieldContainerPortal>
); );
} }
export function SinglePlayerModeSignatureField({ export function SinglePlayerModeSignatureField({
field, field,
validateUninsertedField,
onClick, onClick,
}: SinglePlayerModeFieldProps<FieldWithSignature>) { }: SinglePlayerModeFieldProps<FieldWithSignature>) {
const fontVariable = '--font-signature'; const fontVariable = '--font-signature';
@ -136,10 +89,7 @@ export function SinglePlayerModeSignatureField({
const insertedTypeSignature = field.inserted && field.Signature?.typedSignature; const insertedTypeSignature = field.inserted && field.Signature?.typedSignature;
return ( return (
<SinglePlayerModeFieldCardContainer <SinglePlayerModeFieldCardContainer field={field}>
validateUninsertedField={validateUninsertedField}
field={field}
>
{insertedBase64Signature ? ( {insertedBase64Signature ? (
<img <img
src={insertedBase64Signature} src={insertedBase64Signature}
@ -171,7 +121,6 @@ export function SinglePlayerModeSignatureField({
export function SinglePlayerModeCustomTextField({ export function SinglePlayerModeCustomTextField({
field, field,
validateUninsertedField,
onClick, onClick,
}: SinglePlayerModeFieldProps<Field>) { }: SinglePlayerModeFieldProps<Field>) {
const fontVariable = '--font-sans'; const fontVariable = '--font-sans';
@ -203,10 +152,7 @@ export function SinglePlayerModeCustomTextField({
const fontSize = maxFontSize * scalingFactor; const fontSize = maxFontSize * scalingFactor;
return ( return (
<SinglePlayerModeFieldCardContainer <SinglePlayerModeFieldCardContainer field={field}>
validateUninsertedField={validateUninsertedField}
field={field}
>
{field.inserted ? ( {field.inserted ? (
<p <p
ref={$paragraphEl} ref={$paragraphEl}