chore: add more field types (#1141)

Adds a number of new field types and capabilities to existing fields.

A massive change with far too many moving pieces to document in a single commit.
This commit is contained in:
Catalin Pit
2024-07-18 16:45:44 +03:00
committed by GitHub
parent a3ee732a9b
commit 7b5c57e8af
74 changed files with 5234 additions and 829 deletions

View File

@ -232,6 +232,14 @@ export const EditDocumentForm = ({
fields: data.fields,
});
// Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('field_')) {
localStorage.removeItem(key);
}
}
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
@ -241,7 +249,7 @@ export const EditDocumentForm = ({
toast({
title: 'Error',
description: 'An error occurred while adding signers.',
description: 'An error occurred while adding the fields.',
variant: 'destructive',
});
}
@ -351,6 +359,7 @@ export const EditDocumentForm = ({
fields={fields}
onSubmit={onAddFieldsFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded}
teamId={team?.id}
/>
<AddSubjectFormPartial

View File

@ -150,7 +150,7 @@ export const ResendDocumentActionItem = ({
<FormControl>
<Checkbox
className="h-5 w-5 rounded-full data-[state=checked]:border-black data-[state=checked]:bg-black "
className="h-5 w-5 rounded-full data-[state=checked]:border-black data-[state=checked]:bg-black"
checkClassName="text-white"
value={recipient.id}
checked={value.includes(recipient.id)}

View File

@ -12,10 +12,7 @@ import type { TemplateWithDetails } from '@documenso/prisma/types/template';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
DocumentFlowFormContainer,
DocumentFlowFormContainerHeader,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper';
@ -184,6 +181,14 @@ export const EditTemplateForm = ({
fields: data.fields,
});
// Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('field_')) {
localStorage.removeItem(key);
}
}
toast({
title: 'Template saved',
description: 'Your templates has been saved successfully.',
@ -232,11 +237,6 @@ export const EditTemplateForm = ({
className="lg:h-[calc(100vh-6rem)]"
onSubmit={(e) => e.preventDefault()}
>
<DocumentFlowFormContainerHeader
title={currentDocumentFlow.title}
description={currentDocumentFlow.description}
/>
<Stepper
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
@ -269,6 +269,7 @@ export const EditTemplateForm = ({
recipients={recipients}
fields={fields}
onSubmit={onAddFieldsFormSubmit}
teamId={team?.id}
/>
</Stepper>
</DocumentFlowFormContainer>

View File

@ -6,6 +6,13 @@ import { match } from 'ts-pattern';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { Field, Recipient, Signature } from '@documenso/prisma/client';
import { FieldType } from '@documenso/prisma/client';
@ -30,10 +37,14 @@ import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useStep } from '@documenso/ui/primitives/stepper';
import { CheckboxField } from '~/app/(signing)/sign/[token]/checkbox-field';
import { DateField } from '~/app/(signing)/sign/[token]/date-field';
import { DropdownField } from '~/app/(signing)/sign/[token]/dropdown-field';
import { EmailField } from '~/app/(signing)/sign/[token]/email-field';
import { NameField } from '~/app/(signing)/sign/[token]/name-field';
import { NumberField } from '~/app/(signing)/sign/[token]/number-field';
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
import { RadioField } from '~/app/(signing)/sign/[token]/radio-field';
import { SignDialog } from '~/app/(signing)/sign/[token]/sign-dialog';
import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field';
import { TextField } from '~/app/(signing)/sign/[token]/text-field';
@ -200,15 +211,96 @@ export const SignDirectTemplateForm = ({
onUnsignField={onUnsignField}
/>
))
.with(FieldType.TEXT, () => (
<TextField
key={field.id}
field={field}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
))
.with(FieldType.TEXT, () => {
const parsedFieldMeta = field.fieldMeta
? ZTextFieldMeta.parse(field.fieldMeta)
: null;
return (
<TextField
key={field.id}
field={{
...field,
fieldMeta: parsedFieldMeta,
}}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
);
})
.with(FieldType.NUMBER, () => {
const parsedFieldMeta = field.fieldMeta
? ZNumberFieldMeta.parse(field.fieldMeta)
: null;
return (
<NumberField
key={field.id}
field={{
...field,
fieldMeta: parsedFieldMeta,
}}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
);
})
.with(FieldType.DROPDOWN, () => {
const parsedFieldMeta = field.fieldMeta
? ZDropdownFieldMeta.parse(field.fieldMeta)
: null;
return (
<DropdownField
key={field.id}
field={{
...field,
fieldMeta: parsedFieldMeta,
}}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
);
})
.with(FieldType.RADIO, () => {
const parsedFieldMeta = field.fieldMeta
? ZRadioFieldMeta.parse(field.fieldMeta)
: null;
return (
<RadioField
key={field.id}
field={{
...field,
fieldMeta: parsedFieldMeta,
}}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
);
})
.with(FieldType.CHECKBOX, () => {
const parsedFieldMeta = field.fieldMeta
? ZCheckboxFieldMeta.parse(field.fieldMeta)
: null;
return (
<CheckboxField
key={field.id}
field={{
...field,
fieldMeta: parsedFieldMeta,
}}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
);
})
.otherwise(() => null),
)}
</ElementVisible>

View File

@ -0,0 +1,292 @@
'use client';
import { useEffect, useMemo, useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZCheckboxFieldMeta } from '@documenso/lib/types/field-meta';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { SigningFieldContainer } from './signing-field-container';
export type CheckboxFieldProps = {
field: FieldWithSignatureAndFieldMeta;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const CheckboxField = ({
field,
recipient,
onSignField,
onUnsignField,
}: CheckboxFieldProps) => {
const router = useRouter();
const { toast } = useToast();
const [isPending, startTransition] = useTransition();
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const parsedFieldMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
const values = parsedFieldMeta.values?.map((item) => ({
...item,
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
}));
const [checkedValues, setCheckedValues] = useState(
values
?.map((item) =>
item.checked ? (item.value.length > 0 ? item.value : `empty-value-${item.id}`) : '',
)
.filter(Boolean) || [],
);
const isReadOnly = parsedFieldMeta.readOnly;
const checkboxValidationRule = parsedFieldMeta.validationRule;
const checkboxValidationLength = parsedFieldMeta.validationLength;
const validationSign = checkboxValidationSigns.find(
(sign) => sign.label === checkboxValidationRule,
);
const isLengthConditionMet = useMemo(() => {
if (!validationSign) return true;
return (
(validationSign.value === '>=' && checkedValues.length >= (checkboxValidationLength || 0)) ||
(validationSign.value === '=' && checkedValues.length === (checkboxValidationLength || 0)) ||
(validationSign.value === '<=' && checkedValues.length <= (checkboxValidationLength || 0))
);
}, [checkedValues, validationSign, checkboxValidationLength]);
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const shouldAutoSignField =
(!field.inserted && checkedValues.length > 0 && isLengthConditionMet) ||
(!field.inserted && isReadOnly && isLengthConditionMet);
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
value: checkedValues.join(','),
isBase64: true,
authOptions,
};
if (onSignField) {
await onSignField(payload);
} else {
await signFieldWithToken(payload);
}
startTransition(() => router.refresh());
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while signing the document.',
variant: 'destructive',
});
}
};
const onRemove = async (fieldType?: string) => {
try {
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
};
if (onUnsignField) {
await onUnsignField(payload);
} else {
await removeSignedFieldWithToken(payload);
}
if (fieldType === 'Checkbox') {
setCheckedValues([]);
}
startTransition(() => router.refresh());
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while removing the signature.',
variant: 'destructive',
});
}
};
const handleCheckboxChange = (value: string, itemId: number) => {
const updatedValue = value || `empty-value-${itemId}`;
const updatedValues = checkedValues.includes(updatedValue)
? checkedValues.filter((v) => v !== updatedValue)
: [...checkedValues, updatedValue];
setCheckedValues(updatedValues);
};
const handleCheckboxOptionClick = async (item: {
id: number;
checked: boolean;
value: string;
}) => {
let updatedValues: string[] = [];
try {
const isChecked = checkedValues.includes(
item.value.length > 0 ? item.value : `empty-value-${item.id}`,
);
if (!isChecked) {
updatedValues = [
...checkedValues,
item.value.length > 0 ? item.value : `empty-value-${item.id}`,
];
await removeSignedFieldWithToken({
token: recipient.token,
fieldId: field.id,
});
if (isLengthConditionMet) {
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: updatedValues.join(','),
isBase64: true,
});
}
} else {
updatedValues = checkedValues.filter(
(v) => v !== item.value && v !== `empty-value-${item.id}`,
);
await removeSignedFieldWithToken({
token: recipient.token,
fieldId: field.id,
});
}
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while updating the signature.',
variant: 'destructive',
});
} finally {
setCheckedValues(updatedValues);
startTransition(() => router.refresh());
}
};
useEffect(() => {
if (shouldAutoSignField) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
}
}, [checkedValues, isLengthConditionMet, field.inserted]);
return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Checkbox">
{isLoading && (
<div className="bg-background absolute inset-0 z-20 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<>
{!isLengthConditionMet && (
<FieldToolTip key={field.id} field={field} color="warning" className="">
{validationSign?.label} {checkboxValidationLength}
</FieldToolTip>
)}
<div className="z-50 flex flex-col gap-y-2">
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
const itemValue = item.value || `empty-value-${item.id}`;
return (
<div key={index} className="flex items-center gap-x-1.5">
<Checkbox
className="h-4 w-4"
checkClassName="text-white"
id={`checkbox-${index}`}
checked={checkedValues.includes(itemValue)}
onCheckedChange={() => handleCheckboxChange(item.value, item.id)}
/>
<Label htmlFor={`checkbox-${index}`}>
{item.value.includes('empty-value-') ? '' : item.value}
</Label>
</div>
);
})}
</div>
</>
)}
{field.inserted && (
<div className="flex flex-col gap-y-2">
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
const itemValue = item.value || `empty-value-${item.id}`;
return (
<div key={index} className="flex items-center gap-x-1.5">
<Checkbox
className="h-4 w-4"
checkClassName="text-white"
id={`checkbox-${index}`}
checked={field.customText
.split(',')
.some((customValue) => customValue === itemValue)}
disabled={isLoading}
onCheckedChange={() => void handleCheckboxOptionClick(item)}
/>
<Label htmlFor={`checkbox-${index}`}>
{item.value.includes('empty-value-') ? '' : item.value}
</Label>
</div>
);
})}
</div>
)}
</SigningFieldContainer>
);
};

