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

@ -98,6 +98,7 @@ export const SinglePlayerClient = () => {
height: new Prisma.Decimal(field.pageHeight),
customText: '',
inserted: false,
fieldMeta: field.fieldMeta ?? {},
})),
);
@ -131,7 +132,9 @@ export const SinglePlayerClient = () => {
positionY: field.positionY.toNumber(),
width: field.width.toNumber(),
height: field.height.toNumber(),
fieldMeta: field.fieldMeta,
})),
fieldMeta: { type: undefined },
});
analytics.capture('Marketing: SPM - Document signed', {

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,

View File

@ -1034,6 +1034,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}
const field = await getFieldById({
userId: user.id,
teamId: team?.id,
fieldId: Number(fieldId),
documentId: Number(documentId),
}).catch(() => null);

View File

@ -0,0 +1,82 @@
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
interface CheckboxFieldMeta {
readOnly?: boolean;
required?: boolean;
validationRule?: string;
validationLength?: number;
}
export const validateCheckboxField = (
values: string[],
fieldMeta: CheckboxFieldMeta,
isSigningPage: boolean = false,
): string[] => {
const errors = [];
const { readOnly, required, validationRule, validationLength } = fieldMeta;
if (readOnly && required) {
errors.push('A field cannot be both read-only and required');
}
if (values.length === 0) {
errors.push('At least one option must be added');
}
if (readOnly && values.length === 0) {
errors.push('A read-only field must have at least one value');
}
if (isSigningPage && required && values.length === 0) {
errors.push('Selecting an option is required');
}
if (validationRule && !validationLength) {
errors.push('You need to specify the number of options for validation');
}
if (validationLength && !validationRule) {
errors.push('You need to specify the validation rule');
}
if (validationRule && validationLength) {
const validation = checkboxValidationSigns.find((sign) => sign.label === validationRule);
if (validation) {
let lengthCondition = false;
switch (validation.value) {
case '=':
lengthCondition = isSigningPage
? values.length !== validationLength
: values.length < validationLength;
break;
case '>=':
lengthCondition = values.length < validationLength;
break;
case '<=':
lengthCondition = isSigningPage
? values.length > validationLength
: values.length < validationLength;
break;
}
if (lengthCondition) {
let errorMessage;
if (isSigningPage) {
errorMessage = `You need to ${validationRule.toLowerCase()} ${validationLength} options`;
} else {
errorMessage =
validation.value === '<='
? `You need to select at least ${validationLength} options`
: `You need to add at least ${validationLength} options`;
}
errors.push(errorMessage);
}
}
}
return errors;
};

View File

@ -0,0 +1,54 @@
interface DropdownFieldMeta {
readOnly?: boolean;
required?: boolean;
values?: { value: string }[];
defaultValue?: string;
}
export const validateDropdownField = (
value: string | undefined,
fieldMeta: DropdownFieldMeta,
isSigningPage: boolean = false,
): string[] => {
const errors = [];
const { readOnly, required, values, defaultValue } = fieldMeta;
if (readOnly && required) {
errors.push('A field cannot be both read-only and required');
}
if (readOnly && (!values || values.length === 0)) {
errors.push('A read-only field must have at least one value');
}
if (isSigningPage && required && !value) {
errors.push('Choosing an option is required');
}
if (values && values.length === 0) {
errors.push('Select field must have at least one option');
}
if (values && values.length === 0 && defaultValue) {
errors.push('Default value must be one of the available options');
}
if (value && values && !values.find((item) => item.value === value)) {
errors.push('Selected value must be one of the available options');
}
if (values && defaultValue && !values.find((item) => item.value === defaultValue)) {
errors.push('Default value must be one of the available options');
}
if (values && values.some((item) => item.value.length < 1)) {
errors.push('Option value cannot be empty');
}
if (values && new Set(values.map((item) => item.value)).size !== values.length) {
errors.push('Duplicate values are not allowed');
}
return errors;
};

View File

@ -0,0 +1,67 @@
// import { numberFormatValues } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
interface NumberFieldMeta {
minValue?: number;
maxValue?: number;
readOnly?: boolean;
required?: boolean;
numberFormat?: string;
}
export const validateNumberField = (
value: string,
fieldMeta?: NumberFieldMeta,
isSigningPage: boolean = false,
): string[] => {
const errors = [];
const { minValue, maxValue, readOnly, required, numberFormat } = fieldMeta || {};
const formatRegex: { [key: string]: RegExp } = {
'123,456,789.00': /^(?:\d{1,3}(?:,\d{3})*|\d+)(?:\.\d{1,2})?$/,
'123.456.789,00': /^(?:\d{1,3}(?:\.\d{3})*|\d+)(?:,\d{1,2})?$/,
'123456,789.00': /^(?:\d+)(?:,\d{1,3}(?:\.\d{1,2})?)?$/,
};
const isValidFormat = numberFormat ? formatRegex[numberFormat].test(value) : true;
if (!isValidFormat) {
errors.push(`Value ${value} does not match the number format - ${numberFormat}`);
}
const numberValue = parseFloat(value);
if (isSigningPage && required && !value) {
errors.push('Value is required');
}
if (!/^[0-9,.]+$/.test(value.trim())) {
errors.push(`Value is not a valid number`);
}
if (minValue !== undefined && minValue > 0 && numberValue < minValue) {
errors.push(`Value ${value} is less than the minimum value of ${minValue}`);
}
if (maxValue !== undefined && maxValue > 0 && numberValue > maxValue) {
errors.push(`Value ${value} is greater than the maximum value of ${maxValue}`);
}
if (minValue !== undefined && maxValue !== undefined && minValue > maxValue) {
errors.push('Minimum value cannot be greater than maximum value');
}
if (maxValue !== undefined && minValue !== undefined && maxValue < minValue) {
errors.push('Maximum value cannot be less than minimum value');
}
if (readOnly && numberValue < 1) {
errors.push('A read-only field must have a value greater than 0');
}
if (readOnly && required) {
errors.push('A field cannot be both read-only and required');
}
return errors;
};

View File

@ -0,0 +1,41 @@
interface RadioFieldMeta {
readOnly?: boolean;
required?: boolean;
values?: { checked: boolean; value: string }[];
}
export const validateRadioField = (
value: string | undefined,
fieldMeta: RadioFieldMeta,
isSigningPage: boolean = false,
): string[] => {
const errors = [];
const { readOnly, required, values } = fieldMeta;
if (readOnly && required) {
errors.push('A field cannot be both read-only and required');
}
if (readOnly && (!values || values.length === 0)) {
errors.push('A read-only field must have at least one value');
}
if (isSigningPage && required && !value) {
errors.push('Choosing an option is required');
}
if (values) {
const checkedRadioFieldValues = values.filter((option) => option.checked);
if (values.length === 0) {
errors.push('Radio field must have at least one option');
}
if (checkedRadioFieldValues.length > 1) {
errors.push('There cannot be more than one checked option');
}
}
return errors;
};

View File

@ -0,0 +1,33 @@
interface TextFieldMeta {
characterLimit?: number;
readOnly?: boolean;
required?: boolean;
}
export const validateTextField = (
value: string,
fieldMeta: TextFieldMeta,
isSigningPage: boolean = false,
): string[] => {
const errors = [];
const { characterLimit, readOnly, required } = fieldMeta;
if (required && !value && isSigningPage) {
errors.push('Value is required');
}
if (characterLimit !== undefined && characterLimit > 0 && value.length > characterLimit) {
errors.push(`Value length (${value.length}) exceeds the character limit (${characterLimit})`);
}
if (readOnly && value.length < 1) {
errors.push('A read-only field must have text');
}
if (readOnly && required) {
errors.push('A field cannot be both read-only and required');
}
return errors;
};

View File

@ -0,0 +1,25 @@
import { useMemo } from 'react';
import type { CombinedStylesKey } from '../../../ui/primitives/document-flow/add-fields';
import { combinedStyles } from '../../../ui/primitives/document-flow/field-item';
const defaultFieldItemStyles = {
borderClass: 'border-field-card-border',
activeBorderClass: 'border-field-card-border/80',
initialsBGClass: 'text-field-card-foreground/50 bg-slate-900/10',
fieldBackground: 'bg-field-card-background',
};
export const useFieldItemStyles = (color: CombinedStylesKey | null) => {
return useMemo(() => {
if (!color) return defaultFieldItemStyles;
const selectedColorVariant = combinedStyles[color];
return {
activeBorderClass: selectedColorVariant?.borderActive,
borderClass: selectedColorVariant?.border,
initialsBGClass: selectedColorVariant?.initialsBG,
fieldBackground: selectedColorVariant?.fieldBackground,
};
}, [color]);
};

View File

@ -117,6 +117,9 @@ export const sealDocument = async ({
await insertFieldInPDF(doc, field);
}
// Re-flatten post-insertion to handle fields that create arcoFields
flattenForm(doc);
const pdfBytes = await doc.save();
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) });

View File

