feat: add uninserted field validation

This commit is contained in:
David Nguyen
2023-09-22 12:27:39 +10:00
parent 7eed5c7c96
commit dc49277bf9
8 changed files with 273 additions and 133 deletions

View File

@ -9,7 +9,7 @@ export type MarketingLayoutProps = {
export default function MarketingLayout({ children }: MarketingLayoutProps) { export default function MarketingLayout({ children }: MarketingLayoutProps) {
return ( return (
<div className="relative max-w-[100vw] overflow-y-auto overflow-x-hidden pt-20 md:pt-28"> <div className="relative max-w-[100vw] pt-20 md:pt-28">
<div className="fixed left-0 top-0 z-50 w-full bg-white/50 backdrop-blur-md"> <div className="fixed left-0 top-0 z-50 w-full bg-white/50 backdrop-blur-md">
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" /> <Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
</div> </div>

View File

@ -204,7 +204,7 @@ export default function SinglePlayerModePage() {
</div> </div>
<div className="col-span-12 lg:col-span-6 xl:col-span-5"> <div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer onSubmit={(e) => e.preventDefault()}> <DocumentFlowFormContainer className="top-24" onSubmit={(e) => e.preventDefault()}>
<DocumentFlowFormContainerHeader <DocumentFlowFormContainerHeader
title={currentDocumentFlow.title} title={currentDocumentFlow.title}
description={currentDocumentFlow.description} description={currentDocumentFlow.description}

2
package-lock.json generated
View File

@ -19593,7 +19593,7 @@
"@radix-ui/react-tabs": "^1.0.3", "@radix-ui/react-tabs": "^1.0.3",
"@radix-ui/react-toast": "^1.1.3", "@radix-ui/react-toast": "^1.1.3",
"@radix-ui/react-toggle": "^1.0.2", "@radix-ui/react-toggle": "^1.0.2",
"@radix-ui/react-tooltip": "^1.0.5", "@radix-ui/react-tooltip": "^1.0.6",
"@tanstack/react-table": "^8.9.1", "@tanstack/react-table": "^8.9.1",
"class-variance-authority": "^0.6.0", "class-variance-authority": "^0.6.0",
"clsx": "^1.2.1", "clsx": "^1.2.1",

View File

@ -24,8 +24,8 @@
"typescript": "^5.1.6" "typescript": "^5.1.6"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.3.0",
"@documenso/lib": "*", "@documenso/lib": "*",
"@hookform/resolvers": "^3.3.0",
"@radix-ui/react-accordion": "^1.1.1", "@radix-ui/react-accordion": "^1.1.1",
"@radix-ui/react-alert-dialog": "^1.0.3", "@radix-ui/react-alert-dialog": "^1.0.3",
"@radix-ui/react-aspect-ratio": "^1.0.2", "@radix-ui/react-aspect-ratio": "^1.0.2",
@ -51,7 +51,7 @@
"@radix-ui/react-tabs": "^1.0.3", "@radix-ui/react-tabs": "^1.0.3",
"@radix-ui/react-toast": "^1.1.3", "@radix-ui/react-toast": "^1.1.3",
"@radix-ui/react-toggle": "^1.0.2", "@radix-ui/react-toggle": "^1.0.2",
"@radix-ui/react-tooltip": "^1.0.5", "@radix-ui/react-tooltip": "^1.0.6",
"@tanstack/react-table": "^8.9.1", "@tanstack/react-table": "^8.9.1",
"class-variance-authority": "^0.6.0", "class-variance-authority": "^0.6.0",
"clsx": "^1.2.1", "clsx": "^1.2.1",

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
@ -8,7 +8,7 @@ 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 { 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 { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -24,6 +24,7 @@ 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 {
@ -50,6 +51,8 @@ export const AddSignatureFormPartial = ({
requireName = false, requireName = false,
requireSignature = true, requireSignature = true,
}: AddSignatureFormProps) => { }: AddSignatureFormProps) => {
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
// Refined schema which takes into account whether to allow an empty name or signature. // Refined schema which takes into account whether to allow an empty name or signature.
const refinedSchema = ZAddSignatureFormSchema.superRefine((val, ctx) => { const refinedSchema = ZAddSignatureFormSchema.superRefine((val, ctx) => {
if (requireName && val.name.length === 0) { if (requireName && val.name.length === 0) {
@ -81,72 +84,101 @@ export const AddSignatureFormPartial = ({
/** /**
* A local copy of the provided fields to modify. * A local copy of the provided fields to modify.
*/ */
const [localFields, setLocalFields] = useState( const [localFields, setLocalFields] = useState<Field[]>(JSON.parse(JSON.stringify(fields)));
fields.map((field) => {
let customText = field.customText;
if (field.type === FieldType.DATE) { const uninsertedFields = useMemo(() => {
customText = DateTime.now().toFormat('yyyy-MM-dd hh:mm a'); const fields = localFields.filter((field) => !field.inserted);
return fields.sort((a, b) => {
if (a.page < b.page) {
return -1;
} }
const inserted = match(field.type) if (a.page > b.page) {
.with(FieldType.DATE, () => true) return 1;
.with(FieldType.NAME, () => form.getValues('name').length > 0)
.with(FieldType.EMAIL, () => form.getValues('email').length > 0)
.with(FieldType.SIGNATURE, () => form.getValues('signature').length > 0)
.otherwise(() => true);
return { ...field, inserted, customText };
}),
);
const onEmailInputBlur = () => {
setLocalFields((prev) =>
prev.map((field) => {
if (field.type !== FieldType.EMAIL) {
return field;
} }
const value = form.getValues('email'); const aTop = a.positionY;
const bTop = b.positionY;
return { if (aTop < bTop) {
return -1;
}
if (aTop > bTop) {
return 1;
}
return 0;
});
}, [localFields]);
const onValidateFields = async (values: TAddSignatureFormSchema) => {
setValidateUninsertedFields(true);
const firstUninsertedField = uninsertedFields[0];
const firstUninsertedFieldElement =
firstUninsertedField && document.getElementById(`field-${firstUninsertedField.id}`);
if (firstUninsertedFieldElement) {
firstUninsertedFieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
await onSubmit(values);
};
/**
* Validates whether the corresponding form for a given field type is valid.
*
* @returns `true` if the form associated with the provided field is valid, `false` otherwise.
*/
const validateFieldForm = async (fieldType: Field['type']): Promise<boolean> => {
if (fieldType === FieldType.SIGNATURE) {
await form.trigger('signature');
return !form.formState.errors.signature;
}
if (fieldType === FieldType.NAME) {
await form.trigger('name');
return !form.formState.errors.name;
}
if (fieldType === FieldType.EMAIL) {
await form.trigger('email');
return !form.formState.errors.email;
}
return true;
};
/**
* Insert the corresponding form value into a given field.
*/
const insertFormValueIntoField = (field: Field) => {
return match(field.type)
.with(FieldType.DATE, () => ({
...field, ...field,
customText: value, customText: DateTime.now().toFormat('yyyy-MM-dd hh:mm a'),
inserted: value.length > 0,
};
}),
);
};
const onNameInputBlur = () => {
setLocalFields((prev) =>
prev.map((field) => {
if (field.type !== FieldType.NAME) {
return field;
}
const value = form.getValues('name');
return {
...field,
customText: value,
inserted: value.length > 0,
};
}),
);
};
const onSignatureInputChange = (value: string) => {
setLocalFields((prev) =>
prev.map((field) => {
if (field.type !== FieldType.SIGNATURE) {
return field;
}
return {
...field,
value: value ?? '',
inserted: true, inserted: true,
}))
.with(FieldType.EMAIL, () => ({
...field,
customText: form.getValues('email'),
inserted: true,
}))
.with(FieldType.NAME, () => ({
...field,
customText: form.getValues('name'),
inserted: true,
}))
.with(FieldType.SIGNATURE, () => {
const value = form.getValues('signature');
return {
...field,
value,
Signature: { Signature: {
id: -1, id: -1,
recipientId: -1, recipientId: -1,
@ -155,7 +187,27 @@ export const AddSignatureFormPartial = ({
signatureImageAsBase64: value, signatureImageAsBase64: value,
typedSignature: null, typedSignature: null,
}, },
inserted: true,
}; };
})
.otherwise(() => {
throw new Error('Unsupported field');
});
};
const insertField = (field: Field) => async () => {
const isFieldFormValid = await validateFieldForm(field.type);
if (!isFieldFormValid) {
return;
}
setLocalFields((prev) =>
prev.map((prevField) => {
if (prevField.id !== field.id) {
return prevField;
}
return insertFormValueIntoField(field);
}), }),
); );
}; };
@ -172,16 +224,7 @@ export const AddSignatureFormPartial = ({
<FormItem> <FormItem>
<FormLabel required>Email</FormLabel> <FormLabel required>Email</FormLabel>
<FormControl> <FormControl>
<Input <Input className="bg-background" type="email" autoComplete="email" {...field} />
className="bg-background"
type="email"
autoComplete="email"
{...field}
onBlur={() => {
field.onBlur();
onEmailInputBlur();
}}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -196,14 +239,7 @@ export const AddSignatureFormPartial = ({
<FormItem> <FormItem>
<FormLabel required={requireName}>Name</FormLabel> <FormLabel required={requireName}>Name</FormLabel>
<FormControl> <FormControl>
<Input <Input className="bg-background" {...field} />
className="bg-background"
{...field}
onBlur={() => {
field.onBlur();
onNameInputBlur();
}}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -231,10 +267,7 @@ export const AddSignatureFormPartial = ({
<SignaturePad <SignaturePad
className="h-44 w-full" className="h-44 w-full"
defaultValue={field.value} defaultValue={field.value}
onChange={(value) => { {...field}
field.onChange(value ?? '');
onSignatureInputChange(value ?? '');
}}
/> />
</CardContent> </CardContent>
</Card> </Card>
@ -258,19 +291,37 @@ export const AddSignatureFormPartial = ({
loading={form.formState.isSubmitting} loading={form.formState.isSubmitting}
disabled={form.formState.isSubmitting} disabled={form.formState.isSubmitting}
onGoBackClick={documentFlow.onBackStep} onGoBackClick={documentFlow.onBackStep}
onGoNextClick={async () => await form.handleSubmit(onSubmit)()} onGoNextClick={form.handleSubmit(onValidateFields)}
/> />
</DocumentFlowFormContainerFooter> </DocumentFlowFormContainerFooter>
</fieldset> </fieldset>
{validateUninsertedFields && uninsertedFields[0] && (
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
Click to insert field
</FieldToolTip>
)}
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}> <ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{localFields.map((field) => {localFields.map((field) =>
match(field.type) match(field.type)
.with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, () => { .with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, () => {
return <SinglePlayerModeCustomTextField key={field.id} field={field} />; return (
<SinglePlayerModeCustomTextField
onClick={insertField(field)}
validateUninsertedField={validateUninsertedFields}
key={field.id}
field={field}
/>
);
}) })
.with(FieldType.SIGNATURE, () => ( .with(FieldType.SIGNATURE, () => (
<SinglePlayerModeSignatureField key={field.id} field={field} /> <SinglePlayerModeSignatureField
onClick={insertField(field)}
validateUninsertedField={validateUninsertedFields}
key={field.id}
field={field}
/>
)) ))
.otherwise(() => { .otherwise(() => {
return null; return null;

View File

@ -21,7 +21,7 @@ export const DocumentFlowFormContainer = ({
<form <form
id={id} id={id}
className={cn( className={cn(
'dark:bg-background border-border bg-widget sticky top-20 flex h-full max-h-[80rem] flex-col rounded-xl border px-4 py-6', 'dark:bg-background border-border bg-widget sticky top-20 flex h-full max-h-[64rem] flex-col rounded-xl border px-4 py-6',
className, className,
)} )}
{...props} {...props}

View File

@ -28,6 +28,13 @@ export type FieldContainerPortalProps = {
export type SinglePlayerModeFieldContainerProps = { export type SinglePlayerModeFieldContainerProps = {
field: FieldWithSignature; field: FieldWithSignature;
children: React.ReactNode; children: React.ReactNode;
validateUninsertedField?: boolean;
};
export type SinglePlayerModeFieldProps<T> = {
field: T;
onClick?: () => void;
validateUninsertedField?: boolean;
}; };
export function FieldContainerPortal({ export function FieldContainerPortal({
@ -56,20 +63,35 @@ export function FieldContainerPortal({
export function SinglePlayerModeFieldCardContainer({ export function SinglePlayerModeFieldCardContainer({
field, field,
children, children,
validateUninsertedField = false,
}: SinglePlayerModeFieldContainerProps) { }: SinglePlayerModeFieldContainerProps) {
return ( return (
<FieldContainerPortal field={field}> <FieldContainerPortal field={field}>
<motion.div className="h-full w-full" animate={{ opacity: 1 }} exit={{ opacity: 0 }}> <motion.div className="h-full w-full" animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<Card <Card
className="bg-background relative z-20 h-full w-full" id={`field-${field.id}`}
className={cn('bg-background relative z-20 h-full w-full transition-all', {
'border-orange-300 ring-1 ring-orange-300': !field.inserted && validateUninsertedField,
})}
data-inserted={field.inserted ? 'true' : 'false'} data-inserted={field.inserted ? 'true' : 'false'}
> >
<CardContent <CardContent className="text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2">
className={cn( <AnimatePresence mode="wait" initial={false}>
'text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2', <motion.div
)} key={field.inserted ? 'inserted' : 'not-inserted'}
initial={{ opacity: 0 }}
animate={{
opacity: 1,
}}
exit={{ opacity: 0 }}
transition={{
duration: 0.2,
ease: 'easeIn',
}}
> >
{children} {children}
</motion.div>
</AnimatePresence>
</CardContent> </CardContent>
</Card> </Card>
</motion.div> </motion.div>
@ -77,7 +99,11 @@ export function SinglePlayerModeFieldCardContainer({
); );
} }
export function SinglePlayerModeSignatureField({ field }: { field: FieldWithSignature }) { export function SinglePlayerModeSignatureField({
field,
validateUninsertedField,
onClick,
}: SinglePlayerModeFieldProps<FieldWithSignature>) {
const fontVariable = '--font-signature'; const fontVariable = '--font-signature';
const fontVariableValue = getComputedStyle(document.documentElement).getPropertyValue( const fontVariableValue = getComputedStyle(document.documentElement).getPropertyValue(
fontVariable, fontVariable,
@ -110,22 +136,9 @@ export function SinglePlayerModeSignatureField({ field }: { field: FieldWithSign
const insertedTypeSignature = field.inserted && field.Signature?.typedSignature; const insertedTypeSignature = field.inserted && field.Signature?.typedSignature;
return ( return (
<SinglePlayerModeFieldCardContainer field={field}> <SinglePlayerModeFieldCardContainer
<AnimatePresence mode="wait" initial={false}> validateUninsertedField={validateUninsertedField}
<motion.div field={field}
key={
(insertedBase64Signature && 'base64Signature') ||
(insertedTypeSignature && 'typedSignature') ||
'not-inserted'
}
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: {
duration: 0.3,
},
}}
exit={{ opacity: 0 }}
> >
{insertedBase64Signature ? ( {insertedBase64Signature ? (
<img <img
@ -145,15 +158,22 @@ export function SinglePlayerModeSignatureField({ field }: { field: FieldWithSign
{insertedTypeSignature} {insertedTypeSignature}
</p> </p>
) : ( ) : (
<p className="group-hover:text-primary text-muted-foreground duration-200">Signature</p> <button
onClick={() => onClick?.()}
className="group-hover:text-primary text-muted-foreground absolute inset-0 duration-200"
>
Signature
</button>
)} )}
</motion.div>
</AnimatePresence>
</SinglePlayerModeFieldCardContainer> </SinglePlayerModeFieldCardContainer>
); );
} }
export function SinglePlayerModeCustomTextField({ field }: { field: Field }) { export function SinglePlayerModeCustomTextField({
field,
validateUninsertedField,
onClick,
}: SinglePlayerModeFieldProps<Field>) {
const fontVariable = '--font-sans'; const fontVariable = '--font-sans';
const fontVariableValue = getComputedStyle(document.documentElement).getPropertyValue( const fontVariableValue = getComputedStyle(document.documentElement).getPropertyValue(
fontVariable, fontVariable,
@ -183,7 +203,10 @@ export function SinglePlayerModeCustomTextField({ field }: { field: Field }) {
const fontSize = maxFontSize * scalingFactor; const fontSize = maxFontSize * scalingFactor;
return ( return (
<SinglePlayerModeFieldCardContainer key="not-inserted" field={field}> <SinglePlayerModeFieldCardContainer
validateUninsertedField={validateUninsertedField}
field={field}
>
{field.inserted ? ( {field.inserted ? (
<p <p
ref={$paragraphEl} ref={$paragraphEl}
@ -195,14 +218,17 @@ export function SinglePlayerModeCustomTextField({ field }: { field: Field }) {
{field.customText} {field.customText}
</p> </p>
) : ( ) : (
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200"> <button
onClick={() => onClick?.()}
className="group-hover:text-primary text-muted-foreground absolute inset-0 text-lg duration-200"
>
{match(field.type) {match(field.type)
.with(FieldType.DATE, () => 'Date') .with(FieldType.DATE, () => 'Date')
.with(FieldType.NAME, () => 'Name') .with(FieldType.NAME, () => 'Name')
.with(FieldType.EMAIL, () => 'Email') .with(FieldType.EMAIL, () => 'Email')
.with(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE, () => 'Signature') .with(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE, () => 'Signature')
.otherwise(() => '')} .otherwise(() => '')}
</p> </button>
)} )}
</SinglePlayerModeFieldCardContainer> </SinglePlayerModeFieldCardContainer>
); );

View File

@ -0,0 +1,63 @@
import { TooltipArrow } from '@radix-ui/react-tooltip';
import { VariantProps, cva } from 'class-variance-authority';
import { createPortal } from 'react-dom';
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
import { cn } from '@documenso/ui/lib/utils';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { Field } from '.prisma/client';
const tooltipVariants = cva('font-semibold', {
variants: {
color: {
default: 'border-2 fill-white',
warning: 'border-0 bg-orange-300 fill-orange-300 text-orange-900',
},
},
defaultVariants: {
color: 'default',
},
});
interface FieldToolTipProps extends VariantProps<typeof tooltipVariants> {
children: React.ReactNode;
className?: string;
field: Field;
}
/**
* Renders a tooltip for a given field.
*/
export function FieldToolTip({ children, color, className = '', field }: FieldToolTipProps) {
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`,
}}
>
<TooltipProvider>
<Tooltip delayDuration={0} open={!field.inserted}>
<TooltipTrigger className="absolute top-0 w-full"></TooltipTrigger>
<TooltipContent className={tooltipVariants({ color, className })} sideOffset={2}>
{children}
<TooltipArrow />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>,
document.body,
);
}