View File

@ -139,11 +139,15 @@ export const DateField = ({
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Date</p>
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
Date
</p>
)}
{field.inserted && (
<p className="text-muted-foreground text-sm duration-200">{localDateString}</p>
<p className="text-muted-foreground dark:text-background/80 text-sm duration-200">
{localDateString}
</p>
)}
</SigningFieldContainer>
);

View File

@ -0,0 +1,209 @@
'use client';
import { useEffect, useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZDropdownFieldMeta } from '@documenso/lib/types/field-meta';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { SigningFieldContainer } from './signing-field-container';
export type DropdownFieldProps = {
field: FieldWithSignatureAndFieldMeta;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const DropdownField = ({
field,
recipient,
onSignField,
onUnsignField,
}: DropdownFieldProps) => {
const router = useRouter();
const { toast } = useToast();
const [isPending, startTransition] = useTransition();
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const parsedFieldMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
const isReadOnly = parsedFieldMeta?.readOnly;
const defaultValue = parsedFieldMeta?.defaultValue;
const [localChoice, setLocalChoice] = useState(parsedFieldMeta.defaultValue ?? '');
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const shouldAutoSignField =
(!field.inserted && localChoice) || (!field.inserted && isReadOnly && defaultValue);
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
if (!localChoice) {
return;
}
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
value: localChoice,
isBase64: true,
authOptions,
};
if (onSignField) {
await onSignField(payload);
} else {
await signFieldWithToken(payload);
}
setLocalChoice('');
startTransition(() => router.refresh());
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while signing the document.',
variant: 'destructive',
});
}
};
const onPreSign = () => {
return true;
};
const onRemove = async () => {
try {
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
};
if (onUnsignField) {
await onUnsignField(payload);
return;
} else {
await removeSignedFieldWithToken(payload);
}
setLocalChoice(parsedFieldMeta.defaultValue ?? '');
startTransition(() => router.refresh());
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while removing the signature.',
variant: 'destructive',
});
}
};
const handleSelectItem = (val: string) => {
setLocalChoice(val);
};
useEffect(() => {
if (!field.inserted && localChoice) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
}
}, [localChoice]);
useEffect(() => {
if (shouldAutoSignField) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
}
}, []);
return (
<div className="pointer-events-none">
<SigningFieldContainer
field={field}
onPreSign={onPreSign}
onSign={onSign}
onRemove={onRemove}
type="Dropdown"
>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200">
<Select value={parsedFieldMeta.defaultValue} onValueChange={handleSelectItem}>
<SelectTrigger
className={cn(
'text-muted-foreground z-10 h-full w-full border-none ring-0 focus:ring-0',
{
'hover:text-red-300': parsedFieldMeta.required,
'hover:text-yellow-300': !parsedFieldMeta.required,
},
)}
>
<SelectValue placeholder={'-- Select --'} />
</SelectTrigger>
<SelectContent className="w-full ring-0 focus:ring-0" position="popper">
{parsedFieldMeta?.values?.map((item, index) => (
<SelectItem key={index} value={item.value} className="ring-0 focus:ring-0">
{item.value}
</SelectItem>
))}
</SelectContent>
</Select>
</p>
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 flex items-center justify-center gap-x-1 duration-200">
{field.customText}
</p>
)}
</SigningFieldContainer>
</div>
);
};