@ -1,5 +1,4 @@
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
@ -161,14 +160,7 @@ export const sendDocument = async ({
);
if (allRecipientsHaveNoActionToTake) {
const updatedDocument = await updateDocument({
documentId,
userId,
teamId,
data: { status: DocumentStatus.COMPLETED },
});
await sealDocument({ documentId: updatedDocument.id, requestMetadata });
await sealDocument({ documentId, requestMetadata });
// Keep the return type the same for the `sendDocument` method
return await prisma.document.findFirstOrThrow({

View File

@ -1,15 +1,47 @@
import { prisma } from '@documenso/prisma';
export type GetFieldByIdOptions = {
userId: number;
teamId?: number;
fieldId: number;
documentId: number;
documentId?: number;
templateId?: number;
};
export const getFieldById = async ({ fieldId, documentId }: GetFieldByIdOptions) => {
export const getFieldById = async ({
userId,
teamId,
fieldId,
documentId,
templateId,
}: GetFieldByIdOptions) => {
const field = await prisma.field.findFirst({
where: {
id: fieldId,
documentId,
templateId,
Document: {
OR:
teamId === undefined
? [
{
userId,
teamId: null,
},
]
: [
{
teamId,
team: {
members: {
some: {
userId,
},
},
},
},
],
},
},
});

View File

@ -1,12 +1,26 @@
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
import { validateRadioField } from '@documenso/lib/advanced-fields-validation/validate-radio';
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import {
type TFieldMetaSchema as FieldMeta,
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZFieldMetaSchema,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import {
createDocumentAuditLogData,
diffFieldChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { Field, FieldType } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import type { Field } from '@documenso/prisma/client';
import { FieldType, SendStatus, SigningStatus } from '@documenso/prisma/client';
export interface SetFieldsForDocumentOptions {
userId: number;
@ -20,6 +34,7 @@ export interface SetFieldsForDocumentOptions {
pageY: number;
pageWidth: number;
pageHeight: number;
fieldMeta?: FieldMeta;
}[];
requestMetadata?: RequestMetadata;
}
@ -103,6 +118,83 @@ export const setFieldsForDocument = async ({
linkedFields.map(async (field) => {
const fieldSignerEmail = field.signerEmail.toLowerCase();
const parsedFieldMeta = field.fieldMeta
? ZFieldMetaSchema.parse(field.fieldMeta)
: undefined;
if (field.type === FieldType.TEXT && field.fieldMeta) {
const textFieldParsedMeta = ZTextFieldMeta.parse(field.fieldMeta);
const errors = validateTextField(textFieldParsedMeta.text || '', textFieldParsedMeta);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
}
if (field.type === FieldType.NUMBER && field.fieldMeta) {
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(
String(numberFieldParsedMeta.value),
numberFieldParsedMeta,
);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
}
if (field.type === FieldType.CHECKBOX) {
if (field.fieldMeta) {
const checkboxFieldParsedMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
const errors = validateCheckboxField(
checkboxFieldParsedMeta?.values?.map((item) => item.value) ?? [],
checkboxFieldParsedMeta,
);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
} else {
throw new Error(
'To proceed further, please set at least one value for the Checkbox field',
);
}
}
if (field.type === FieldType.RADIO) {
if (field.fieldMeta) {
const radioFieldParsedMeta = ZRadioFieldMeta.parse(field.fieldMeta);
const checkedRadioFieldValue = radioFieldParsedMeta.values?.find(
(option) => option.checked,
)?.value;
const errors = validateRadioField(checkedRadioFieldValue, radioFieldParsedMeta);
if (errors.length > 0) {
throw new Error(errors.join('. '));
}
} else {
throw new Error(
'To proceed further, please set at least one value for the Radio field',
);
}
}
if (field.type === FieldType.DROPDOWN) {
if (field.fieldMeta) {
const dropdownFieldParsedMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
const errors = validateDropdownField(undefined, dropdownFieldParsedMeta);
if (errors.length > 0) {
throw new Error(errors.join('. '));
}
} else {
throw new Error(
'To proceed further, please set at least one value for the Dropdown field',
);
}
}
const upsertedField = await tx.field.upsert({
where: {
id: field._persisted?.id ?? -1,
@ -114,6 +206,7 @@ export const setFieldsForDocument = async ({
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
fieldMeta: parsedFieldMeta,
},
create: {
type: field.type,
@ -124,6 +217,7 @@ export const setFieldsForDocument = async ({
height: field.pageHeight,
customText: '',
inserted: false,
fieldMeta: parsedFieldMeta,
Document: {
connect: {
id: documentId,

View File

@ -1,5 +1,19 @@
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
import { validateRadioField } from '@documenso/lib/advanced-fields-validation/validate-radio';
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
import {
type TFieldMetaSchema as FieldMeta,
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZFieldMetaSchema,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import type { FieldType } from '@documenso/prisma/client';
import { FieldType } from '@documenso/prisma/client';
export type SetFieldsForTemplateOptions = {
userId: number;
@ -13,6 +27,7 @@ export type SetFieldsForTemplateOptions = {
pageY: number;
pageWidth: number;
pageHeight: number;
fieldMeta?: FieldMeta;
}[];
};
@ -70,8 +85,60 @@ export const setFieldsForTemplate = async ({
const persistedFields = await prisma.$transaction(
// Disabling as wrapping promises here causes type issues
// eslint-disable-next-line @typescript-eslint/promise-function-async
linkedFields.map((field) =>
prisma.field.upsert({
linkedFields.map((field) => {
const parsedFieldMeta = field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined;
if (field.type === FieldType.TEXT && field.fieldMeta) {
const textFieldParsedMeta = ZTextFieldMeta.parse(field.fieldMeta);
const errors = validateTextField(textFieldParsedMeta.text || '', textFieldParsedMeta);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
}
if (field.type === FieldType.NUMBER && field.fieldMeta) {
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(
String(numberFieldParsedMeta.value),
numberFieldParsedMeta,
);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
}
if (field.type === FieldType.CHECKBOX && field.fieldMeta) {
const checkboxFieldParsedMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
const errors = validateCheckboxField(
checkboxFieldParsedMeta?.values?.map((item) => item.value) ?? [],
checkboxFieldParsedMeta,
);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
}
if (field.type === FieldType.RADIO && field.fieldMeta) {
const radioFieldParsedMeta = ZRadioFieldMeta.parse(field.fieldMeta);
const checkedRadioFieldValue = radioFieldParsedMeta.values?.find(
(option) => option.checked,
)?.value;
const errors = validateRadioField(checkedRadioFieldValue, radioFieldParsedMeta);
if (errors.length > 0) {
throw new Error(errors.join('. '));
}
}
if (field.type === FieldType.DROPDOWN && field.fieldMeta) {
const dropdownFieldParsedMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
const errors = validateDropdownField(undefined, dropdownFieldParsedMeta);
if (errors.length > 0) {
throw new Error(errors.join('. '));
}
}
// Proceed with upsert operation
return prisma.field.upsert({
where: {
id: field._persisted?.id ?? -1,
templateId,
@ -82,6 +149,7 @@ export const setFieldsForTemplate = async ({
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
fieldMeta: parsedFieldMeta,
},
create: {
type: field.type,
@ -92,6 +160,7 @@ export const setFieldsForTemplate = async ({
height: field.pageHeight,
customText: '',
inserted: false,
fieldMeta: parsedFieldMeta,
Template: {
connect: {
id: templateId,
@ -106,8 +175,8 @@ export const setFieldsForTemplate = async ({
},
},
},
}),
),
});
}),
);
if (removedFields.length > 0) {

View File

@ -3,6 +3,11 @@
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
import { validateRadioField } from '@documenso/lib/advanced-fields-validation/validate-radio';
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
@ -10,6 +15,13 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuth } from '../../types/document-auth';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { validateFieldAuth } from '../document/validate-field-auth';
@ -87,6 +99,52 @@ export const signFieldWithToken = async ({
throw new Error(`Field ${fieldId} has no recipientId`);
}
if (field.type === FieldType.NUMBER && field.fieldMeta) {
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(value, numberFieldParsedMeta, true);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
}
if (field.type === FieldType.TEXT && field.fieldMeta) {
const textFieldParsedMeta = ZTextFieldMeta.parse(field.fieldMeta);
const errors = validateTextField(value, textFieldParsedMeta, true);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
}
if (field.type === FieldType.CHECKBOX && field.fieldMeta) {
const checkboxFieldParsedMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
const checkboxFieldValues = value.split(',');
const errors = validateCheckboxField(checkboxFieldValues, checkboxFieldParsedMeta, true);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
}
if (field.type === FieldType.RADIO && field.fieldMeta) {
const radioFieldParsedMeta = ZRadioFieldMeta.parse(field.fieldMeta);
const errors = validateRadioField(value, radioFieldParsedMeta, true);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
}
if (field.type === FieldType.DROPDOWN && field.fieldMeta) {
const dropdownFieldParsedMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
const errors = validateDropdownField(value, dropdownFieldParsedMeta, true);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
}
const derivedRecipientActionAuth = await validateFieldAuth({
documentAuthOptions: document.authOptions,
recipient,
@ -177,6 +235,16 @@ export const signFieldWithToken = async ({
type,
data: updatedField.customText,
}))
.with(
FieldType.NUMBER,
FieldType.RADIO,
FieldType.CHECKBOX,
FieldType.DROPDOWN,
(type) => ({
type,
data: updatedField.customText,
}),
)
.exhaustive(),
fieldSecurity: derivedRecipientActionAuth
? {

View File

@ -1,3 +1,4 @@
import { type TFieldMetaSchema as FieldMeta } from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import type { FieldType, Team } from '@documenso/prisma/client';
@ -18,6 +19,7 @@ export type UpdateFieldOptions = {
pageWidth?: number;
pageHeight?: number;
requestMetadata?: RequestMetadata;
fieldMeta?: FieldMeta;
};
export const updateField = async ({
@ -33,6 +35,7 @@ export const updateField = async ({
pageWidth,
pageHeight,
requestMetadata,
fieldMeta,
}: UpdateFieldOptions) => {
const oldField = await prisma.field.findFirstOrThrow({
where: {
@ -71,6 +74,7 @@ export const updateField = async ({
positionY: pageY,
width: pageWidth,
height: pageHeight,
fieldMeta,
},
include: {
Recipient: true,

View File

@ -1,7 +1,7 @@
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
import fontkit from '@pdf-lib/fontkit';
import { PDFDocument, RotationTypes, degrees, radiansToDegrees } from 'pdf-lib';
import { match } from 'ts-pattern';
import { P, match } from 'ts-pattern';
import {
DEFAULT_HANDWRITING_FONT_SIZE,
@ -13,6 +13,8 @@ import { FieldType } from '@documenso/prisma/client';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '../../types/field-meta';
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
const fontCaveat = await fetch(process.env.FONT_CAVEAT_URI).then(async (res) =>
res.arrayBuffer(),
@ -77,86 +79,163 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
await pdf.embedFont(fontCaveat);
}
const isInsertingImage =
isSignatureField && typeof field.Signature?.signatureImageAsBase64 === 'string';
await match(field)
.with(
{
type: P.union(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE),
Signature: { signatureImageAsBase64: P.string },
},
async (field) => {
const image = await pdf.embedPng(field.Signature?.signatureImageAsBase64 ?? '');
if (isSignatureField && isInsertingImage) {
const image = await pdf.embedPng(field.Signature?.signatureImageAsBase64 ?? '');
let imageWidth = image.width;
let imageHeight = image.height;
let imageWidth = image.width;
let imageHeight = image.height;
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
imageWidth = imageWidth * scalingFactor;
imageHeight = imageHeight * scalingFactor;
imageWidth = imageWidth * scalingFactor;
imageHeight = imageHeight * scalingFactor;
let imageX = fieldX + (fieldWidth - imageWidth) / 2;
let imageY = fieldY + (fieldHeight - imageHeight) / 2;
let imageX = fieldX + (fieldWidth - imageWidth) / 2;
let imageY = fieldY + (fieldHeight - imageHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system
imageY = pageHeight - imageY - imageHeight;
// Invert the Y axis since PDFs use a bottom-left coordinate system
imageY = pageHeight - imageY - imageHeight;
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
imageX,
imageY,
pageRotationInDegrees,
);
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
imageX,
imageY,
pageRotationInDegrees,
);
imageX = adjustedPosition.xPos;
imageY = adjustedPosition.yPos;
}
imageX = adjustedPosition.xPos;
imageY = adjustedPosition.yPos;
}
page.drawImage(image, {
x: imageX,
y: imageY,
width: imageWidth,
height: imageHeight,
rotate: degrees(pageRotationInDegrees),
});
},
)
.with({ type: FieldType.CHECKBOX }, (field) => {
const meta = ZCheckboxFieldMeta.safeParse(field.fieldMeta);
page.drawImage(image, {
x: imageX,
y: imageY,
width: imageWidth,
height: imageHeight,
rotate: degrees(pageRotationInDegrees),
if (!meta.success) {
console.error(meta.error);
throw new Error('Invalid checkbox field meta');
}
const selected = field.customText.split(',');
for (const [index, item] of (meta.data.values ?? []).entries()) {
const offsetY = index * 16;
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
if (selected.includes(item.value)) {
checkbox.check();
}
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + 16,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
checkbox.addToPage(page, {
x: fieldX,
y: pageHeight - (fieldY + offsetY),
height: 8,
width: 8,
});
}
})
.with({ type: FieldType.RADIO }, (field) => {
const meta = ZRadioFieldMeta.safeParse(field.fieldMeta);
if (!meta.success) {
console.error(meta.error);
throw new Error('Invalid radio field meta');
}
const selected = field.customText.split(',');
for (const [index, item] of (meta.data.values ?? []).entries()) {
const offsetY = index * 16;
const radio = pdf.getForm().createRadioGroup(`radio.${field.secondaryId}.${index}`);
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + 16,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
radio.addOptionToPage(item.value, page, {
x: fieldX,
y: pageHeight - (fieldY + offsetY),
height: 8,
width: 8,
});
if (selected.includes(item.value)) {
radio.select(item.value);
}
}
})
.otherwise((field) => {
const longestLineInTextForWidth = field.customText
.split('\n')
.sort((a, b) => b.length - a.length)[0];
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
const textHeight = font.heightAtSize(fontSize);
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
let textX = fieldX + (fieldWidth - textWidth) / 2;
let textY = fieldY + (fieldHeight - textHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system
textY = pageHeight - textY - textHeight;
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
textX,
textY,
pageRotationInDegrees,
);
textX = adjustedPosition.xPos;
textY = adjustedPosition.yPos;
}
page.drawText(field.customText, {
x: textX,
y: textY,
size: fontSize,
font,
rotate: degrees(pageRotationInDegrees),
});
});
} else {
const longestLineInTextForWidth = field.customText
.split('\n')
.sort((a, b) => b.length - a.length)[0];
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
const textHeight = font.heightAtSize(fontSize);
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
let textX = fieldX + (fieldWidth - textWidth) / 2;
let textY = fieldY + (fieldHeight - textHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system
textY = pageHeight - textY - textHeight;
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
textX,
textY,
pageRotationInDegrees,
);
textX = adjustedPosition.xPos;
textY = adjustedPosition.yPos;
}
page.drawText(field.customText, {
x: textX,
y: textY,
size: fontSize,
font,
rotate: degrees(pageRotationInDegrees),
});
}
return pdf;
};

View File

@ -13,6 +13,7 @@ import {
DocumentSource,
DocumentStatus,
FieldType,
Prisma,
RecipientRole,
SendStatus,
SigningStatus,
@ -26,6 +27,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuthTypes } from '../../types/document-auth';
import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import { ZFieldMetaSchema } from '../../types/field-meta';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import type { CreateDocumentAuditLogDataResponse } from '../../utils/document-audit-logs';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
@ -296,12 +298,16 @@ export const createDocumentFromDirectTemplate = async ({
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
})),
);
});
await tx.field.createMany({
data: nonDirectRecipientFieldsToCreate,
data: nonDirectRecipientFieldsToCreate.map((field) => ({
...field,
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
})),
});
// Create the direct recipient and their non signature fields.
@ -331,6 +337,7 @@ export const createDocumentFromDirectTemplate = async ({
height: templateField.height,
customText,
inserted: true,
fieldMeta: templateField.fieldMeta || Prisma.JsonNull,
})),
},
},
@ -361,6 +368,7 @@ export const createDocumentFromDirectTemplate = async ({
height: templateField.height,
customText: '',
inserted: true,
fieldMeta: templateField.fieldMeta || Prisma.JsonNull,
Signature: {
create: {
recipientId: createdDirectRecipient.id,
@ -454,10 +462,20 @@ export const createDocumentFromDirectTemplate = async ({
data:
field.Signature?.signatureImageAsBase64 || field.Signature?.typedSignature || '',
}))
.with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, FieldType.TEXT, (type) => ({
type,
data: field.customText,
}))
.with(
FieldType.DATE,
FieldType.EMAIL,
FieldType.NAME,
FieldType.TEXT,
FieldType.NUMBER,
FieldType.CHECKBOX,
FieldType.DROPDOWN,
FieldType.RADIO,
(type) => ({
type,
data: field.customText,
}),
)
.exhaustive(),
fieldSecurity: derivedRecipientActionAuth
? {

View File

@ -13,6 +13,7 @@ import {
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import { ZFieldMetaSchema } from '../../types/field-meta';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import {
@ -225,12 +226,16 @@ export const createDocumentFromTemplate = async ({
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
})),
);
});
await tx.field.createMany({
data: fieldsToCreate,
data: fieldsToCreate.map((field) => ({
...field,
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
})),
});
await tx.documentAuditLog.create({

View File

@ -253,6 +253,22 @@ export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = z.object({
type: z.union([z.literal(FieldType.SIGNATURE), z.literal(FieldType.FREE_SIGNATURE)]),
data: z.string(),
}),
z.object({
type: z.literal(FieldType.RADIO),
data: z.string(),
}),
z.object({
type: z.literal(FieldType.CHECKBOX),
data: z.string(),
}),
z.object({
type: z.literal(FieldType.DROPDOWN),
data: z.string(),
}),
z.object({
type: z.literal(FieldType.NUMBER),
data: z.string(),
}),
]),
fieldSecurity: z.preprocess(
(input) => {

View File

@ -0,0 +1,80 @@
import { z } from 'zod';
export const ZBaseFieldMeta = z.object({
label: z.string().optional(),
placeholder: z.string().optional(),
required: z.boolean().optional(),
readOnly: z.boolean().optional(),
});
export type TBaseFieldMeta = z.infer<typeof ZBaseFieldMeta>;
export const ZTextFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('text').default('text'),
text: z.string().optional(),
characterLimit: z.number().optional(),
});
export type TTextFieldMeta = z.infer<typeof ZTextFieldMeta>;
export const ZNumberFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('number').default('number'),
numberFormat: z.string().optional(),
value: z.string().optional(),
minValue: z.number().optional(),
maxValue: z.number().optional(),
});
export type TNumberFieldMeta = z.infer<typeof ZNumberFieldMeta>;
export const ZRadioFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('radio').default('radio'),
values: z
.array(
z.object({
id: z.number(),
checked: z.boolean(),
value: z.string(),
}),
)
.optional(),
});
export type TRadioFieldMeta = z.infer<typeof ZRadioFieldMeta>;
export const ZCheckboxFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('checkbox').default('checkbox'),
values: z
.array(
z.object({
id: z.number(),
checked: z.boolean(),
value: z.string(),
}),
)
.optional(),
validationRule: z.string().optional(),
validationLength: z.number().optional(),
});
export type TCheckboxFieldMeta = z.infer<typeof ZCheckboxFieldMeta>;
export const ZDropdownFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('dropdown').default('dropdown'),
values: z.array(z.object({ value: z.string() })).optional(),
defaultValue: z.string().optional(),
});
export type TDropdownFieldMeta = z.infer<typeof ZDropdownFieldMeta>;
export const ZFieldMetaSchema = z
.union([
ZTextFieldMeta,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
])
.optional();
export type TFieldMetaSchema = z.infer<typeof ZFieldMetaSchema>;

View File

@ -1,4 +1,4 @@
import { Field } from '@documenso/prisma/client';
import type { Field } from '@documenso/prisma/client';
/**
* Sort the fields by the Y position on the document.

View File

@ -0,0 +1,12 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "FieldType" ADD VALUE 'NUMBER';
ALTER TYPE "FieldType" ADD VALUE 'RADIO';
ALTER TYPE "FieldType" ADD VALUE 'CHECKBOX';
ALTER TYPE "FieldType" ADD VALUE 'DROPDOWN';

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Field" ADD COLUMN "fieldMeta" JSONB;

View File

@ -413,6 +413,10 @@ enum FieldType {
EMAIL
DATE
TEXT
NUMBER
RADIO
CHECKBOX
DROPDOWN
}
model Field {
@ -433,6 +437,7 @@ model Field {
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
Signature Signature?
fieldMeta Json?
@@index([documentId])
@@index([templateId])

View File

@ -0,0 +1,7 @@
import { type TFieldMetaSchema as FieldMeta } from '@documenso/lib/types/field-meta';
import type { Field, Signature } from '@documenso/prisma/client';
export type FieldWithSignatureAndFieldMeta = Field & {
Signature?: Signature | null;
fieldMeta: FieldMeta | null;
};

View File

@ -1,6 +1,7 @@
import { TRPCError } from '@trpc/server';
import { AppError } from '@documenso/lib/errors/app-error';
import { getFieldById } from '@documenso/lib/server-only/field/get-field-by-id';
import { removeSignedFieldWithToken } from '@documenso/lib/server-only/field/remove-signed-field-with-token';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template';
@ -11,6 +12,7 @@ import { authenticatedProcedure, procedure, router } from '../trpc';
import {
ZAddFieldsMutationSchema,
ZAddTemplateFieldsMutationSchema,
ZGetFieldQuerySchema,
ZRemovedSignedFieldWithTokenMutationSchema,
ZSignFieldWithTokenMutationSchema,
} from './schema';
@ -34,6 +36,7 @@ export const fieldRouter = router({
pageY: field.pageY,
pageWidth: field.pageWidth,
pageHeight: field.pageHeight,
fieldMeta: field.fieldMeta,
})),
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
@ -65,6 +68,7 @@ export const fieldRouter = router({
pageY: field.pageY,
pageWidth: field.pageWidth,
pageHeight: field.pageHeight,
fieldMeta: field.fieldMeta,
})),
});
} catch (err) {
@ -116,4 +120,49 @@ export const fieldRouter = router({
});
}
}),
getField: authenticatedProcedure.input(ZGetFieldQuerySchema).query(async ({ input, ctx }) => {
try {
const { fieldId, teamId } = input;
return await getFieldById({
userId: ctx.user.id,
teamId,
fieldId,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to find this field. Please try again.',
});
}
}),
// This doesn't appear to be used anywhere, and it doesn't seem to support updating template fields
// so commenting this out for now.
// updateField: authenticatedProcedure
// .input(ZUpdateFieldMutationSchema)
// .mutation(async ({ input, ctx }) => {
// try {
// const { documentId, fieldId, fieldMeta, teamId } = input;
// return await updateField({
// userId: ctx.user.id,
// teamId,
// fieldId,
// documentId,
// requestMetadata: extractNextApiRequestMetadata(ctx.req),
// fieldMeta: fieldMeta,
// });
// } catch (err) {
// console.error(err);
// throw new TRPCError({
// code: 'BAD_REQUEST',
// message: 'We were unable to set this field. Please try again later.',
// });
// }
// }),
});

View File

@ -1,6 +1,7 @@
import { z } from 'zod';
import { ZRecipientActionAuthSchema } from '@documenso/lib/types/document-auth';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { FieldType } from '@documenso/prisma/client';
export const ZAddFieldsMutationSchema = z.object({
@ -16,6 +17,7 @@ export const ZAddFieldsMutationSchema = z.object({
pageY: z.number().min(0),
pageWidth: z.number().min(0),
pageHeight: z.number().min(0),
fieldMeta: ZFieldMetaSchema,
}),
),
});
@ -35,6 +37,7 @@ export const ZAddTemplateFieldsMutationSchema = z.object({
pageY: z.number().min(0),
pageWidth: z.number().min(0),
pageHeight: z.number().min(0),
fieldMeta: ZFieldMetaSchema,
}),
),
});
@ -59,3 +62,17 @@ export const ZRemovedSignedFieldWithTokenMutationSchema = z.object({
export type TRemovedSignedFieldWithTokenMutationSchema = z.infer<
typeof ZRemovedSignedFieldWithTokenMutationSchema
>;
export const ZGetFieldQuerySchema = z.object({
fieldId: z.number(),
teamId: z.number().optional(),
});
export type TGetFieldQuerySchema = z.infer<typeof ZGetFieldQuerySchema>;
export const ZUpdateFieldMutationSchema = z.object({
fieldId: z.number(),
documentId: z.number(),
fieldMeta: ZFieldMetaSchema,
teamId: z.number().optional(),
});

View File

@ -23,6 +23,10 @@ export const mapField = (
.with(FieldType.EMAIL, () => signer.email)
.with(FieldType.NAME, () => signer.name)
.with(FieldType.TEXT, () => signer.customText)
.with(FieldType.NUMBER, () => signer.customText)
.with(FieldType.RADIO, () => signer.customText)
.with(FieldType.CHECKBOX, () => signer.customText)
.with(FieldType.DROPDOWN, () => signer.customText)
.otherwise(() => '');
return {

View File

@ -31,7 +31,7 @@ export const singleplayerRouter = router({
.input(ZCreateSinglePlayerDocumentMutationSchema)
.mutation(async ({ input }) => {
try {
const { signer, fields, documentData, documentName } = input;
const { signer, fields, documentData, documentName, fieldMeta } = input;
const document = await getFile({
data: documentData.data,
@ -69,6 +69,7 @@ export const singleplayerRouter = router({
documentId: -1,
templateId: null,
recipientId: -1,
fieldMeta: fieldMeta || null,
});
}

View File

@ -1,5 +1,6 @@
import { z } from 'zod';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { DocumentDataType, FieldType } from '@documenso/prisma/client';
export const ZCreateSinglePlayerDocumentMutationSchema = z.object({
@ -24,6 +25,7 @@ export const ZCreateSinglePlayerDocumentMutationSchema = z.object({
height: z.number(),
}),
),
fieldMeta: ZFieldMetaSchema,
});
export type TCreateSinglePlayerDocumentMutationSchema = z.infer<

View File

@ -1,10 +1,12 @@
'use client';
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import type { Field } from '@documenso/prisma/client';
import { cn } from '../../lib/utils';
@ -22,6 +24,51 @@ export type FieldContainerPortalProps = {
cardClassName?: string;
};
const getCardClassNames = (
field: Field,
parsedField: TFieldMetaSchema | null,
isValidating: boolean,
checkBoxOrRadio: boolean,
cardClassName?: string,
) => {
const baseClasses = 'field-card-container relative z-20 h-full w-full transition-all';
const insertedClasses =
'bg-documenso/20 border-documenso ring-documenso-200 ring-offset-documenso-200 ring-2 ring-offset-2 dark:shadow-none';
const nonRequiredClasses =
'border-yellow-300 shadow-none ring-2 ring-yellow-100 ring-offset-2 ring-offset-yellow-100 dark:border-2';
const validatingClasses = 'border-orange-300 ring-1 ring-orange-300';
const requiredClasses =
'border-red-500 shadow-none ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 hover:text-red-500';
const requiredCheckboxRadioClasses = 'border-dashed border-red-500';
if (checkBoxOrRadio) {
return cn(
{
[insertedClasses]: field.inserted,
'ring-offset-yellow-200 border-dashed border-yellow-300 ring-2 ring-yellow-200 ring-offset-2 dark:shadow-none':
!field.inserted && !parsedField?.required,
'shadow-none': !field.inserted,
[validatingClasses]: !field.inserted && isValidating,
[requiredCheckboxRadioClasses]: !field.inserted && parsedField?.required,
},
cardClassName,
);
}
return cn(
baseClasses,
{
[insertedClasses]: field.inserted,
[nonRequiredClasses]: !field.inserted && !parsedField?.required,
'shadow-none': !field.inserted && checkBoxOrRadio,
[validatingClasses]: !field.inserted && isValidating,
[requiredClasses]: !field.inserted && parsedField?.required && !checkBoxOrRadio,
},
cardClassName,
);
};
export function FieldContainerPortal({
field,
children,
@ -29,16 +76,22 @@ export function FieldContainerPortal({
}: FieldContainerPortalProps) {
const coords = useFieldPageCoords(field);
const isCheckboxOrRadioField = field.type === 'CHECKBOX' || field.type === 'RADIO';
const isFieldSigned = field.inserted;
const style = {
top: `${coords.y}px`,
left: `${coords.x}px`,
// height: `${coords.height}px`,
// width: `${coords.width}px`,
...((!isCheckboxOrRadioField) && {
height: `${coords.height}px`,
width: `${coords.width}px`,
}),
};
return createPortal(
<div
className={cn('absolute', className)}
style={{
top: `${coords.y}px`,
left: `${coords.x}px`,
height: `${coords.height}px`,
width: `${coords.width}px`,
}}
>
<div className={cn('absolute', className)} style={style}>
{children}
</div>,
document.body,
@ -47,7 +100,6 @@ export function FieldContainerPortal({
export function FieldRootContainer({ field, children, cardClassName }: FieldContainerPortalProps) {
const [isValidating, setIsValidating] = useState(false);
const ref = React.useRef<HTMLDivElement>(null);
useEffect(() => {
@ -70,19 +122,27 @@ export function FieldRootContainer({ field, children, cardClassName }: FieldCont
};
}, []);
const parsedField = useMemo(
() => (field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : null),
[field.fieldMeta],
);
const isCheckboxOrRadio = useMemo(
() => parsedField?.type === 'checkbox' || parsedField?.type === 'radio',
[parsedField],
);
const cardClassNames = useMemo(
() => getCardClassNames(field, parsedField, isValidating, isCheckboxOrRadio, cardClassName),
[field, parsedField, isValidating, isCheckboxOrRadio, cardClassName],
);
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,
},
cardClassName,
)}
ref={ref}
data-inserted={field.inserted ? 'true' : 'false'}
className={cardClassNames}
>
<CardContent className="text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2">
{children}

View File

@ -0,0 +1,108 @@
// !: We declare all of our classes here since TailwindCSS will remove any unused CSS classes,
// !: therefore doing this at runtime is not possible without whitelisting a set of classnames.
// !:
// !: This will later be improved as we move to a CSS variable approach and rotate the lightness
// !: values of the declared variable to do all the background, border and shadow styles.
export const SIGNER_COLOR_STYLES = {
green: {
default: {
background: 'bg-[hsl(var(--signer-green))]',
base: 'rounded-lg shadow-[0_0_0_5px_hsl(var(--signer-green)/10%),0_0_0_2px_hsl(var(--signer-green)/60%),0_0_0_0.5px_hsl(var(--signer-green))]',
fieldItem:
'group/field-item p-2 border-none ring-none hover:bg-gradient-to-r hover:from-[hsl(var(--signer-green))]/10 hover:to-[hsl(var(--signer-green))]/10',
fieldItemInitials:
'opacity-0 transition duration-200 group-hover/field-item:opacity-100 group-hover/field-item:bg-[hsl(var(--signer-green))]',
comboxBoxItem:
'hover:bg-[hsl(var(--signer-green)/15%)] active:bg-[hsl(var(--signer-green)/15%)]',
},
},
blue: {
default: {
background: 'bg-[hsl(var(--signer-blue))]',
base: 'rounded-lg shadow-[0_0_0_5px_hsl(var(--signer-blue)/10%),0_0_0_2px_hsl(var(--signer-blue)/60%),0_0_0_0.5px_hsl(var(--signer-blue))]',
fieldItem:
'group/field-item p-2 border-none ring-none hover:bg-gradient-to-r hover:from-[hsl(var(--signer-blue))]/10 hover:to-[hsl(var(--signer-blue))]/10',
fieldItemInitials:
'opacity-0 transition duration-200 group-hover/field-item:opacity-100 group-hover/field-item:bg-[hsl(var(--signer-blue))]',
comboxBoxItem:
'hover:bg-[hsl(var(--signer-blue)/15%)] active:bg-[hsl(var(--signer-blue)/15%)]',
},
},
purple: {
default: {
background: 'bg-[hsl(var(--signer-purple))]',
base: 'rounded-lg shadow-[0_0_0_5px_hsl(var(--signer-purple)/10%),0_0_0_2px_hsl(var(--signer-purple)/60%),0_0_0_0.5px_hsl(var(--signer-purple))]',
fieldItem:
'group/field-item p-2 border-none ring-none hover:bg-gradient-to-r hover:from-[hsl(var(--signer-purple))]/10 hover:to-[hsl(var(--signer-purple))]/10',
fieldItemInitials:
'opacity-0 transition duration-200 group-hover/field-item:opacity-100 group-hover/field-item:bg-[hsl(var(--signer-purple))]',
comboxBoxItem:
'hover:bg-[hsl(var(--signer-purple)/15%)] active:bg-[hsl(var(--signer-purple)/15%)]',
},
},
orange: {
default: {
background: 'bg-[hsl(var(--signer-orange))]',
base: 'rounded-lg shadow-[0_0_0_5px_hsl(var(--signer-orange)/10%),0_0_0_2px_hsl(var(--signer-orange)/60%),0_0_0_0.5px_hsl(var(--signer-orange))]',
fieldItem:
'group/field-item p-2 border-none ring-none hover:bg-gradient-to-r hover:from-[hsl(var(--signer-orange))]/10 hover:to-[hsl(var(--signer-orange))]/10',
fieldItemInitials:
'opacity-0 transition duration-200 group-hover/field-item:opacity-100 group-hover/field-item:bg-[hsl(var(--signer-orange))]',
comboxBoxItem:
'hover:bg-[hsl(var(--signer-orange)/15%)] active:bg-[hsl(var(--signer-orange)/15%)]',
},
},
yellow: {
default: {
background: 'bg-[hsl(var(--signer-yellow))]',
base: 'rounded-lg shadow-[0_0_0_5px_hsl(var(--signer-yellow)/10%),0_0_0_2px_hsl(var(--signer-yellow)/60%),0_0_0_0.5px_hsl(var(--signer-yellow))]',
fieldItem:
'group/field-item p-2 border-none ring-none hover:bg-gradient-to-r hover:from-[hsl(var(--signer-yellow))]/10 hover:to-[hsl(var(--signer-yellow))]/10',
fieldItemInitials:
'opacity-0 transition duration-200 group-hover/field-item:opacity-100 group-hover/field-item:bg-[hsl(var(--signer-yellow))]',
comboxBoxItem:
'hover:bg-[hsl(var(--signer-yellow)/15%)] active:bg-[hsl(var(--signer-yellow)/15%)]',
},
},
pink: {
default: {
background: 'bg-[hsl(var(--signer-pink))]',
base: 'rounded-lg shadow-[0_0_0_5px_hsl(var(--signer-pink)/10%),0_0_0_2px_hsl(var(--signer-pink)/60%),0_0_0_0.5px_hsl(var(--signer-pink))]',
fieldItem:
'group/field-item p-2 border-none ring-none hover:bg-gradient-to-r hover:from-[hsl(var(--signer-pink))]/10 hover:to-[hsl(var(--signer-pink))]/10',
fieldItemInitials:
'opacity-0 transition duration-200 group-hover/field-item:opacity-100 group-hover/field-item:bg-[hsl(var(--signer-pink))]',
comboxBoxItem:
'hover:bg-[hsl(var(--signer-pink)/15%)] active:bg-[hsl(var(--signer-pink)/15%)]',
},
},
};
export type CombinedStylesKey = keyof typeof SIGNER_COLOR_STYLES;
export const AVAILABLE_SIGNER_COLORS = [
'green',
'blue',
'purple',
'orange',
'yellow',
'pink',
] satisfies CombinedStylesKey[];
export const useSignerColors = (index: number) => {
const key = AVAILABLE_SIGNER_COLORS[index % AVAILABLE_SIGNER_COLORS.length];
return SIGNER_COLOR_STYLES[key];
};
export const getSignerColorStyles = (index: number) => {
// Disabling the rule since the hook doesn't do anything special and can
// be used universally.
// eslint-disable-next-line react-hooks/rules-of-hooks
return useSignerColors(index);
};

View File

@ -16,7 +16,7 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'border-input bg-background ring-offset-background focus-visible:ring-ring data-[state=checked]:border-primary peer h-4 w-4 shrink-0 rounded-sm border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'border-input bg-background ring-offset-background focus-visible:ring-ring data-[state=checked]:border-primary data-[state=checked]:bg-primary peer h-4 w-4 shrink-0 rounded-sm border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}

View File

@ -4,18 +4,34 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Caveat } from 'next/font/google';
import { Check, ChevronsUpDown, Info } from 'lucide-react';
import {
CalendarDays,
Check,
CheckSquare,
ChevronDown,
ChevronsUpDown,
Disc,
Hash,
Info,
Mail,
Type,
User,
} from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import {
type TFieldMetaSchema as FieldMeta,
ZFieldMetaSchema,
} from '@documenso/lib/types/field-meta';
import { nanoid } from '@documenso/lib/universal/id';
import type { Field, Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { FieldType, SendStatus } from '@documenso/prisma/client';
import { FieldType, RecipientRole, SendStatus } from '@documenso/prisma/client';
import { getSignerColorStyles, useSignerColors } from '../../lib/signer-colors';
import { cn } from '../../lib/utils';
import { Button } from '../button';
import { Card, CardContent } from '../card';
@ -32,6 +48,7 @@ import {
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { FieldItem } from './field-item';
import { FieldAdvancedSettings } from './field-item-advanced-settings';
import { MissingSignatureFieldDialog } from './missing-signature-field-dialog';
import { type DocumentFlowStep, FRIENDLY_FIELD_TYPE } from './types';
@ -42,11 +59,21 @@ const fontCaveat = Caveat({
variable: '--font-caveat',
});
const DEFAULT_HEIGHT_PERCENT = 5;
const DEFAULT_WIDTH_PERCENT = 15;
const MIN_HEIGHT_PX = 40;
const MIN_WIDTH_PX = 140;
const MIN_HEIGHT_PX = 60;
const MIN_WIDTH_PX = 200;
export type FieldFormType = {
nativeId?: number;
formId: string;
pageNumber: number;
type: FieldType;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
signerEmail: string;
fieldMeta?: FieldMeta;
};
export type AddFieldsFormProps = {
documentFlow: DocumentFlowStep;
@ -56,8 +83,15 @@ export type AddFieldsFormProps = {
onSubmit: (_data: TAddFieldsFormSchema) => void;
canGoBack?: boolean;
isDocumentPdfLoaded: boolean;
teamId?: number;
};
/*
I hate this, but due to TailwindCSS JIT, I couldnn't find a better way to do this for now.
TODO: Try to find a better way to do this.
*/
export const AddFieldsFormPartial = ({
documentFlow,
hideRecipients = false,
@ -66,6 +100,7 @@ export const AddFieldsFormPartial = ({
onSubmit,
canGoBack = false,
isDocumentPdfLoaded,
teamId,
}: AddFieldsFormProps) => {
const [isMissingSignatureDialogVisible, setIsMissingSignatureDialogVisible] = useState(false);
@ -73,11 +108,15 @@ export const AddFieldsFormPartial = ({
const { currentStep, totalSteps, previousStep } = useStep();
const canRenderBackButtonAsRemove =
currentStep === 1 && typeof documentFlow.onBackStep === 'function' && canGoBack;
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
const [currentField, setCurrentField] = useState<FieldFormType>();
const {
control,
handleSubmit,
formState: { isSubmitting },
setValue,
getValues,
} = useForm<TAddFieldsFormSchema>({
defaultValues: {
fields: fields.map((field) => ({
@ -91,11 +130,30 @@ export const AddFieldsFormPartial = ({
pageHeight: Number(field.height),
signerEmail:
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
})),
},
});
const onFormSubmit = handleSubmit(onSubmit);
const handleSavedFieldSettings = (fieldState: FieldMeta) => {
const initialValues = getValues();
const updatedFields = initialValues.fields.map((field) => {
if (field.formId === currentField?.formId) {
const parsedFieldMeta = ZFieldMetaSchema.parse(fieldState);
return {
...field,
fieldMeta: parsedFieldMeta,
};
}
return field;
});
setValue('fields', updatedFields);
};
const {
append,
@ -110,9 +168,45 @@ export const AddFieldsFormPartial = ({
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const selectedSignerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id);
const selectedSignerStyles = useSignerColors(
selectedSignerIndex === -1 ? 0 : selectedSignerIndex,
);
const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT;
const filterFieldsWithEmptyValues = (fields: typeof localFields, fieldType: string) =>
fields
.filter((field) => field.type === fieldType)
.filter((field) => {
if (field.fieldMeta && 'values' in field.fieldMeta) {
return field.fieldMeta.values?.length === 0;
}
return true;
});
const emptyCheckboxFields = useMemo(
() => filterFieldsWithEmptyValues(localFields, FieldType.CHECKBOX),
// eslint-disable-next-line react-hooks/exhaustive-deps
[localFields],
);
const emptyRadioFields = useMemo(
() => filterFieldsWithEmptyValues(localFields, FieldType.RADIO),
// eslint-disable-next-line react-hooks/exhaustive-deps
[localFields],
);
const emptySelectFields = useMemo(
() => filterFieldsWithEmptyValues(localFields, FieldType.DROPDOWN),
// eslint-disable-next-line react-hooks/exhaustive-deps
[localFields],
);
const hasErrors =
emptyCheckboxFields.length > 0 || emptyRadioFields.length > 0 || emptySelectFields.length > 0;
const isFieldsDisabled =
!selectedSigner ||
hasSelectedSignerBeenSent ||
@ -195,6 +289,7 @@ export const AddFieldsFormPartial = ({
pageWidth: fieldPageWidth,
pageHeight: fieldPageHeight,
signerEmail: selectedSigner.email,
fieldMeta: undefined,
});
setIsFieldWithinBounds(false);
@ -276,11 +371,9 @@ export const AddFieldsFormPartial = ({
return;
}
const { height, width } = $page.getBoundingClientRect();
fieldBounds.current = {
height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX),
width: Math.max(width * (DEFAULT_WIDTH_PERCENT / 100), MIN_WIDTH_PX),
height: Math.max(MIN_HEIGHT_PX),
width: Math.max(MIN_WIDTH_PX),
};
});
@ -320,6 +413,10 @@ export const AddFieldsFormPartial = ({
);
}, [recipientsByRole]);
const handleAdvancedSettings = () => {
setShowAdvancedSettings((prev) => !prev);
};
const handleGoNextClick = () => {
const everySignerHasSignature = recipientsByRole.SIGNER.every((signer) =>
localFields.some(
@ -338,297 +435,473 @@ export const AddFieldsFormPartial = ({
return (
<>
<DocumentFlowFormContainerHeader
title={documentFlow.title}
description={documentFlow.description}
/>
<DocumentFlowFormContainerContent>
<div className="flex flex-col">
{selectedField && (
<Card
className={cn(
'bg-field-card/80 pointer-events-none fixed z-50 cursor-pointer border-2 backdrop-blur-[1px]',
{
'border-field-card-border': isFieldWithinBounds,
'opacity-50': !isFieldWithinBounds,
},
)}
style={{
top: coords.y,
left: coords.x,
height: fieldBounds.current.height,
width: fieldBounds.current.width,
}}
>
<CardContent className="text-field-card-foreground flex h-full w-full items-center justify-center p-2">
{FRIENDLY_FIELD_TYPE[selectedField]}
</CardContent>
</Card>
)}
{isDocumentPdfLoaded &&
localFields.map((field, index) => (
<FieldItem
key={index}
field={field}
disabled={selectedSigner?.email !== field.signerEmail || hasSelectedSignerBeenSent}
minHeight={fieldBounds.current.height}
minWidth={fieldBounds.current.width}
passive={isFieldWithinBounds && !!selectedField}
onResize={(options) => onFieldResize(options, index)}
onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)}
/>
))}
{!hideRecipients && (
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
className="bg-background text-muted-foreground mb-12 justify-between font-normal"
{showAdvancedSettings && currentField ? (
<FieldAdvancedSettings
title="Advanced settings"
description={`Configure the ${FRIENDLY_FIELD_TYPE[currentField.type]} field`}
field={currentField}
fields={localFields}
onAdvancedSettings={handleAdvancedSettings}
isDocumentPdfLoaded={isDocumentPdfLoaded}
onSave={handleSavedFieldSettings}
teamId={teamId}
/>
) : (
<>
<DocumentFlowFormContainerHeader
title={documentFlow.title}
description={documentFlow.description}
/>
<DocumentFlowFormContainerContent>
<div className="flex flex-col">
{selectedField && (
<div
className={cn(
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200',
selectedSignerStyles.default.base,
{
'-rotate-6 scale-90 opacity-50': !isFieldWithinBounds,
},
)}
style={{
top: coords.y,
left: coords.x,
height: fieldBounds.current.height,
width: fieldBounds.current.width,
}}
>
{selectedSigner?.email && (
<span className="flex-1 truncate text-left">
{selectedSigner?.name} ({selectedSigner?.email})
</span>
)}
{FRIENDLY_FIELD_TYPE[selectedField]}
</div>
)}
{!selectedSigner?.email && (
<span className="flex-1 truncate text-left">{selectedSigner?.email}</span>
)}
{isDocumentPdfLoaded &&
localFields.map((field, index) => {
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail);
<ChevronsUpDown className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
return (
<FieldItem
key={index}
recipientIndex={recipientIndex === -1 ? 0 : recipientIndex}
field={field}
disabled={
selectedSigner?.email !== field.signerEmail || hasSelectedSignerBeenSent
}
minHeight={fieldBounds.current.height}
minWidth={fieldBounds.current.width}
passive={isFieldWithinBounds && !!selectedField}
onResize={(options) => onFieldResize(options, index)}
onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)}
onAdvancedSettings={() => {
setCurrentField(field);
handleAdvancedSettings();
}}
hideRecipients={hideRecipients}
/>
);
})}
<PopoverContent className="p-0" align="start">
<Command value={selectedSigner?.email}>
<CommandInput />
<CommandEmpty>
<span className="text-muted-foreground inline-block px-4">
No recipient matching this description was found.
</span>
</CommandEmpty>
{recipientsByRoleToDisplay.map(([role, recipients], roleIndex) => (
<CommandGroup key={roleIndex}>
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
{`${RECIPIENT_ROLES_DESCRIPTION[role].roleName}s`}
</div>
{recipients.length === 0 && (
<div
key={`${role}-empty`}
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
>
No recipients with this role
</div>
{!hideRecipients && (
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
className={cn(
'bg-background text-muted-foreground hover:text-foreground mb-12 mt-2 justify-between font-normal',
selectedSignerStyles.default.base,
)}
>
{selectedSigner?.email && (
<span className="flex-1 truncate text-left">
{selectedSigner?.name} ({selectedSigner?.email})
</span>
)}
{recipients.map((recipient) => (
<CommandItem
key={recipient.id}
className={cn('px-2 last:mb-1 [&:not(:first-child)]:mt-1', {
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
})}
onSelect={() => {
setSelectedSigner(recipient);
setShowRecipientsSelector(false);
}}
>
<span
className={cn('text-foreground/70 truncate', {
'text-foreground/80': recipient === selectedSigner,
})}
>
{recipient.name && (
<span title={`${recipient.name} (${recipient.email})`}>
{recipient.name} ({recipient.email})
</span>
)}
{!selectedSigner?.email && (
<span className="gradie flex-1 truncate text-left">
{selectedSigner?.email}
</span>
)}
{!recipient.name && (
<span title={recipient.email}>{recipient.email}</span>
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
<div className="ml-auto flex items-center justify-center">
{recipient.sendStatus !== SendStatus.SENT ? (
<Check
aria-hidden={recipient !== selectedSigner}
className={cn('h-4 w-4 flex-shrink-0', {
'opacity-0': recipient !== selectedSigner,
'opacity-100': recipient === selectedSigner,
})}
/>
) : (
<Tooltip>
<TooltipTrigger>
<Info className="ml-2 h-4 w-4" />
</TooltipTrigger>
<PopoverContent className="p-0" align="start">
<Command value={selectedSigner?.email}>
<CommandInput />
<TooltipContent className="text-muted-foreground max-w-xs">
This document has already been sent to this recipient. You can no
longer edit this recipient.
</TooltipContent>
</Tooltip>
)}
<CommandEmpty>
<span className="text-muted-foreground inline-block px-4">
No recipient matching this description was found.
</span>
</CommandEmpty>
{recipientsByRoleToDisplay.map(([role, roleRecipients], roleIndex) => (
<CommandGroup key={roleIndex}>
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
{`${RECIPIENT_ROLES_DESCRIPTION[role].roleName}s`}
</div>
</CommandItem>
{roleRecipients.length === 0 && (
<div
key={`${role}-empty`}
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
>
No recipients with this role
</div>
)}
{roleRecipients.map((recipient) => (
<CommandItem
key={recipient.id}
className={cn(
'px-2 last:mb-1 [&:not(:first-child)]:mt-1',
getSignerColorStyles(
Math.max(
recipients.findIndex((r) => r.id === recipient.id),
0,
),
).default.comboxBoxItem,
{
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
},
)}
onSelect={() => {
setSelectedSigner(recipient);
setShowRecipientsSelector(false);
}}
>
<span
className={cn('text-foreground/70 truncate', {
'text-foreground/80': recipient === selectedSigner,
})}
>
{recipient.name && (
<span title={`${recipient.name} (${recipient.email})`}>
{recipient.name} ({recipient.email})
</span>
)}
{!recipient.name && (
<span title={recipient.email}>{recipient.email}</span>
)}
</span>
<div className="ml-auto flex items-center justify-center">
{recipient.sendStatus !== SendStatus.SENT ? (
<Check
aria-hidden={recipient !== selectedSigner}
className={cn('h-4 w-4 flex-shrink-0', {
'opacity-0': recipient !== selectedSigner,
'opacity-100': recipient === selectedSigner,
})}
/>
) : (
<Tooltip>
<TooltipTrigger>
<Info className="ml-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
This document has already been sent to this recipient. You can
no longer edit this recipient.
</TooltipContent>
</Tooltip>
)}
</div>
</CommandItem>
))}
</CommandGroup>
))}
</CommandGroup>
))}
</Command>
</PopoverContent>
</Popover>
</Command>
</PopoverContent>
</Popover>
)}
<div className="-mx-2 flex-1 overflow-y-auto px-2">
<fieldset disabled={isFieldsDisabled} className="my-2 grid grid-cols-3 gap-4">
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.SIGNATURE)}
onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
>
<Card
className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-lg font-normal',
fontCaveat.className,
)}
>
Signature
</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.EMAIL)}
onMouseDown={() => setSelectedField(FieldType.EMAIL)}
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
>
<Card
className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
)}
>
<Mail className="h-4 w-4" />
Email
</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.NAME)}
onMouseDown={() => setSelectedField(FieldType.NAME)}
data-selected={selectedField === FieldType.NAME ? true : undefined}
>
<Card
className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
)}
>
<User className="h-4 w-4" />
Name
</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.DATE)}
onMouseDown={() => setSelectedField(FieldType.DATE)}
data-selected={selectedField === FieldType.DATE ? true : undefined}
>
<Card
className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
)}
>
<CalendarDays className="h-4 w-4" />
Date
</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.TEXT)}
onMouseDown={() => setSelectedField(FieldType.TEXT)}
data-selected={selectedField === FieldType.TEXT ? true : undefined}
>
<Card
className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
)}
>
<Type className="h-4 w-4" />
Text
</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.NUMBER)}
onMouseDown={() => setSelectedField(FieldType.NUMBER)}
data-selected={selectedField === FieldType.NUMBER ? true : undefined}
>
<Card
className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
)}
>
<Hash className="h-4 w-4" />
Number
</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.RADIO)}
onMouseDown={() => setSelectedField(FieldType.RADIO)}
data-selected={selectedField === FieldType.RADIO ? true : undefined}
>
<Card
className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
)}
>
<Disc className="h-4 w-4" />
Radio
</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.CHECKBOX)}
onMouseDown={() => setSelectedField(FieldType.CHECKBOX)}
data-selected={selectedField === FieldType.CHECKBOX ? true : undefined}
>
<Card
className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
)}
>
<CheckSquare className="h-4 w-4" />
Checkbox
</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.DROPDOWN)}
onMouseDown={() => setSelectedField(FieldType.DROPDOWN)}
data-selected={selectedField === FieldType.DROPDOWN ? true : undefined}
>
<Card
className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
)}
>
<ChevronDown className="h-4 w-4" />
Dropdown
</p>
</CardContent>
</Card>
</button>
</fieldset>
</div>
</div>
</DocumentFlowFormContainerContent>
{hasErrors && (
<div className="mt-4">
<ul>
<li className="text-sm text-red-500">
To proceed further, please set at least one value for the{' '}
{emptyCheckboxFields.length > 0
? 'Checkbox'
: emptyRadioFields.length > 0
? 'Radio'
: 'Select'}{' '}
field.
</li>
</ul>
</div>
)}
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep
title={documentFlow.title}
step={currentStep}
maxStep={totalSteps}
/>
<div className="-mx-2 flex-1 overflow-y-auto px-2">
<fieldset disabled={isFieldsDisabled} className="grid grid-cols-2 gap-x-4 gap-y-8">
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.SIGNATURE)}
onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground w-full truncate text-3xl font-medium',
fontCaveat.className,
)}
>
{selectedSigner?.name || 'Signature'}
</p>
<DocumentFlowFormContainerActions
loading={isSubmitting}
disabled={isSubmitting}
disableNextStep={hasErrors}
onGoBackClick={() => {
previousStep();
remove();
documentFlow.onBackStep?.();
}}
goBackLabel={canRenderBackButtonAsRemove ? 'Remove' : undefined}
onGoNextClick={handleGoNextClick}
/>
</DocumentFlowFormContainerFooter>
<p className="text-muted-foreground mt-2 text-center text-xs">Signature</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.EMAIL)}
onMouseDown={() => setSelectedField(FieldType.EMAIL)}
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
)}
>
{'Email'}
</p>
<p className="text-muted-foreground mt-2 text-xs">Email</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.NAME)}
onMouseDown={() => setSelectedField(FieldType.NAME)}
data-selected={selectedField === FieldType.NAME ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
)}
>
{'Name'}
</p>
<p className="text-muted-foreground mt-2 text-xs">Name</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.DATE)}
onMouseDown={() => setSelectedField(FieldType.DATE)}
data-selected={selectedField === FieldType.DATE ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
)}
>
{'Date'}
</p>
<p className="text-muted-foreground mt-2 text-xs">Date</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.TEXT)}
onMouseDown={() => setSelectedField(FieldType.TEXT)}
data-selected={selectedField === FieldType.TEXT ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
)}
>
{'Text'}
</p>
<p className="text-muted-foreground mt-2 text-xs">Custom Text</p>
</CardContent>
</Card>
</button>
</fieldset>
</div>
</div>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep
title={documentFlow.title}
step={currentStep}
maxStep={totalSteps}
/>
<DocumentFlowFormContainerActions
loading={isSubmitting}
disabled={isSubmitting}
onGoBackClick={() => {
previousStep();
remove();
documentFlow.onBackStep?.();
}}
goBackLabel={canRenderBackButtonAsRemove ? 'Remove' : undefined}
onGoNextClick={handleGoNextClick}
/>
</DocumentFlowFormContainerFooter>
<MissingSignatureFieldDialog
isOpen={isMissingSignatureDialogVisible}
onOpenChange={(value) => setIsMissingSignatureDialogVisible(value)}
/>
<MissingSignatureFieldDialog
isOpen={isMissingSignatureDialogVisible}
onOpenChange={(value) => setIsMissingSignatureDialogVisible(value)}
/>
</>
)}
</>
);
};