View File

@ -119,10 +119,16 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Email</p>
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
Email
</p>
)}
{field.inserted && <p className="text-muted-foreground duration-200">{field.customText}</p>}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 truncate duration-200">
{field.customText}
</p>
)}
</SigningFieldContainer>
);
};

View File

@ -163,10 +163,16 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Name</p>
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
Name
</p>
)}
{field.inserted && <p className="text-muted-foreground duration-200">{field.customText}</p>}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 truncate duration-200">
{field.customText}
</p>
)}
<Dialog open={showFullNameModal} onOpenChange={setShowFullNameModal}>
<DialogContent>

View File

@ -0,0 +1,337 @@
'use client';
import { useEffect, useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Hash, Loader } from 'lucide-react';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZNumberFieldMeta } from '@documenso/lib/types/field-meta';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { SigningFieldContainer } from './signing-field-container';
type ValidationErrors = {
isNumber: string[];
required: string[];
minValue: string[];
maxValue: string[];
numberFormat: string[];
};
export type NumberFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const NumberField = ({ field, recipient, onSignField, onUnsignField }: NumberFieldProps) => {
const router = useRouter();
const { toast } = useToast();
const [isPending, startTransition] = useTransition();
const [showRadioModal, setShowRadioModal] = useState(false);
const parsedFieldMeta = field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null;
const isReadOnly = parsedFieldMeta?.readOnly;
const defaultValue = parsedFieldMeta?.value;
const [localNumber, setLocalNumber] = useState(
parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0',
);
const initialErrors: ValidationErrors = {
isNumber: [],
required: [],
minValue: [],
maxValue: [],
numberFormat: [],
};
const [errors, setErrors] = useState(initialErrors);
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value;
setLocalNumber(text);
if (parsedFieldMeta) {
const validationErrors = validateNumberField(text, parsedFieldMeta, true);
setErrors({
isNumber: validationErrors.filter((error) => error.includes('valid number')),
required: validationErrors.filter((error) => error.includes('required')),
minValue: validationErrors.filter((error) => error.includes('minimum value')),
maxValue: validationErrors.filter((error) => error.includes('maximum value')),
numberFormat: validationErrors.filter((error) => error.includes('number format')),
});
} else {
const validationErrors = validateNumberField(text);
setErrors((prevErrors) => ({
...prevErrors,
isNumber: validationErrors.filter((error) => error.includes('valid number')),
}));
}
};
const onDialogSignClick = () => {
setShowRadioModal(false);
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
};
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
if (!localNumber || Object.values(errors).some((error) => error.length > 0)) {
return;
}
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
value: localNumber,
isBase64: true,
authOptions,
};
if (onSignField) {
await onSignField(payload);
return;
}
await signFieldWithToken(payload);
setLocalNumber('');
startTransition(() => router.refresh());
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while signing the document.',
variant: 'destructive',
});
}
};
const onPreSign = () => {
setShowRadioModal(true);
if (localNumber && parsedFieldMeta) {
const validationErrors = validateNumberField(localNumber, parsedFieldMeta, true);
setErrors({
isNumber: validationErrors.filter((error) => error.includes('valid number')),
required: validationErrors.filter((error) => error.includes('required')),
minValue: validationErrors.filter((error) => error.includes('minimum value')),
maxValue: validationErrors.filter((error) => error.includes('maximum value')),
numberFormat: validationErrors.filter((error) => error.includes('number format')),
});
}
return false;
};
const onRemove = async () => {
try {
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
};
if (onUnsignField) {
await onUnsignField(payload);
return;
}
await removeSignedFieldWithToken(payload);
setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta?.value) : '');
startTransition(() => router.refresh());
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while removing the signature.',
variant: 'destructive',
});
}
};
useEffect(() => {
if (!showRadioModal) {
setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0');
setErrors(initialErrors);
}
}, [showRadioModal]);
useEffect(() => {
if (
(!field.inserted && defaultValue && localNumber) ||
(!field.inserted && isReadOnly && defaultValue)
) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
}
}, []);
let fieldDisplayName = 'Number';
if (parsedFieldMeta?.label) {
fieldDisplayName = parsedFieldMeta.label.length > 10 ? parsedFieldMeta.label.substring(0, 10) + '...' : parsedFieldMeta.label;
}
const userInputHasErrors = Object.values(errors).some((error) => error.length > 0);
return (
<SigningFieldContainer
field={field}
onPreSign={onPreSign}
onSign={onSign}
onRemove={onRemove}
type="Signature"
>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<p
className={cn(
'group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200',
{
'group-hover:text-yellow-300': !field.inserted && !parsedFieldMeta?.required,
'group-hover:text-red-300': !field.inserted && parsedFieldMeta?.required,
},
)}
>
<span className="flex items-center justify-center gap-x-1 text-sm">
<Hash className='h-4 w-4' /> {fieldDisplayName}
</span>
</p>
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 flex items-center justify-center gap-x-1 duration-200">
{field.customText}
</p>
)}
<Dialog open={showRadioModal} onOpenChange={setShowRadioModal}>
<DialogContent>
<DialogTitle>
{parsedFieldMeta?.label ? parsedFieldMeta?.label : 'Add number'}
</DialogTitle>
<div>
<Input
type="text"
placeholder={parsedFieldMeta?.placeholder ?? ''}
className={cn('mt-2 w-full rounded-md', {
'border-2 border-red-300 ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
userInputHasErrors,
})}
value={localNumber}
onChange={handleNumberChange}
/>
</div>
{userInputHasErrors && (
<div>
{errors.isNumber?.map((error, index) => (
<p key={index} className="mt-2 text-sm text-red-500">
{error}
</p>
))}
{errors.required?.map((error, index) => (
<p key={index} className="mt-2 text-sm text-red-500">
{error}
</p>
))}
{errors.minValue?.map((error, index) => (
<p key={index} className="mt-2 text-sm text-red-500">
{error}
</p>
))}
{errors.maxValue?.map((error, index) => (
<p key={index} className="mt-2 text-sm text-red-500">
{error}
</p>
))}
{errors.numberFormat?.map((error, index) => (
<p key={index} className="mt-2 text-sm text-red-500">
{error}
</p>
))}
</div>
)}
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowRadioModal(false);
setLocalNumber('');
}}
>
Cancel
</Button>
<Button
type="button"
className="flex-1"
disabled={!localNumber || userInputHasErrors}
onClick={() => onDialogSignClick()}
>
Save
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</SigningFieldContainer>
);
};

View File

@ -0,0 +1,190 @@
'use client';
import { useEffect, useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZRadioFieldMeta } from '@documenso/lib/types/field-meta';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { Label } from '@documenso/ui/primitives/label';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { SigningFieldContainer } from './signing-field-container';
export type RadioFieldProps = {
field: FieldWithSignatureAndFieldMeta;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const RadioField = ({ field, recipient, onSignField, onUnsignField }: RadioFieldProps) => {
const router = useRouter();
const { toast } = useToast();
const [isPending, startTransition] = useTransition();
const parsedFieldMeta = ZRadioFieldMeta.parse(field.fieldMeta);
const values = parsedFieldMeta.values?.map((item) => ({
...item,
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
}));
const checkedItem = values?.find((item) => item.checked);
const defaultValue = !field.inserted && !!checkedItem ? checkedItem.value : '';
const [selectedOption, setSelectedOption] = useState(defaultValue);
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const shouldAutoSignField =
(!field.inserted && selectedOption) ||
(!field.inserted && defaultValue) ||
(!field.inserted && parsedFieldMeta.readOnly && defaultValue);
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
if (!selectedOption) {
return;
}
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
value: selectedOption,
isBase64: true,
authOptions,
};
if (onSignField) {
await onSignField(payload);
} else {
await signFieldWithToken(payload);
}
setSelectedOption('');
startTransition(() => router.refresh());
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while signing the document.',
variant: 'destructive',
});
}
};
const onRemove = async () => {
try {
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
};
if (onUnsignField) {
await onUnsignField(payload);
} else {
await removeSignedFieldWithToken(payload);
}
setSelectedOption('');
startTransition(() => router.refresh());
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while removing the signature.',
variant: 'destructive',
});
}
};
const handleSelectItem = (selectedOption: string) => {
setSelectedOption(selectedOption);
};
useEffect(() => {
if (shouldAutoSignField) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
}
}, [selectedOption, field]);
return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Radio">
{isLoading && (
<div className="bg-background absolute inset-0 z-20 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<RadioGroup onValueChange={(value) => handleSelectItem(value)} className="z-10">
{values?.map((item, index) => (
<div key={index} className="flex items-center gap-x-1.5">
<RadioGroupItem
className="h-4 w-4 shrink-0"
value={item.value}
id={`option-${index}`}
checked={item.checked}
/>
<Label htmlFor={`option-${index}`}>
{item.value.includes('empty-value-') ? '' : item.value}
</Label>
</div>
))}
</RadioGroup>
)}
{field.inserted && (
<RadioGroup>
{values?.map((item, index) => (
<div key={index} className="flex items-center gap-x-1.5">
<RadioGroupItem
className=""
value={item.value}
id={`option-${index}`}
checked={item.value === field.customText}
/>
<Label htmlFor={`option-${index}`}>
{item.value.includes('empty-value-') ? '' : item.value}
</Label>
</div>
))}
</RadioGroup>
)}
</SigningFieldContainer>
);
};