View File

@ -1,5 +1,6 @@
import { z } from 'zod';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { FieldType } from '@documenso/prisma/client';
export const ZAddFieldsFormSchema = z.object({
@ -14,6 +15,7 @@ export const ZAddFieldsFormSchema = z.object({
pageY: z.number().min(0),
pageWidth: z.number().min(0),
pageHeight: z.number().min(0),
fieldMeta: ZFieldMetaSchema,
}),
),
});

View File

@ -59,7 +59,6 @@ export const AddSignatureFormPartial = ({
requireSignature = true,
}: AddSignatureFormProps) => {
const { currentStep, totalSteps } = useStep();
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
// Refined schema which takes into account whether to allow an empty name or signature.
@ -147,6 +146,26 @@ export const AddSignatureFormPartial = ({
return !form.formState.errors.customText;
}
if (fieldType === FieldType.NUMBER) {
await form.trigger('number');
return !form.formState.errors.number;
}
if (fieldType === FieldType.RADIO) {
await form.trigger('radio');
return !form.formState.errors.radio;
}
if (fieldType === FieldType.CHECKBOX) {
await form.trigger('checkbox');
return !form.formState.errors.checkbox;
}
if (fieldType === FieldType.DROPDOWN) {
await form.trigger('dropdown');
return !form.formState.errors.dropdown;
}
return true;
};
@ -170,7 +189,7 @@ export const AddSignatureFormPartial = ({
customText: form.getValues('name'),
inserted: true,
}))
.with(FieldType.TEXT, () => ({
.with(FieldType.TEXT, FieldType.NUMBER, FieldType.RADIO, FieldType.CHECKBOX, () => ({
...field,
customText: form.getValues('customText'),
inserted: true,
@ -374,15 +393,25 @@ export const AddSignatureFormPartial = ({
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{localFields.map((field) =>
match(field.type)
.with(FieldType.DATE, FieldType.TEXT, FieldType.EMAIL, FieldType.NAME, () => {
return (
<SinglePlayerModeCustomTextField
onClick={insertField(field)}
key={field.id}
field={field}
/>
);
})
.with(
FieldType.DATE,
FieldType.TEXT,
FieldType.EMAIL,
FieldType.NAME,
FieldType.NUMBER,
FieldType.CHECKBOX,
FieldType.RADIO,
FieldType.DROPDOWN,
() => {
return (
<SinglePlayerModeCustomTextField
onClick={insertField(field)}
key={field.id}
field={field}
/>
);
},
)
.with(FieldType.SIGNATURE, () => (
<SinglePlayerModeSignatureField
onClick={insertField(field)}

View File

@ -7,6 +7,10 @@ export const ZAddSignatureFormSchema = z.object({
.email({ message: 'Invalid email address' }),
name: z.string(),
customText: z.string(),
number: z.number().optional(),
radio: z.string().optional(),
checkbox: z.boolean().optional(),
dropdown: z.string().optional(),
signature: z.string(),
});

View File

@ -0,0 +1,47 @@
import { ZCheckboxFieldMeta } from '@documenso/lib/types/field-meta';
import type { TCheckboxFieldMeta } from '@documenso/lib/types/field-meta';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { Label } from '@documenso/ui/primitives/label';
import { FieldIcon } from '../field-icon';
import type { TDocumentFlowFormSchema } from '../types';
type Field = TDocumentFlowFormSchema['fields'][0];
export type CheckboxFieldProps = {
field: Field;
};
export const CheckboxField = ({ field }: CheckboxFieldProps) => {
let parsedFieldMeta: TCheckboxFieldMeta | undefined = undefined;
if (field.fieldMeta) {
parsedFieldMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
}
if (parsedFieldMeta && (!parsedFieldMeta.values || parsedFieldMeta.values.length === 0)) {
return (
<FieldIcon fieldMeta={field.fieldMeta} type={field.type} signerEmail={field.signerEmail} />
);
}
return (
<div className='flex flex-col gap-y-2'>
{!parsedFieldMeta?.values ? (
<FieldIcon fieldMeta={field.fieldMeta} type={field.type} signerEmail={field.signerEmail} />
) : (
parsedFieldMeta.values.map((item: { value: string; checked: boolean }, index: number) => (
<div key={index} className='flex items-center gap-x-1.5'>
<Checkbox
className="h-4 w-4"
checkClassName="text-white"
id={`checkbox-${index}`}
checked={item.checked}
/>
<Label htmlFor={`checkbox-${index}`}>{item.value}</Label>
</div>
))
)}
</div>
);
};

View File

@ -0,0 +1,49 @@
import { ZRadioFieldMeta } from '@documenso/lib/types/field-meta';
import type { TRadioFieldMeta } from '@documenso/lib/types/field-meta';
import { Label } from '@documenso/ui/primitives/label';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { FieldIcon } from '../field-icon';
import type { TDocumentFlowFormSchema } from '../types';
type Field = TDocumentFlowFormSchema['fields'][0];
export type RadioFieldProps = {
field: Field;
};
export const RadioField = ({ field }: RadioFieldProps) => {
let parsedFieldMeta: TRadioFieldMeta | undefined = undefined;
if (field.fieldMeta) {
parsedFieldMeta = ZRadioFieldMeta.parse(field.fieldMeta);
}
if (parsedFieldMeta && (!parsedFieldMeta.values || parsedFieldMeta.values.length === 0)) {
return (
<FieldIcon fieldMeta={field.fieldMeta} type={field.type} signerEmail={field.signerEmail} />
);
}
return (
<div className='flex flex-col gap-y-2'>
{!parsedFieldMeta?.values ? (
<FieldIcon fieldMeta={field.fieldMeta} type={field.type} signerEmail={field.signerEmail} />
) : (
<RadioGroup>
{parsedFieldMeta.values?.map((item, index) => (
<div key={index} className="flex items-center gap-x-1.5">
<RadioGroupItem
className="pointer-events-none"
value={item.value}
id={`option-${index}`}
checked={item.checked}
/>
<Label htmlFor={`option-${index}`}>{item.value}</Label>
</div>
))}
</RadioGroup>
)}
</div>
);
};

View File

@ -126,6 +126,7 @@ export type DocumentFlowFormContainerActionsProps = {
onGoNextClick?: () => void;
loading?: boolean;
disabled?: boolean;
disableNextStep?: boolean;
};
export const DocumentFlowFormContainerActions = ({
@ -137,6 +138,7 @@ export const DocumentFlowFormContainerActions = ({
onGoNextClick,
loading,
disabled,
disableNextStep = false,
}: DocumentFlowFormContainerActionsProps) => {
return (
<div className="mt-4 flex gap-x-4">
@ -155,7 +157,7 @@ export const DocumentFlowFormContainerActions = ({
type="button"
className="bg-documenso flex-1"
size="lg"
disabled={disabled || loading || !canGoNext}
disabled={disabled || disableNextStep || loading || !canGoNext}
loading={loading}
onClick={onGoNextClick}
>

View File

@ -0,0 +1,65 @@
import { CalendarDays, CheckSquare, ChevronDown, Disc, Hash, Mail, Type, User } from 'lucide-react';
import type { TFieldMetaSchema as FieldMetaType } from '@documenso/lib/types/field-meta';
import { FieldType } from '@documenso/prisma/client';
import { cn } from '../../lib/utils';
type FieldIconProps = {
fieldMeta: FieldMetaType;
type: FieldType;
signerEmail?: string;
fontCaveatClassName?: string;
};
const fieldIcons = {
[FieldType.EMAIL]: { icon: Mail, label: 'Email' },
[FieldType.NAME]: { icon: User, label: 'Name' },
[FieldType.DATE]: { icon: CalendarDays, label: 'Date' },
[FieldType.TEXT]: { icon: Type, label: 'Add text' },
[FieldType.NUMBER]: { icon: Hash, label: 'Add number' },
[FieldType.RADIO]: { icon: Disc, label: 'Radio' },
[FieldType.CHECKBOX]: { icon: CheckSquare, label: 'Checkbox' },
[FieldType.DROPDOWN]: { icon: ChevronDown, label: 'Select' },
};
export const FieldIcon = ({
fieldMeta,
type,
signerEmail,
fontCaveatClassName,
}: FieldIconProps) => {
if (type === 'SIGNATURE' || type === 'FREE_SIGNATURE') {
return (
<div
className={cn(
'text-field-card-foreground flex items-center justify-center gap-x-1 text-xl',
fontCaveatClassName,
)}
>
Signature
</div>
);
} else {
const Icon = fieldIcons[type]?.icon;
let label;
if (fieldMeta && (type === 'TEXT' || type === 'NUMBER')) {
if (type === 'TEXT' && 'text' in fieldMeta && fieldMeta.text && !fieldMeta.label) {
label = fieldMeta.text.length > 10 ? fieldMeta.text.substring(0, 10) + '...' : fieldMeta.text;
} else if (fieldMeta.label) {
label = fieldMeta.label.length > 10 ? fieldMeta.label.substring(0, 10) + '...' : fieldMeta.label;
} else {
label = fieldIcons[type]?.label;
}
} else {
label = fieldIcons[type]?.label;
}
return (
<div className="text-field-card-foreground flex items-center justify-center gap-x-1.5 text-sm">
<Icon className='h-4 w-4' /> {label}
</div>
);
}
};

View File

@ -0,0 +1,303 @@
'use client';
import { forwardRef, useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { usePathname } from 'next/navigation';
import { match } from 'ts-pattern';
import {
type TBaseFieldMeta as BaseFieldMeta,
type TCheckboxFieldMeta as CheckboxFieldMeta,
type TDropdownFieldMeta as DropdownFieldMeta,
type TFieldMetaSchema as FieldMeta,
type TNumberFieldMeta as NumberFieldMeta,
type TRadioFieldMeta as RadioFieldMeta,
type TTextFieldMeta as TextFieldMeta,
ZFieldMetaSchema,
} from '@documenso/lib/types/field-meta';
import { FieldType } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { FieldFormType } from './add-fields';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
DocumentFlowFormContainerHeader,
} from './document-flow-root';
import { FieldItem } from './field-item';
import { CheckboxFieldAdvancedSettings } from './field-items-advanced-settings/checkbox-field';
import { DropdownFieldAdvancedSettings } from './field-items-advanced-settings/dropdown-field';
import { NumberFieldAdvancedSettings } from './field-items-advanced-settings/number-field';
import { RadioFieldAdvancedSettings } from './field-items-advanced-settings/radio-field';
import { TextFieldAdvancedSettings } from './field-items-advanced-settings/text-field';
export type FieldAdvancedSettingsProps = {
teamId?: number;
title: string;
description: string;
field: FieldFormType;
fields: FieldFormType[];
onAdvancedSettings?: () => void;
isDocumentPdfLoaded?: boolean;
onSave?: (fieldState: FieldMeta) => void;
};
export type FieldMetaKeys =
| keyof BaseFieldMeta
| keyof TextFieldMeta
| keyof NumberFieldMeta
| keyof RadioFieldMeta
| keyof CheckboxFieldMeta
| keyof DropdownFieldMeta;
const getDefaultState = (fieldType: FieldType): FieldMeta => {
switch (fieldType) {
case FieldType.TEXT:
return {
type: 'text',
label: '',
placeholder: '',
text: '',
characterLimit: 0,
required: false,
readOnly: false,
};
case FieldType.NUMBER:
return {
type: 'number',
label: '',
placeholder: '',
numberFormat: '',
value: '0',
minValue: 0,
maxValue: 0,
required: false,
readOnly: false,
};
case FieldType.RADIO:
return {
type: 'radio',
values: [],
required: false,
readOnly: false,
};
case FieldType.CHECKBOX:
return {
type: 'checkbox',
values: [],
validationRule: '',
validationLength: 0,
required: false,
readOnly: false,
};
case FieldType.DROPDOWN:
return {
type: 'dropdown',
values: [],
defaultValue: '',
required: false,
readOnly: false,
};
default:
throw new Error(`Unsupported field type: ${fieldType}`);
}
};
export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSettingsProps>(
(
{
title,
description,
field,
fields,
onAdvancedSettings,
isDocumentPdfLoaded = true,
onSave,
teamId,
},
ref,
) => {
const { toast } = useToast();
const params = useParams();
const pathname = usePathname();
const id = params?.id;
const isTemplatePage = pathname?.includes('template');
const isDocumentPage = pathname?.includes('document');
const [errors, setErrors] = useState<string[]>([]);
const { data: template } = trpc.template.getTemplateWithDetailsById.useQuery(
{
id: Number(id),
},
{
enabled: isTemplatePage,
},
);
const { data: document } = trpc.document.getDocumentById.useQuery(
{
id: Number(id),
teamId,
},
{
enabled: isDocumentPage,
},
);
const doesFieldExist = (!!document || !!template) && field.nativeId !== undefined;
const { data: fieldData } = trpc.field.getField.useQuery(
{
fieldId: Number(field.nativeId),
teamId,
},
{
enabled: doesFieldExist,
},
);
const fieldMeta = fieldData?.fieldMeta;
const localStorageKey = `field_${field.formId}_${field.type}`;
const defaultState: FieldMeta = getDefaultState(field.type);
const [fieldState, setFieldState] = useState(() => {
const savedState = localStorage.getItem(localStorageKey);
return savedState ? { ...defaultState, ...JSON.parse(savedState) } : defaultState;
});
useEffect(() => {
if (fieldMeta && typeof fieldMeta === 'object') {
const parsedFieldMeta = ZFieldMetaSchema.parse(fieldMeta);
setFieldState({
...defaultState,
...parsedFieldMeta,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fieldMeta]);
const handleFieldChange = (
key: FieldMetaKeys,
value: string | { checked: boolean; value: string }[] | { value: string }[] | boolean,
) => {
setFieldState((prevState: FieldMeta) => {
if (['characterLimit', 'minValue', 'maxValue', 'validationLength'].includes(key)) {
const parsedValue = Number(value);
return {
...prevState,
[key]: isNaN(parsedValue) ? undefined : parsedValue,
};
} else {
return {
...prevState,
[key]: value,
};
}
});
};
const handleOnGoNextClick = () => {
try {
if (errors.length > 0) {
return;
} else {
localStorage.setItem(localStorageKey, JSON.stringify(fieldState));
onSave?.(fieldState);
onAdvancedSettings?.();
}
} catch (error) {
console.error('Failed to save to localStorage:', error);
toast({
title: 'Error',
description: 'Failed to save settings.',
variant: 'destructive',
});
}
};
return (
<div ref={ref} className="flex h-full flex-col">
<DocumentFlowFormContainerHeader title={title} description={description} />
<DocumentFlowFormContainerContent>
{isDocumentPdfLoaded &&
fields.map((field, index) => (
<span key={index} className="opacity-75 active:pointer-events-none">
<FieldItem key={index} field={field} disabled={true} />
</span>
))}
{match(field.type)
.with(FieldType.TEXT, () => (
<TextFieldAdvancedSettings
fieldState={fieldState}
handleFieldChange={handleFieldChange}
handleErrors={setErrors}
/>
))
.with(FieldType.NUMBER, () => (
<NumberFieldAdvancedSettings
fieldState={fieldState}
handleFieldChange={handleFieldChange}
handleErrors={setErrors}
/>
))
.with(FieldType.RADIO, () => (
<RadioFieldAdvancedSettings
fieldState={fieldState}
handleFieldChange={handleFieldChange}
handleErrors={setErrors}
/>
))
.with(FieldType.CHECKBOX, () => (
<CheckboxFieldAdvancedSettings
fieldState={fieldState}
handleFieldChange={handleFieldChange}
handleErrors={setErrors}
/>
))
.with(FieldType.DROPDOWN, () => (
<DropdownFieldAdvancedSettings
fieldState={fieldState}
handleFieldChange={handleFieldChange}
handleErrors={setErrors}
/>
))
.otherwise(() => null)}
{errors.length > 0 && (
<div className="mt-4">
<ul>
{errors.map((error, index) => (
<li className="text-sm text-red-500" key={index}>
{error}
</li>
))}
</ul>
</div>
)}
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter className="mt-auto">
<DocumentFlowFormContainerActions
goNextLabel="Save"
goBackLabel="Cancel"
onGoBackClick={onAdvancedSettings}
onGoNextClick={handleOnGoNextClick}
disableNextStep={errors.length > 0}
/>
</DocumentFlowFormContainerFooter>
</div>
);
},
);
FieldAdvancedSettings.displayName = 'FieldAdvancedSettings';

View File

@ -1,20 +1,34 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Trash } from 'lucide-react';
import { Caveat } from 'next/font/google';
import { Settings2, Trash } from 'lucide-react';
import { createPortal } from 'react-dom';
import { Rnd } from 'react-rnd';
import { match } from 'ts-pattern';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '@documenso/lib/types/field-meta';
import { useSignerColors } from '../../lib/signer-colors';
import { cn } from '../../lib/utils';
import { Card, CardContent } from '../card';
import { CheckboxField } from './advanced-fields/checkbox';
import { RadioField } from './advanced-fields/radio';
import { FieldIcon } from './field-icon';
import type { TDocumentFlowFormSchema } from './types';
import { FRIENDLY_FIELD_TYPE } from './types';
type Field = TDocumentFlowFormSchema['fields'][0];
const fontCaveat = Caveat({
weight: ['500'],
subsets: ['latin'],
display: 'swap',
variable: '--font-caveat',
});
export type FieldItemProps = {
field: Field;
passive?: boolean;
@ -24,17 +38,23 @@ export type FieldItemProps = {
onResize?: (_node: HTMLElement) => void;
onMove?: (_node: HTMLElement) => void;
onRemove?: () => void;
onAdvancedSettings?: () => void;
recipientIndex?: number;
hideRecipients?: boolean;
};
export const FieldItem = ({
field,
passive,
disabled,
minHeight: _minHeight,
minWidth: _minWidth,
minHeight,
minWidth,
onResize,
onMove,
onRemove,
onAdvancedSettings,
recipientIndex = 0,
hideRecipients = false,
}: FieldItemProps) => {
const [active, setActive] = useState(false);
const [coords, setCoords] = useState({
@ -43,6 +63,12 @@ export const FieldItem = ({
pageHeight: 0,
pageWidth: 0,
});
const [settingsActive, setSettingsActive] = useState(false);
const $el = useRef(null);
const signerStyles = useSignerColors(recipientIndex);
const advancedField = ['NUMBER', 'RADIO', 'CHECKBOX', 'DROPDOWN', 'TEXT'].includes(field.type);
const calculateCoords = useCallback(() => {
const $page = document.querySelector<HTMLElement>(
@ -89,25 +115,63 @@ export const FieldItem = ({
};
}, [calculateCoords]);
const handleClickOutsideField = (event: MouseEvent) => {
if (settingsActive && $el.current && !event.composedPath().includes($el.current)) {
setSettingsActive(false);
}
};
useEffect(() => {
document.body.addEventListener('click', handleClickOutsideField);
return () => {
document.body.removeEventListener('click', handleClickOutsideField);
};
}, [settingsActive]);
const hasFieldMetaValues = (
fieldType: string,
fieldMeta: TFieldMetaSchema,
parser: typeof ZCheckboxFieldMeta | typeof ZRadioFieldMeta,
) => {
if (field.type !== fieldType || !fieldMeta) {
return false;
}
const parsedMeta = parser?.parse(fieldMeta);
return parsedMeta && parsedMeta.values && parsedMeta.values.length > 0;
};
const checkBoxHasValues = useMemo(
() => hasFieldMetaValues('CHECKBOX', field.fieldMeta, ZCheckboxFieldMeta),
[field.fieldMeta],
);
const radioHasValues = useMemo(
() => hasFieldMetaValues('RADIO', field.fieldMeta, ZRadioFieldMeta),
[field.fieldMeta],
);
const fixedSize = checkBoxHasValues || radioHasValues;
return createPortal(
<Rnd
key={coords.pageX + coords.pageY + coords.pageHeight + coords.pageWidth}
className={cn('z-20', {
className={cn('group z-20', {
'pointer-events-none': passive,
'pointer-events-none opacity-75': disabled,
'pointer-events-none cursor-not-allowed opacity-75': disabled,
'z-10': !active || disabled,
})}
// minHeight={minHeight}
// minWidth={minWidth}
minHeight={fixedSize ? '' : minHeight || 'auto'}
minWidth={fixedSize ? '' : minWidth || 'auto'}
default={{
x: coords.pageX,
y: coords.pageY,
height: coords.pageHeight,
width: coords.pageWidth,
height: fixedSize ? '' : coords.pageHeight,
width: fixedSize ? '' : coords.pageWidth,
}}
bounds={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`}
onDragStart={() => setActive(true)}
onResizeStart={() => setActive(true)}
enableResizing={!fixedSize}
onResizeStop={(_e, _d, ref) => {
setActive(false);
onResize?.(ref);
@ -117,35 +181,69 @@ export const FieldItem = ({
onMove?.(d.node);
}}
>
{!disabled && (
<button
className="text-muted-foreground/50 hover:text-muted-foreground/80 bg-background absolute -right-2 -top-2 z-20 flex h-8 w-8 items-center justify-center rounded-full border"
onClick={() => onRemove?.()}
onTouchEnd={() => onRemove?.()}
>
<Trash className="h-4 w-4" />
</button>
)}
<Card
className={cn('bg-field-card/80 h-full w-full backdrop-blur-[1px]', {
'border-field-card-border': !disabled,
'border-field-card-border/80': active,
})}
<div
className={cn(
'relative flex h-full w-full items-center justify-center bg-white',
signerStyles.default.base,
signerStyles.default.fieldItem,
)}
onClick={() => {
setSettingsActive((prev) => !prev);
}}
ref={$el}
>
<CardContent
className={cn(
'text-field-card-foreground flex h-full w-full flex-col items-center justify-center p-2',
{
'text-field-card-foreground/50': disabled,
},
)}
>
{FRIENDLY_FIELD_TYPE[field.type]}
{match(field.type)
.with('CHECKBOX', () => <CheckboxField field={field} />)
.with('RADIO', () => <RadioField field={field} />)
.otherwise(() => (
<FieldIcon
fieldMeta={field.fieldMeta}
type={field.type}
signerEmail={field.signerEmail}
fontCaveatClassName={fontCaveat.className}
/>
))}
<p className="w-full truncate text-center text-xs">{field.signerEmail}</p>
</CardContent>
</Card>
{!hideRecipients && (
<div className="absolute -right-6 top-0 z-20 hidden h-full w-6 items-center justify-center group-hover:flex">
<div
className={cn(
'flex h-7 w-6 flex-col items-center justify-center rounded-r-lg text-[0.625rem] font-bold text-white',
signerStyles.default.fieldItemInitials,
{
'!opacity-50': disabled || passive,
},
)}
>
{(field.signerEmail?.charAt(0)?.toUpperCase() ?? '') +
(field.signerEmail?.charAt(1)?.toUpperCase() ?? '')}
</div>
</div>
)}
</div>
{!disabled && settingsActive && (
<div className="mt-1 flex justify-center">
<div className="dark:bg-background group flex items-center justify-evenly rounded-md border gap-x-1 bg-gray-900 p-0.5">
{advancedField && (
<button
className="dark:text-muted-foreground/50 dark:hover:text-muted-foreground dark:hover:bg-foreground/10 rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onAdvancedSettings}
onTouchEnd={onAdvancedSettings}
>
<Settings2 className="h-3 w-3" />
</button>
)}
<button
className="dark:text-muted-foreground/50 dark:hover:text-muted-foreground dark:hover:bg-foreground/10 rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onRemove}
onTouchEnd={onRemove}
>
<Trash className="h-3 w-3" />
</button>
</div>
</div>
)}
</Rnd>,
document.body,
);

View File

@ -0,0 +1,224 @@
'use client';
import { useEffect, useState } from 'react';
import { ChevronDown, ChevronUp, Trash } from 'lucide-react';
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { type TCheckboxFieldMeta as CheckboxFieldMeta } from '@documenso/lib/types/field-meta';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Switch } from '@documenso/ui/primitives/switch';
import { checkboxValidationLength, checkboxValidationRules } from './constants';
type CheckboxFieldAdvancedSettingsProps = {
fieldState: CheckboxFieldMeta;
handleFieldChange: (
key: keyof CheckboxFieldMeta,
value: string | { checked: boolean; value: string }[] | boolean,
) => void;
handleErrors: (errors: string[]) => void;
};
export const CheckboxFieldAdvancedSettings = ({
fieldState,
handleFieldChange,
handleErrors,
}: CheckboxFieldAdvancedSettingsProps) => {
const [showValidation, setShowValidation] = useState(false);
const [values, setValues] = useState(fieldState.values ?? [{ id: 1, checked: false, value: '' }]);
const [readOnly, setReadOnly] = useState(fieldState.readOnly ?? false);
const [required, setRequired] = useState(fieldState.required ?? false);
const [validationLength, setValidationLength] = useState(fieldState.validationLength ?? 0);
const [validationRule, setValidationRule] = useState(fieldState.validationRule ?? '');
const handleToggleChange = (field: keyof CheckboxFieldMeta, value: string | boolean) => {
const readOnly = field === 'readOnly' ? Boolean(value) : Boolean(fieldState.readOnly);
const required = field === 'required' ? Boolean(value) : Boolean(fieldState.required);
const validationRule =
field === 'validationRule' ? String(value) : String(fieldState.validationRule);
const validationLength =
field === 'validationLength' ? Number(value) : Number(fieldState.validationLength);
setReadOnly(readOnly);
setRequired(required);
setValidationRule(validationRule);
setValidationLength(validationLength);
const errors = validateCheckboxField(
values.map((item) => item.value),
{
readOnly,
required,
validationRule,
validationLength,
},
);
handleErrors(errors);
handleFieldChange(field, value);
};
const addValue = () => {
const newId = values.length > 0 ? Math.max(...values.map((val) => val.id)) + 1 : 1;
setValues([...values, { id: newId, checked: false, value: '' }]);
};
useEffect(() => {
const errors = validateCheckboxField(
values.map((item) => item.value),
{
readOnly,
required,
validationRule,
validationLength,
},
);
handleErrors(errors);
handleFieldChange('values', values);
}, [values]);
const removeValue = (index: number) => {
if (values.length === 1) return;
const newValues = [...values];
newValues.splice(index, 1);
setValues(newValues);
handleFieldChange('values', newValues);
};
const handleCheckboxValue = (
index: number,
property: 'value' | 'checked',
newValue: string | boolean,
) => {
const newValues = [...values];
if (property === 'checked') {
newValues[index].checked = Boolean(newValue);
} else if (property === 'value') {
newValues[index].value = String(newValue);
}
setValues(newValues);
handleFieldChange('values', newValues);
};
useEffect(() => {
setValues(fieldState.values ?? [{ id: 1, checked: false, value: '' }]);
}, [fieldState.values]);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-row items-center gap-x-4">
<div className="flex w-2/3 flex-col">
<Label>Validation</Label>
<Select
value={fieldState.validationRule}
onValueChange={(val) => handleToggleChange('validationRule', val)}
>
<SelectTrigger className="text-muted-foreground bg-background mt-2 w-full">
<SelectValue placeholder="Select at least" />
</SelectTrigger>
<SelectContent position="popper">
{checkboxValidationRules.map((item, index) => (
<SelectItem key={index} value={item}>
{item}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="mt-3 flex w-1/3 flex-col">
<Select
value={fieldState.validationLength ? String(fieldState.validationLength) : ''}
onValueChange={(val) => handleToggleChange('validationLength', val)}
>
<SelectTrigger className="text-muted-foreground bg-background mt-2 w-full">
<SelectValue placeholder="Pick a number" />
</SelectTrigger>
<SelectContent position="popper">
{checkboxValidationLength.map((item, index) => (
<SelectItem key={index} value={String(item)}>
{item}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-row items-center gap-2">
<Switch
className="bg-background"
checked={fieldState.required}
onCheckedChange={(checked) => handleToggleChange('required', checked)}
/>
<Label>Required field</Label>
</div>
<div className="flex flex-row items-center gap-2">
<Switch
className="bg-background"
checked={fieldState.readOnly}
onCheckedChange={(checked) => handleToggleChange('readOnly', checked)}
/>
<Label>Read only</Label>
</div>
</div>
<Button
className="bg-foreground/10 hover:bg-foreground/5 border-foreground/10 mt-2 border"
variant="outline"
onClick={() => setShowValidation((prev) => !prev)}
>
<span className="flex w-full flex-row justify-between">
<span className="flex items-center">Checkbox values</span>
{showValidation ? <ChevronUp /> : <ChevronDown />}
</span>
</Button>
{showValidation && (
<div>
{values.map((value, index) => (
<div key={index} className="mt-2 flex items-center gap-4">
<Checkbox
className="data-[state=checked]:bg-documenso border-foreground/30 h-5 w-5"
checkClassName="text-white"
checked={value.checked}
onCheckedChange={(checked) => handleCheckboxValue(index, 'checked', checked)}
/>
<Input
className="w-1/2"
value={value.value}
onChange={(e) => handleCheckboxValue(index, 'value', e.target.value)}
/>
<button
type="button"
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => removeValue(index)}
>
<Trash className="h-5 w-5" />
</button>
</div>
))}
<Button
className="bg-foreground/10 hover:bg-foreground/5 border-foreground/10 ml-9 mt-4 border"
variant="outline"
onClick={addValue}
>
Add another value
</Button>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,31 @@
export const numberFormatValues = [
{
label: '123,456,789.00',
value: '123,456,789.00',
},
{
label: '123.456.789,00',
value: '123.456.789,00',
},
{
label: '123456,789.00',
value: '123456,789.00',
},
];
export const checkboxValidationRules = ['Select at least', 'Select exactly', 'Select at most'];
export const checkboxValidationLength = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
export const checkboxValidationSigns = [
{
label: 'Select at least',
value: '>=',
},
{
label: 'Select exactly',
value: '=',
},
{
label: 'Select at most',
value: '<=',
},
];

View File

@ -0,0 +1,171 @@
'use client';
import { useEffect, useState } from 'react';
import { ChevronDown, ChevronUp, Trash } from 'lucide-react';
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
import { type TDropdownFieldMeta as DropdownFieldMeta } from '@documenso/lib/types/field-meta';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Switch } from '@documenso/ui/primitives/switch';
type DropdownFieldAdvancedSettingsProps = {
fieldState: DropdownFieldMeta;
handleFieldChange: (
key: keyof DropdownFieldMeta,
value: string | { value: string }[] | boolean,
) => void;
handleErrors: (errors: string[]) => void;
};
export const DropdownFieldAdvancedSettings = ({
fieldState,
handleFieldChange,
handleErrors,
}: DropdownFieldAdvancedSettingsProps) => {
const [showValidation, setShowValidation] = useState(false);
const [values, setValues] = useState(fieldState.values ?? [{ value: 'Option 1' }]);
const [readOnly, setReadOnly] = useState(fieldState.readOnly ?? false);
const [required, setRequired] = useState(fieldState.required ?? false);
const [defaultValue, setDefaultValue] = useState(fieldState.defaultValue ?? 'Option 1');
const addValue = () => {
setValues([...values, { value: 'New option' }]);
handleFieldChange('values', [...values, { value: 'New option' }]);
};
const removeValue = (index: number) => {
if (values.length === 1) return;
const newValues = [...values];
newValues.splice(index, 1);
setValues(newValues);
handleFieldChange('values', newValues);
};
const handleToggleChange = (field: keyof DropdownFieldMeta, value: string | boolean) => {
const readOnly = field === 'readOnly' ? Boolean(value) : Boolean(fieldState.readOnly);
const required = field === 'required' ? Boolean(value) : Boolean(fieldState.required);
setReadOnly(readOnly);
setRequired(required);
const errors = validateDropdownField(undefined, { readOnly, required, values });
handleErrors(errors);
handleFieldChange(field, value);
};
const handleValueChange = (index: number, newValue: string) => {
const updatedValues = [...values];
updatedValues[index].value = newValue;
setValues(updatedValues);
handleFieldChange('values', updatedValues);
};
useEffect(() => {
const errors = validateDropdownField(undefined, { readOnly, required, values });
handleErrors(errors);
}, [values]);
useEffect(() => {
setValues(fieldState.values ?? [{ value: 'Option 1' }]);
}, [fieldState.values]);
useEffect(() => {
setDefaultValue(fieldState.defaultValue ?? 'Option 1');
}, [fieldState.defaultValue]);
return (
<div className="text-dark flex flex-col gap-4">
<div>
<Label>Select default option</Label>
<Select
defaultValue={defaultValue}
onValueChange={(val) => {
setDefaultValue(val);
handleFieldChange('defaultValue', val);
}}
>
<SelectTrigger className="text-muted-foreground bg-background mt-2 w-full">
<SelectValue defaultValue={defaultValue} placeholder="-- Select --" />
</SelectTrigger>
<SelectContent position="popper">
{values.map((item, index) => (
<SelectItem
key={index}
value={item.value && item.value.length > 0 ? item.value.toString() : String(index)}
>
{item.value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-row items-center gap-2">
<Switch
className="bg-background"
checked={fieldState.required}
onCheckedChange={(checked) => handleToggleChange('required', checked)}
/>
<Label>Required field</Label>
</div>
<div className="flex flex-row items-center gap-2">
<Switch
className="bg-background"
checked={fieldState.readOnly}
onCheckedChange={(checked) => handleToggleChange('readOnly', checked)}
/>
<Label>Read only</Label>
</div>
</div>
<Button
className="bg-foreground/10 hover:bg-foreground/5 border-foreground/10 mt-2 border"
variant="outline"
onClick={() => setShowValidation((prev) => !prev)}
>
<span className="flex w-full flex-row justify-between">
<span className="flex items-center">Dropdown options</span>
{showValidation ? <ChevronUp /> : <ChevronDown />}
</span>
</Button>
{showValidation && (
<div>
{values.map((value, index) => (
<div key={index} className="mt-2 flex items-center gap-4">
<Input
className="w-1/2"
value={value.value}
onChange={(e) => handleValueChange(index, e.target.value)}
/>
<button
type="button"
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => removeValue(index)}
>
<Trash className="h-5 w-5" />
</button>
</div>
))}
<Button
className="bg-foreground/10 hover:bg-foreground/5 border-foreground/10 ml-9 mt-4 border"
variant="outline"
onClick={addValue}
>
Add another option
</Button>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,160 @@
'use client';
import { useState } from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
import { type TNumberFieldMeta as NumberFieldMeta } from '@documenso/lib/types/field-meta';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Switch } from '@documenso/ui/primitives/switch';
import { numberFormatValues } from './constants';
type NumberFieldAdvancedSettingsProps = {
fieldState: NumberFieldMeta;
handleFieldChange: (key: keyof NumberFieldMeta, value: string | boolean) => void;
handleErrors: (errors: string[]) => void;
};
export const NumberFieldAdvancedSettings = ({
fieldState,
handleFieldChange,
handleErrors,
}: NumberFieldAdvancedSettingsProps) => {
const [showValidation, setShowValidation] = useState(false);
const handleInput = (field: keyof NumberFieldMeta, value: string | boolean) => {
const userValue = field === 'value' ? value : fieldState.value || 0;
const userMinValue = field === 'minValue' ? Number(value) : Number(fieldState.minValue || 0);
const userMaxValue = field === 'maxValue' ? Number(value) : Number(fieldState.maxValue || 0);
const readOnly = field === 'readOnly' ? Boolean(value) : Boolean(fieldState.readOnly);
const required = field === 'required' ? Boolean(value) : Boolean(fieldState.required);
const numberFormat = field === 'numberFormat' ? String(value) : fieldState.numberFormat || '';
const valueErrors = validateNumberField(String(userValue), {
minValue: userMinValue,
maxValue: userMaxValue,
readOnly,
required,
numberFormat,
});
handleErrors(valueErrors);
handleFieldChange(field, value);
};
return (
<div className="flex flex-col gap-4">
<div>
<Label>Label</Label>
<Input
id="label"
className="bg-background mt-2"
placeholder="Label"
value={fieldState.label}
onChange={(e) => handleFieldChange('label', e.target.value)}
/>
</div>
<div>
<Label className="mt-4">Placeholder</Label>
<Input
id="placeholder"
className="bg-background mt-2"
placeholder="Placeholder"
value={fieldState.placeholder}
onChange={(e) => handleFieldChange('placeholder', e.target.value)}
/>
</div>
<div>
<Label className="mt-4">Value</Label>
<Input
id="value"
className="bg-background mt-2"
placeholder="Value"
value={fieldState.value}
onChange={(e) => handleInput('value', e.target.value)}
/>
</div>
<div>
<Label>Number format</Label>
<Select
value={fieldState.numberFormat}
onValueChange={(val) => handleInput('numberFormat', val)}
>
<SelectTrigger className="text-muted-foreground bg-background mt-2 w-full">
<SelectValue placeholder="Field format" />
</SelectTrigger>
<SelectContent position="popper">
{numberFormatValues.map((item, index) => (
<SelectItem key={index} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="mt-2 flex flex-col gap-4">
<div className="flex flex-row items-center gap-2">
<Switch
className="bg-background"
checked={fieldState.required}
onCheckedChange={(checked) => handleInput('required', checked)}
/>
<Label>Required field</Label>
</div>
<div className="flex flex-row items-center gap-2">
<Switch
className="bg-background"
checked={fieldState.readOnly}
onCheckedChange={(checked) => handleInput('readOnly', checked)}
/>
<Label>Read only</Label>
</div>
</div>
<Button
className="bg-foreground/10 hover:bg-foreground/5 border-foreground/10 mt-2 border"
variant="outline"
onClick={() => setShowValidation((prev) => !prev)}
>
<span className="flex w-full flex-row justify-between">
<span className="flex items-center">Validation</span>
{showValidation ? <ChevronUp /> : <ChevronDown />}
</span>
</Button>
{showValidation && (
<div className="mb-4 flex flex-row gap-x-4">
<div className="flex flex-col">
<Label className="mt-4">Min</Label>
<Input
id="minValue"
className="bg-background mt-2"
placeholder="E.g. 0"
value={fieldState.minValue}
onChange={(e) => handleInput('minValue', e.target.value)}
/>
</div>
<div className="flex flex-col">
<Label className="mt-4">Max</Label>
<Input
id="maxValue"
className="bg-background mt-2"
placeholder="E.g. 100"
value={fieldState.maxValue}
onChange={(e) => handleInput('maxValue', e.target.value)}
/>
</div>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,166 @@
'use client';
import { useEffect, useState } from 'react';
import { ChevronDown, ChevronUp, Trash } from 'lucide-react';
import { validateRadioField } from '@documenso/lib/advanced-fields-validation/validate-radio';
import { type TRadioFieldMeta as RadioFieldMeta } from '@documenso/lib/types/field-meta';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { Switch } from '@documenso/ui/primitives/switch';
export type RadioFieldAdvancedSettingsProps = {
fieldState: RadioFieldMeta;
handleFieldChange: (
key: keyof RadioFieldMeta,
value: string | { checked: boolean; value: string }[] | boolean,
) => void;
handleErrors: (errors: string[]) => void;
};
export const RadioFieldAdvancedSettings = ({
fieldState,
handleFieldChange,
handleErrors,
}: RadioFieldAdvancedSettingsProps) => {
const [showValidation, setShowValidation] = useState(false);
const [values, setValues] = useState(
fieldState.values ?? [{ id: 1, checked: false, value: 'Default value' }],
);
const [readOnly, setReadOnly] = useState(fieldState.readOnly ?? false);
const [required, setRequired] = useState(fieldState.required ?? false);
const addValue = () => {
const newId = values.length > 0 ? Math.max(...values.map((val) => val.id)) + 1 : 1;
const newValue = { id: newId, checked: false, value: '' };
const updatedValues = [...values, newValue];
setValues(updatedValues);
handleFieldChange('values', updatedValues);
};
const removeValue = (id: number) => {
if (values.length === 1) return;
const newValues = values.filter((val) => val.id !== id);
setValues(newValues);
handleFieldChange('values', newValues);
};
const handleCheckedChange = (checked: boolean, id: number) => {
const newValues = values.map((val) => {
if (val.id === id) {
return { ...val, checked: Boolean(checked) };
} else {
return { ...val, checked: false };
}
});
setValues(newValues);
handleFieldChange('values', newValues);
};
const handleToggleChange = (field: keyof RadioFieldMeta, value: string | boolean) => {
const readOnly = field === 'readOnly' ? Boolean(value) : Boolean(fieldState.readOnly);
const required = field === 'required' ? Boolean(value) : Boolean(fieldState.required);
setReadOnly(readOnly);
setRequired(required);
const errors = validateRadioField(String(value), { readOnly, required, values });
handleErrors(errors);
handleFieldChange(field, value);
};
const handleInputChange = (value: string, id: number) => {
const newValues = values.map((val) => {
if (val.id === id) {
return { ...val, value };
}
return val;
});
setValues(newValues);
handleFieldChange('values', newValues);
return newValues;
};
useEffect(() => {
setValues(fieldState.values ?? [{ id: 1, checked: false, value: 'Default value' }]);
}, [fieldState.values]);
useEffect(() => {
const errors = validateRadioField(undefined, { readOnly, required, values });
handleErrors(errors);
}, [values]);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4">
<div className="flex flex-row items-center gap-2">
<Switch
className="bg-background"
checked={fieldState.required}
onCheckedChange={(checked) => handleToggleChange('required', checked)}
/>
<Label>Required field</Label>
</div>
<div className="flex flex-row items-center gap-2">
<Switch
className="bg-background"
checked={fieldState.readOnly}
onCheckedChange={(checked) => handleToggleChange('readOnly', checked)}
/>
<Label>Read only</Label>
</div>
</div>
<Button
className="bg-foreground/10 hover:bg-foreground/5 border-foreground/10 mt-2 border"
variant="outline"
onClick={() => setShowValidation((prev) => !prev)}
>
<span className="flex w-full flex-row justify-between">
<span className="flex items-center">Radio values</span>
{showValidation ? <ChevronUp /> : <ChevronDown />}
</span>
</Button>
{showValidation && (
<div>
{values.map((value) => (
<div key={value.id} className="mt-2 flex items-center gap-4">
<Checkbox
className="data-[state=checked]:bg-documenso border-foreground/30 data-[state=checked]:ring-documenso dark:data-[state=checked]:ring-offset-background h-5 w-5 rounded-full data-[state=checked]:ring-1 data-[state=checked]:ring-offset-2 data-[state=checked]:ring-offset-white"
checked={value.checked}
onCheckedChange={(checked) => handleCheckedChange(Boolean(checked), value.id)}
/>
<Input
className="w-1/2"
value={value.value}
onChange={(e) => handleInputChange(e.target.value, value.id)}
/>
<button
type="button"
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50 dark:text-white"
onClick={() => removeValue(value.id)}
>
<Trash className="h-5 w-5" />
</button>
</div>
))}
<Button
className="bg-foreground/10 hover:bg-foreground/5 border-foreground/10 ml-9 mt-4 border"
variant="outline"
onClick={addValue}
>
Add another value
</Button>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,103 @@
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
import { type TTextFieldMeta as TextFieldMeta } from '@documenso/lib/types/field-meta';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
type TextFieldAdvancedSettingsProps = {
fieldState: TextFieldMeta;
handleFieldChange: (key: keyof TextFieldMeta, value: string | boolean) => void;
handleErrors: (errors: string[]) => void;
};
export const TextFieldAdvancedSettings = ({
fieldState,
handleFieldChange,
handleErrors,
}: TextFieldAdvancedSettingsProps) => {
const handleInput = (field: keyof TextFieldMeta, value: string | boolean) => {
const text = field === 'text' ? String(value) : fieldState.text || '';
const limit =
field === 'characterLimit' ? Number(value) : Number(fieldState.characterLimit || 0);
const readOnly = field === 'readOnly' ? Boolean(value) : Boolean(fieldState.readOnly);
const required = field === 'required' ? Boolean(value) : Boolean(fieldState.required);
const textErrors = validateTextField(text, {
characterLimit: Number(limit),
readOnly,
required,
});
handleErrors(textErrors);
handleFieldChange(field, value);
};
return (
<div className="flex flex-col gap-4">
<div>
<Label>Label</Label>
<Input
id="label"
className="bg-background mt-2"
placeholder="Field label"
value={fieldState.label}
onChange={(e) => handleFieldChange('label', e.target.value)}
/>
</div>
<div>
<Label className="mt-4">Placeholder</Label>
<Input
id="placeholder"
className="bg-background mt-2"
placeholder="Field placeholder"
value={fieldState.placeholder}
onChange={(e) => handleFieldChange('placeholder', e.target.value)}
/>
</div>
<div>
<Label className="mt-4">Add text</Label>
<Textarea
id="text"
className="bg-background mt-2"
placeholder="Add text to the field"
value={fieldState.text}
onChange={(e) => handleInput('text', e.target.value)}
/>
</div>
<div>
<Label>Character Limit</Label>
<Input
id="characterLimit"
type="number"
min={0}
className="bg-background mt-2"
placeholder="Field character limit"
value={fieldState.characterLimit}
onChange={(e) => handleInput('characterLimit', e.target.value)}
/>
</div>
<div className="mt-4 flex flex-col gap-4">
<div className="flex flex-row items-center gap-2">
<Switch
className="bg-background"
checked={fieldState.required}
onCheckedChange={(checked) => handleInput('required', checked)}
/>
<Label>Required field</Label>
</div>
<div className="flex flex-row items-center gap-2">
<Switch
className="bg-background"
checked={fieldState.readOnly}
onCheckedChange={(checked) => handleInput('readOnly', checked)}
/>
<Label>Read only</Label>
</div>
</div>
</div>
);
};

View File

@ -2,7 +2,10 @@
import React, { useRef } from 'react';
import { Caveat } from 'next/font/google';
import { AnimatePresence, motion } from 'framer-motion';
import { CalendarDays, CheckSquare, ChevronDown, Disc, Hash, Mail, Type, User } from 'lucide-react';
import { match } from 'ts-pattern';
import { useElementScaleSize } from '@documenso/lib/client-only/hooks/use-element-scale-size';
@ -18,6 +21,14 @@ import { FieldType } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldRootContainer } from '../../components/field/field';
import { cn } from '../../lib/utils';
const fontCaveat = Caveat({
weight: ['500'],
subsets: ['latin'],
display: 'swap',
variable: '--font-caveat',
});
export type SinglePlayerModeFieldContainerProps = {
field: FieldWithSignature;
@ -110,9 +121,11 @@ export function SinglePlayerModeSignatureField({
) : (
<button
onClick={() => onClick?.()}
className="group-hover:text-primary text-muted-foreground absolute inset-0 h-full w-full duration-200"
className={
cn('group-hover:text-primary absolute inset-0 h-full w-full duration-200', fontCaveat.className)
}
>
Signature
<span className="text-muted-foreground truncate text-3xl font-medium ">Signature</span>
</button>
)}
</SinglePlayerModeFieldCardContainer>
@ -152,32 +165,82 @@ export function SinglePlayerModeCustomTextField({
const fontSize = maxFontSize * scalingFactor;
return (
<SinglePlayerModeFieldCardContainer field={field}>
{field.inserted ? (
<p
ref={$paragraphEl}
style={{
fontSize: `clamp(${minFontSize}px, ${fontSize}px, ${maxFontSize}px)`,
fontFamily: `var(${fontVariable})`,
}}
>
{field.customText}
</p>
) : (
<button
onClick={() => onClick?.()}
className="group-hover:text-primary text-muted-foreground absolute inset-0 h-full w-full text-lg duration-200"
>
{match(field.type)
.with(FieldType.DATE, () => 'Date')
.with(FieldType.NAME, () => 'Name')
.with(FieldType.EMAIL, () => 'Email')
.with(FieldType.TEXT, () => 'Text')
.with(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE, () => 'Signature')
.otherwise(() => '')}
</button>
)}
</SinglePlayerModeFieldCardContainer>
<>
<SinglePlayerModeFieldCardContainer field={field}>
{field.inserted ? (
<p
ref={$paragraphEl}
style={{
fontSize: `clamp(${minFontSize}px, ${fontSize}px, ${maxFontSize}px)`,
fontFamily: `var(${fontVariable})`,
}}
>
{field.customText ??
(field.fieldMeta && typeof field.fieldMeta === 'object' && 'label' in field.fieldMeta
? field.fieldMeta.label
: '')}
</p>
) : (
<button
onClick={() => onClick?.()}
className="group-hover:text-primary text-muted-foreground absolute inset-0 h-full w-full text-lg duration-200"
>
{match(field.type)
.with(FieldType.DATE, () => (
<div className="text-field-card-foreground flex items-center justify-center gap-x-1 text-xl font-light">
<CalendarDays /> Date
</div>
))
.with(FieldType.NAME, () => (
<div className="text-field-card-foreground flex items-center justify-center gap-x-1 text-xl font-light">
<User /> Name
</div>
))
.with(FieldType.EMAIL, () => (
<div className="text-field-card-foreground flex items-center justify-center gap-x-1 text-xl font-light">
<Mail /> Email
</div>
))
.with(FieldType.TEXT, () => (
<div className="text-field-card-foreground flex items-center justify-center gap-x-1 text-xl font-light">
<Type /> Text
</div>
))
.with(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE, () => (
<div
className={cn(
'text-muted-foreground w-full truncate text-3xl font-medium',
fontCaveat.className,
)}
>
Signature
</div>
))
.with(FieldType.NUMBER, () => (
<div className="text-field-card-foreground flex items-center justify-center gap-x-1 text-xl font-light">
<Hash /> Number
</div>
))
.with(FieldType.CHECKBOX, () => (
<div className="text-field-card-foreground flex items-center justify-center gap-x-1 text-xl font-light">
<CheckSquare /> Checkbox
</div>
))
.with(FieldType.RADIO, () => (
<div className="text-field-card-foreground flex items-center justify-center gap-x-1 text-xl font-light">
<Disc /> Radio
</div>
))
.with(FieldType.DROPDOWN, () => (
<div className="text-field-card-foreground flex items-center justify-center gap-x-1 text-xl font-light">
<ChevronDown /> Dropdown
</div>
))
.otherwise(() => '')}
</button>
)}
</SinglePlayerModeFieldCardContainer>
</>
);
}

View File

@ -1,5 +1,6 @@
import { z } from 'zod';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { FieldType } from '@documenso/prisma/client';
export const ZDocumentFlowFormSchema = z.object({
@ -30,6 +31,7 @@ export const ZDocumentFlowFormSchema = z.object({
pageY: z.number().min(0),
pageWidth: z.number().min(0),
pageHeight: z.number().min(0),
fieldMeta: ZFieldMetaSchema,
}),
),
@ -48,6 +50,10 @@ export const FRIENDLY_FIELD_TYPE: Record<FieldType, string> = {
[FieldType.DATE]: 'Date',
[FieldType.EMAIL]: 'Email',
[FieldType.NAME]: 'Name',
[FieldType.NUMBER]: 'Number',
[FieldType.RADIO]: 'Radio',
[FieldType.CHECKBOX]: 'Checkbox',
[FieldType.DROPDOWN]: 'Select',
};
export interface DocumentFlowStep {

View File

@ -4,13 +4,27 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Caveat } from 'next/font/google';
import { ChevronsUpDown } from 'lucide-react';
import {
CalendarDays,
CheckSquare,
ChevronDown,
ChevronsUpDown,
Disc,
Hash,
Mail,
Type,
User,
} from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import {
type TFieldMetaSchema as FieldMeta,
ZFieldMetaSchema,
} from '@documenso/lib/types/field-meta';
import { nanoid } from '@documenso/lib/universal/id';
import type { Field, Recipient } from '@documenso/prisma/client';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
@ -28,6 +42,7 @@ import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import { FieldItem } from '@documenso/ui/primitives/document-flow/field-item';
@ -35,8 +50,10 @@ import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/ty
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { getSignerColorStyles, useSignerColors } from '../../lib/signer-colors';
import type { FieldFormType } from '../document-flow/add-fields';
import { FieldAdvancedSettings } from '../document-flow/field-item-advanced-settings';
import { useStep } from '../stepper';
// import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import type { TAddTemplateFieldsFormSchema } from './add-template-fields.types';
const fontCaveat = Caveat({
@ -46,11 +63,8 @@ const fontCaveat = Caveat({
variable: '--font-caveat',
});
const DEFAULT_HEIGHT_PERCENT = 5;
const DEFAULT_WIDTH_PERCENT = 15;
const MIN_HEIGHT_PX = 60;
const MIN_WIDTH_PX = 200;
const MIN_HEIGHT_PX = 40;
const MIN_WIDTH_PX = 140;
export type AddTemplateFieldsFormProps = {
documentFlow: DocumentFlowStep;
@ -58,6 +72,7 @@ export type AddTemplateFieldsFormProps = {
recipients: Recipient[];
fields: Field[];
onSubmit: (_data: TAddTemplateFieldsFormSchema) => void;
teamId?: number;
};
export const AddTemplateFieldsFormPartial = ({
@ -66,15 +81,19 @@ export const AddTemplateFieldsFormPartial = ({
recipients,
fields,
onSubmit,
teamId,
}: AddTemplateFieldsFormProps) => {
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
const { currentStep, totalSteps, previousStep } = useStep();
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
const [currentField, setCurrentField] = useState<FieldFormType>();
const {
control,
handleSubmit,
formState: { isSubmitting },
setValue,
getValues,
} = useForm<TAddTemplateFieldsFormSchema>({
defaultValues: {
fields: fields.map((field) => ({
@ -97,6 +116,25 @@ export const AddTemplateFieldsFormPartial = ({
const onFormSubmit = handleSubmit(onSubmit);
const handleSavedFieldSettings = (fieldState: FieldMeta) => {
const initialValues = getValues();
const updatedFields = initialValues.fields.map((field) => {
if (field.formId === currentField?.formId) {
const parsedFieldMeta = ZFieldMetaSchema.parse(fieldState);
return {
...field,
fieldMeta: parsedFieldMeta,
};
}
return field;
});
setValue('fields', updatedFields);
};
const {
append,
remove,
@ -111,6 +149,11 @@ export const AddTemplateFieldsFormPartial = ({
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const selectedSignerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id);
const selectedSignerStyles = useSignerColors(
selectedSignerIndex === -1 ? 0 : selectedSignerIndex,
);
const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
const [coords, setCoords] = useState({
x: 0,
@ -189,6 +232,7 @@ export const AddTemplateFieldsFormPartial = ({
signerEmail: selectedSigner.email,
signerId: selectedSigner.id,
signerToken: selectedSigner.token ?? '',
fieldMeta: undefined,
});
setIsFieldWithinBounds(false);
@ -270,11 +314,9 @@ export const AddTemplateFieldsFormPartial = ({
return;
}
const { height, width } = $page.getBoundingClientRect();
fieldBounds.current = {
height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX),
width: Math.max(width * (DEFAULT_WIDTH_PERCENT / 100), MIN_WIDTH_PX),
height: Math.max(MIN_HEIGHT_PX),
width: Math.max(MIN_WIDTH_PX),
};
});
@ -314,265 +356,428 @@ export const AddTemplateFieldsFormPartial = ({
);
}, [recipientsByRole]);
const handleAdvancedSettings = () => {
setShowAdvancedSettings((prev) => !prev);
};
return (
<>
<DocumentFlowFormContainerContent>
<div className="flex flex-col">
{selectedField && (
<Card
className={cn(
'bg-background pointer-events-none fixed z-50 cursor-pointer transition-opacity',
{
'border-primary': isFieldWithinBounds,
'opacity-50': !isFieldWithinBounds,
},
)}
style={{
top: coords.y,
left: coords.x,
height: fieldBounds.current.height,
width: fieldBounds.current.width,
}}
>
<CardContent className="text-foreground flex h-full w-full items-center justify-center p-2">
{FRIENDLY_FIELD_TYPE[selectedField]}
</CardContent>
</Card>
)}
{localFields.map((field, index) => (
<FieldItem
key={index}
field={field}
disabled={selectedSigner?.email !== field.signerEmail}
minHeight={fieldBounds.current.height}
minWidth={fieldBounds.current.width}
passive={isFieldWithinBounds && !!selectedField}
onResize={(options) => onFieldResize(options, index)}
onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)}
/>
))}
{!hideRecipients && (
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
className="bg-background text-muted-foreground mb-12 justify-between font-normal"
{showAdvancedSettings && currentField ? (
<FieldAdvancedSettings
title="Advanced settings"
description={`Configure the ${FRIENDLY_FIELD_TYPE[currentField.type]} field`}
field={currentField}
fields={localFields}
onAdvancedSettings={handleAdvancedSettings}
onSave={handleSavedFieldSettings}
teamId={teamId}
/>
) : (
<>
<DocumentFlowFormContainerHeader
title={documentFlow.title}
description={documentFlow.description}
/>
<DocumentFlowFormContainerContent>
<div className="flex flex-col">
{selectedField && (
<div
className={cn(
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200',
selectedSignerStyles.default.base,
{
'-rotate-6 scale-90 opacity-50': !isFieldWithinBounds,
},
)}
style={{
top: coords.y,
left: coords.x,
height: fieldBounds.current.height,
width: fieldBounds.current.width,
}}
>
{selectedSigner?.email && (
<span className="flex-1 truncate text-left">
{selectedSigner?.name} ({selectedSigner?.email})
</span>
)}
{FRIENDLY_FIELD_TYPE[selectedField]}
</div>
)}
{!selectedSigner?.email && (
<span className="flex-1 truncate text-left">{selectedSigner?.email}</span>
)}
{localFields.map((field, index) => {
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail);
<ChevronsUpDown className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
return (
<FieldItem
key={index}
recipientIndex={recipientIndex === -1 ? 0 : recipientIndex}
field={field}
disabled={selectedSigner?.email !== field.signerEmail}
minHeight={fieldBounds.current.height}
minWidth={fieldBounds.current.width}
passive={isFieldWithinBounds && !!selectedField}
onResize={(options) => onFieldResize(options, index)}
onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)}
onAdvancedSettings={() => {
setCurrentField(field);
handleAdvancedSettings();
}}
hideRecipients={hideRecipients}
/>
);
})}
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput />
<CommandEmpty>
<span className="text-muted-foreground inline-block px-4">
No recipient matching this description was found.
</span>
</CommandEmpty>
{recipientsByRoleToDisplay.map(([role, recipients], roleIndex) => (
<CommandGroup key={roleIndex}>
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
{`${RECIPIENT_ROLES_DESCRIPTION[role].roleName}s`}
</div>
{recipients.length === 0 && (
<div
key={`${role}-empty`}
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
>
No recipients with this role
</div>
{!hideRecipients && (
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
className={cn(
'bg-background text-muted-foreground hover:text-foreground mb-12 mt-2 justify-between font-normal',
selectedSignerStyles.default.base,
)}
>
{selectedSigner?.email && (
<span className="flex-1 truncate text-left">
{selectedSigner?.name} ({selectedSigner?.email})
</span>
)}
{recipients.map((recipient) => (
<CommandItem
key={recipient.id}
className={cn('px-2 last:mb-1 [&:not(:first-child)]:mt-1')}
onSelect={() => {
setSelectedSigner(recipient);
setShowRecipientsSelector(false);
}}
>
<span
className={cn('text-foreground/70 truncate', {
'text-foreground/80': recipient === selectedSigner,
})}
>
{recipient.name && (
<span title={`${recipient.name} (${recipient.email})`}>
{recipient.name} ({recipient.email})
{!selectedSigner?.email && (
<span className="gradie flex-1 truncate text-left">
{selectedSigner?.email}
</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command value={selectedSigner?.email}>
<CommandInput />
<CommandEmpty>
<span className="text-muted-foreground inline-block px-4">
No recipient matching this description was found.
</span>
</CommandEmpty>
{recipientsByRoleToDisplay.map(([role, roleRecipients], roleIndex) => (
<CommandGroup key={roleIndex}>
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
{`${RECIPIENT_ROLES_DESCRIPTION[role].roleName}s`}
</div>
{roleRecipients.length === 0 && (
<div
key={`${role}-empty`}
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
>
No recipients with this role
</div>
)}
{roleRecipients.map((recipient) => (
<CommandItem
key={recipient.id}
className={cn(
'px-2 last:mb-1 [&:not(:first-child)]:mt-1',
getSignerColorStyles(
Math.max(
recipients.findIndex((r) => r.id === recipient.id),
0,
),
).default.comboxBoxItem,
)}
onSelect={() => {
setSelectedSigner(recipient);
setShowRecipientsSelector(false);
}}
>
<span
className={cn('text-foreground/70 truncate', {
'text-foreground/80': recipient === selectedSigner,
})}
>
{recipient.name && (
<span title={`${recipient.name} (${recipient.email})`}>
{recipient.name} ({recipient.email})
</span>
)}
{!recipient.name && (
<span title={recipient.email}>{recipient.email}</span>
)}
</span>
)}
{!recipient.name && (
<span title={recipient.email}>{recipient.email}</span>
)}
</span>
</CommandItem>
</CommandItem>
))}
</CommandGroup>
))}
</CommandGroup>
))}
</Command>
</PopoverContent>
</Popover>
)}
</Command>
</PopoverContent>
</Popover>
)}
<div className="-mx-2 flex-1 overflow-y-auto px-2">
<div className="grid grid-cols-2 gap-x-4 gap-y-8">
<button
type="button"
className="group h-full w-full"
disabled={!selectedSigner}
onClick={() => setSelectedField(FieldType.SIGNATURE)}
onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
<div className="-mx-2 flex-1 overflow-y-auto px-2">
<fieldset className="my-2 grid grid-cols-3 gap-4">
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.SIGNATURE)}
onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
>
<Card
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground w-full truncate text-3xl font-medium',
fontCaveat.className,
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
{selectedSigner?.name || 'Signature'}
</p>
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-lg font-normal',
fontCaveat.className,
)}
>
Signature
</p>
</CardContent>
</Card>
</button>
<p className="text-muted-foreground mt-2 text-center text-xs">Signature</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
disabled={!selectedSigner}
onClick={() => setSelectedField(FieldType.EMAIL)}
onMouseDown={() => setSelectedField(FieldType.EMAIL)}
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.EMAIL)}
onMouseDown={() => setSelectedField(FieldType.EMAIL)}
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
>
<Card
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
{'Email'}
</p>
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
)}
>
<Mail className="h-4 w-4" />
Email
</p>
</CardContent>
</Card>
</button>
<p className="text-muted-foreground mt-2 text-xs">Email</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
disabled={!selectedSigner}
onClick={() => setSelectedField(FieldType.NAME)}
onMouseDown={() => setSelectedField(FieldType.NAME)}
data-selected={selectedField === FieldType.NAME ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.NAME)}
onMouseDown={() => setSelectedField(FieldType.NAME)}
data-selected={selectedField === FieldType.NAME ? true : undefined}
>
<Card
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
{'Name'}
</p>
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
)}
>
<User className="h-4 w-4" />
Name
</p>
</CardContent>
</Card>
</button>
<p className="text-muted-foreground mt-2 text-xs">Name</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
disabled={!selectedSigner}
onClick={() => setSelectedField(FieldType.DATE)}
onMouseDown={() => setSelectedField(FieldType.DATE)}
data-selected={selectedField === FieldType.DATE ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.DATE)}
onMouseDown={() => setSelectedField(FieldType.DATE)}
data-selected={selectedField === FieldType.DATE ? true : undefined}
>
<Card
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
{'Date'}
</p>
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
)}
>
<CalendarDays className="h-4 w-4" />
Date
</p>
</CardContent>
</Card>
</button>
<p className="text-muted-foreground mt-2 text-xs">Date</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.TEXT)}
onMouseDown={() => setSelectedField(FieldType.TEXT)}
data-selected={selectedField === FieldType.TEXT ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.TEXT)}
onMouseDown={() => setSelectedField(FieldType.TEXT)}
data-selected={selectedField === FieldType.TEXT ? true : undefined}
>
<Card
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
{'Text'}
</p>
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
)}
>
<Type className="h-4 w-4" />
Text
</p>
</CardContent>
</Card>
</button>
<p className="text-muted-foreground mt-2 text-xs">Custom Text</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.NUMBER)}
onMouseDown={() => setSelectedField(FieldType.NUMBER)}
data-selected={selectedField === FieldType.NUMBER ? true : undefined}
>
<Card
className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
)}
>
<Hash className="h-4 w-4" />
Number
</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.RADIO)}
onMouseDown={() => setSelectedField(FieldType.RADIO)}
data-selected={selectedField === FieldType.RADIO ? true : undefined}
>
<Card
className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
)}
>
<Disc className="h-4 w-4" />
Radio
</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.CHECKBOX)}
onMouseDown={() => setSelectedField(FieldType.CHECKBOX)}
data-selected={selectedField === FieldType.CHECKBOX ? true : undefined}
>
<Card
className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
)}
>
<CheckSquare className="h-4 w-4" />
Checkbox
</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.DROPDOWN)}
onMouseDown={() => setSelectedField(FieldType.DROPDOWN)}
data-selected={selectedField === FieldType.DROPDOWN ? true : undefined}
>
<Card
className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
)}
>
<ChevronDown className="h-4 w-4" />
Dropdown
</p>
</CardContent>
</Card>
</button>
</fieldset>
</div>
</div>
</div>
</div>
</DocumentFlowFormContainerContent>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep
title={documentFlow.title}
step={currentStep}
maxStep={totalSteps}
/>
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep
title={documentFlow.title}
step={currentStep}
maxStep={totalSteps}
/>
<DocumentFlowFormContainerActions
loading={isSubmitting}
disabled={isSubmitting}
goNextLabel="Save Template"
onGoBackClick={() => {
previousStep();
remove();
}}
onGoNextClick={() => void onFormSubmit()}
/>
</DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerActions
loading={isSubmitting}
disabled={isSubmitting}
goNextLabel="Save Template"
onGoBackClick={() => {
previousStep();
remove();
}}
onGoNextClick={() => void onFormSubmit()}
/>
</DocumentFlowFormContainerFooter>
</>
)}
</>
);
};

View File

@ -1,5 +1,6 @@
import { z } from 'zod';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { FieldType } from '@documenso/prisma/client';
export const ZAddTemplateFieldsFormSchema = z.object({
@ -16,6 +17,7 @@ export const ZAddTemplateFieldsFormSchema = z.object({
pageY: z.number().min(0),
pageWidth: z.number().min(0),
pageHeight: z.number().min(0),
fieldMeta: ZFieldMetaSchema,
}),
),
});

View File

@ -26,6 +26,7 @@ import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from '../document-flow/document-flow-root';
import { ShowFieldItem } from '../document-flow/show-field-item';
@ -168,6 +169,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
return (
<>
<DocumentFlowFormContainerHeader
title={documentFlow.title}
description={documentFlow.description}
/>
<DocumentFlowFormContainerContent>
{isDocumentPdfLoaded &&
fields.map((field, index) => (

View File

@ -40,6 +40,7 @@ import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from '../document-flow/document-flow-root';
import { ShowFieldItem } from '../document-flow/show-field-item';
@ -104,6 +105,11 @@ export const AddTemplateSettingsFormPartial = ({
return (
<>
<DocumentFlowFormContainerHeader
title={documentFlow.title}
description={documentFlow.description}
/>
<DocumentFlowFormContainerContent>
{isDocumentPdfLoaded &&
fields.map((field, index) => (

View File

@ -46,6 +46,13 @@
--warning: 54 96% 45%;
--gold: 47.9 95.8% 53.1%;
--signer-green: 100 48% 55%;
--signer-blue: 212 56% 50%;
--signer-purple: 266 100% 64%;
--signer-orange: 36 92% 54%;
--signer-yellow: 51 100% 43%;
--signer-pink: 313 65% 57%;
}
.dark {

View File

@ -1,11 +1,11 @@
{
"extends": "@documenso/tsconfig/react-library.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"include": ["."],
"exclude": ["dist", "build", "node_modules"]
}
"include": [
"."
],
"exclude": [
"dist",
"build",
"node_modules"
]
}

View File

@ -12,12 +12,9 @@
]
},
"prebuild": {
"cache": false,
"dependsOn": [
"^prebuild"
],
"outputs": [
".next/**",
"!.next/cache/**"
]
},
"lint": {
@ -141,4 +138,4 @@
"E2E_TEST_AUTHENTICATE_USER_EMAIL",
"E2E_TEST_AUTHENTICATE_USER_PASSWORD"
]
}
}