View File

@ -190,7 +190,7 @@ export const SignatureField = ({
)}
{state === 'empty' && (
<p className="group-hover:text-primary font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
<p className="group-hover:text-primary font-signature text-muted-foreground duration-200 group-hover:text-yellow-300 text-xl">
Signature
</p>
)}
@ -199,12 +199,12 @@ export const SignatureField = ({
<img
src={signature.signatureImageAsBase64}
alt={`Signature for ${recipient.name}`}
className="h-full w-full object-contain dark:invert"
className="h-full w-full object-contain"
/>
)}
{state === 'signed-text' && (
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
<p className="font-signature text-muted-foreground dark:text-background text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
{/* This optional chaining is intentional, we don't want to move the check into the condition above */}
{signature?.typedSignature}
</p>

View File

@ -2,10 +2,14 @@
import React from 'react';
import { X } from 'lucide-react';
import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { FieldType } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { cn } from '@documenso/ui/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
@ -34,8 +38,8 @@ export type SignatureFieldProps = {
* The auth values will be passed in if available.
*/
onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise<void> | void;
onRemove?: () => Promise<void> | void;
type?: 'Date' | 'Email' | 'Name' | 'Signature';
onRemove?: (fieldType?: string) => Promise<void> | void;
type?: 'Date' | 'Email' | 'Name' | 'Signature' | 'Radio' | 'Dropdown' | 'Number' | 'Checkbox';
tooltipText?: string | null;
};
@ -51,6 +55,9 @@ export const SigningFieldContainer = ({
}: SignatureFieldProps) => {
const { executeActionAuthProcedure, isAuthRedirectRequired } = useRequiredDocumentAuthContext();
const parsedFieldMeta = field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined;
const readOnlyField = parsedFieldMeta?.readOnly || false;
const handleInsertField = async () => {
if (field.inserted || !onSign) {
return;
@ -102,41 +109,70 @@ export const SigningFieldContainer = ({
await onRemove?.();
};
const onClearCheckBoxValues = async (fieldType?: string) => {
if (!field.inserted) {
return;
}
await onRemove?.(fieldType);
};
return (
<FieldRootContainer field={field}>
{!field.inserted && !loading && (
<button
type="submit"
className="absolute inset-0 z-10 h-full w-full"
onClick={async () => handleInsertField()}
/>
)}
<div className={cn(type === 'Checkbox' ? 'group' : '')}>
<FieldRootContainer field={field}>
{!field.inserted && !loading && !readOnlyField && (
<button
type="submit"
className="absolute inset-0 z-10 h-full w-full rounded-md border"
onClick={async () => handleInsertField()}
/>
)}
{type === 'Date' && field.inserted && !loading && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
Remove
</button>
</TooltipTrigger>
{readOnlyField && (
<button className="bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100">
<span className="bg-foreground/50 dark:bg-background/50 text-background dark:text-foreground rounded-xl p-2">
Read only field
</span>
</button>
)}
{tooltipText && <TooltipContent className="max-w-xs">{tooltipText}</TooltipContent>}
</Tooltip>
)}
{type === 'Date' && field.inserted && !loading && !readOnlyField && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
Remove
</button>
</TooltipTrigger>
{type !== 'Date' && field.inserted && !loading && (
<button
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
Remove
</button>
)}
{tooltipText && <TooltipContent className="max-w-xs">{tooltipText}</TooltipContent>}
</Tooltip>
)}
{children}
</FieldRootContainer>
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<button
className="dark:bg-background absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
onClick={() => void onClearCheckBoxValues(type)}
>
<span className="dark:text-muted-foreground/50 dark:hover:text-muted-foreground dark:hover:bg-foreground/10 rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
<X className="h-4 w-4" />
</span>
</button>
)}
{type !== 'Date' && type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<button
className="text-destructive bg-background/50 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
Remove
</button>
)}
{children}
</FieldRootContainer>
</div>
);
};

View File

@ -4,9 +4,17 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import type { CompletedField } from '@documenso/lib/types/fields';
import type { Field, Recipient } from '@documenso/prisma/client';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
@ -14,10 +22,14 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
import { truncateTitle } from '~/helpers/truncate-title';
import { CheckboxField } from './checkbox-field';
import { DateField } from './date-field';
import { DropdownField } from './dropdown-field';
import { EmailField } from './email-field';
import { SigningForm } from './form';
import { NameField } from './name-field';
import { NumberField } from './number-field';
import { RadioField } from './radio-field';
import { SignatureField } from './signature-field';
import { TextField } from './text-field';
@ -101,9 +113,41 @@ export const SigningPageView = ({
.with(FieldType.EMAIL, () => (
<EmailField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.TEXT, () => (
<TextField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.TEXT, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null,
};
return <TextField key={field.id} field={fieldWithMeta} recipient={recipient} />;
})
.with(FieldType.NUMBER, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null,
};
return <NumberField key={field.id} field={fieldWithMeta} recipient={recipient} />;
})
.with(FieldType.RADIO, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null,
};
return <RadioField key={field.id} field={fieldWithMeta} recipient={recipient} />;
})
.with(FieldType.CHECKBOX, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null,
};
return <CheckboxField key={field.id} field={fieldWithMeta} recipient={recipient} />;
})
.with(FieldType.DROPDOWN, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null,
};
return <DropdownField key={field.id} field={fieldWithMeta} recipient={recipient} />;
})
.otherwise(() => null),
)}
</ElementVisible>

View File

@ -4,29 +4,31 @@ import { useEffect, useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { Loader, Type } from 'lucide-react';
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZTextFieldMeta } from '@documenso/lib/types/field-meta';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { SigningFieldContainer } from './signing-field-container';
export type TextFieldProps = {
field: FieldWithSignature;
field: FieldWithSignatureAndFieldMeta;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
@ -34,9 +36,16 @@ export type TextFieldProps = {
export const TextField = ({ field, recipient, onSignField, onUnsignField }: TextFieldProps) => {
const router = useRouter();
const { toast } = useToast();
const initialErrors: Record<string, string[]> = {
required: [],
characterLimit: [],
};
const [errors, setErrors] = useState(initialErrors);
const userInputHasErrors = Object.values(errors).some((error) => error.length > 0);
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const [isPending, startTransition] = useTransition();
@ -49,21 +58,52 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const parsedFieldMeta = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null;
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const shouldAutoSignField =
(!field.inserted && parsedFieldMeta?.text) ||
(!field.inserted && parsedFieldMeta?.text && parsedFieldMeta?.readOnly);
const [showCustomTextModal, setShowCustomTextModal] = useState(false);
const [localText, setLocalCustomText] = useState('');
const [localText, setLocalCustomText] = useState(parsedFieldMeta?.text ?? '');
useEffect(() => {
if (!showCustomTextModal) {
setLocalCustomText('');
setLocalCustomText(parsedFieldMeta?.text ?? '');
setErrors(initialErrors);
}
}, [showCustomTextModal]);
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const text = e.target.value;
setLocalCustomText(text);
if (parsedFieldMeta) {
const validationErrors = validateTextField(text, parsedFieldMeta, true);
setErrors({
required: validationErrors.filter((error) => error.includes('required')),
characterLimit: validationErrors.filter((error) => error.includes('character limit')),
});
}
};
/**
* When the user clicks the sign button in the dialog where they enter the text field.
*/
const onDialogSignClick = () => {
if (parsedFieldMeta) {
const validationErrors = validateTextField(localText, parsedFieldMeta, true);
if (validationErrors.length > 0) {
setErrors({
required: validationErrors.filter((error) => error.includes('required')),
characterLimit: validationErrors.filter((error) => error.includes('character limit')),
});
return;
}
}
setShowCustomTextModal(false);
void executeActionAuthProcedure({
@ -73,17 +113,22 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
};
const onPreSign = () => {
if (!localText) {
setShowCustomTextModal(true);
return false;
setShowCustomTextModal(true);
if (localText && parsedFieldMeta) {
const validationErrors = validateTextField(localText, parsedFieldMeta, true);
setErrors({
required: validationErrors.filter((error) => error.includes('required')),
characterLimit: validationErrors.filter((error) => error.includes('character limit')),
});
}
return true;
return false;
};
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
if (!localText) {
if (!localText || userInputHasErrors) {
return;
}
@ -136,6 +181,8 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
await removeSignedFieldWithToken(payload);
setLocalCustomText(parsedFieldMeta?.text ?? '');
startTransition(() => router.refresh());
} catch (err) {
console.error(err);
@ -148,6 +195,34 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
}
};
useEffect(() => {
if (shouldAutoSignField) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
}
}, []);
const parsedField = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : undefined;
const labelDisplay =
parsedField?.label && parsedField.label.length < 20
? parsedField.label
: parsedField?.label
? parsedField?.label.substring(0, 20) + '...'
: undefined;
const textDisplay =
parsedField?.text && parsedField.text.length < 20
? parsedField.text
: parsedField?.text
? parsedField?.text.substring(0, 20) + '...'
: undefined;
const fieldDisplayName = labelDisplay ? labelDisplay : textDisplay ? textDisplay : 'Add text';
const charactersRemaining = (parsedFieldMeta?.characterLimit ?? 0) - (localText.length ?? 0);
return (
<SigningFieldContainer
field={field}
@ -163,27 +238,69 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Text</p>
<p
className={cn(
'group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200',
{
'group-hover:text-yellow-300': !field.inserted && !parsedFieldMeta?.required,
'group-hover:text-red-300': !field.inserted && parsedFieldMeta?.required,
},
)}
>
<span className="flex items-center justify-center gap-x-1">
<Type />
{fieldDisplayName}
</span>
</p>
)}
{field.inserted && <p className="text-muted-foreground duration-200">{field.customText}</p>}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 flex items-center justify-center gap-x-1 duration-200">
{field.customText.length < 20
? field.customText
: field.customText.substring(0, 15) + '...'}
</p>
)}
<Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}>
<DialogContent>
<DialogTitle>
Enter your Text <span className="text-muted-foreground">({recipient.email})</span>
</DialogTitle>
<DialogTitle>{parsedFieldMeta?.label ? parsedFieldMeta?.label : 'Add Text'}</DialogTitle>
<div className="">
<Label htmlFor="custom-text">Custom Text</Label>
<Input
<div>
<Textarea
id="custom-text"
className="border-border mt-2 w-full rounded-md border"
onChange={(e) => setLocalCustomText(e.target.value)}
placeholder={parsedFieldMeta?.placeholder ?? 'Enter your text here'}
className={cn('mt-2 w-full rounded-md', {
'border-2 border-red-300 ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
userInputHasErrors,
})}
value={localText}
onChange={handleTextChange}
/>
</div>
{parsedFieldMeta?.characterLimit !== undefined && parsedFieldMeta?.characterLimit > 0 && !userInputHasErrors && (
<div className="text-muted-foreground text-sm">
{charactersRemaining} characters remaining
</div>
)}
{userInputHasErrors && (
<div className="text-sm">
{errors.required.map((error, index) => (
<p key={index} className="text-red-500">
{error}
</p>
))}
{errors.characterLimit.map((error, index) => (
<p key={index} className="text-red-500">
{error}{' '}
{charactersRemaining < 0 && `(${Math.abs(charactersRemaining)} characters over)`}
</p>
))}
</div>
)}
<DialogFooter>
<div className="mt-4 flex w-full flex-1 flex-nowrap gap-4">
<Button
@ -201,10 +318,10 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
<Button
type="button"
className="flex-1"
disabled={!localText}
disabled={!localText || userInputHasErrors}
onClick={() => onDialogSignClick()}
>
Save Text
Save
</Button>
</div>
</DialogFooter>

View File

@ -42,12 +42,12 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
<FieldRootContainer
field={field}
key={field.id}
cardClassName="border-gray-100/50 !shadow-none backdrop-blur-[1px] bg-background/90"
cardClassName="border-gray-300/50 !shadow-none backdrop-blur-[1px] bg-gray-50 ring-0 ring-offset-0"
>
<div className="absolute -right-3 -top-3">
<PopoverHover
trigger={
<Avatar className="dark:border-border h-8 w-8 border-2 border-solid border-gray-200/50 transition-colors hover:border-gray-200">
<Avatar className="dark:border-foreground h-8 w-8 border-2 border-solid border-gray-200/50 transition-colors hover:border-gray-200">
<AvatarFallback className="bg-neutral-50 text-xs text-gray-400">
{extractInitials(field.Recipient.name || field.Recipient.email)}
</AvatarFallback>
@ -78,7 +78,7 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
</PopoverHover>
</div>
<div className="text-muted-foreground break-all text-sm">
<div className="text-muted-foreground dark:text-background/70 break-all text-sm">
{field.Recipient.signingStatus === SigningStatus.SIGNED &&
match(field)
.with({ type: FieldType.SIGNATURE }, (field) =>
@ -95,9 +95,19 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
),
)
.with(
{ type: P.union(FieldType.NAME, FieldType.TEXT, FieldType.EMAIL) },
{
type: P.union(
FieldType.NAME,
FieldType.EMAIL,
FieldType.NUMBER,
FieldType.RADIO,
FieldType.CHECKBOX,
FieldType.DROPDOWN,
),
},
() => field.customText,
)
.with({ type: FieldType.TEXT }, () => field.customText.substring(0, 20) + '...')
.with({ type: FieldType.DATE }, () =>
convertToLocalSystemFormat(
field.customText,