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

@@ -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, () => (
.with(FieldType.TEXT, () => {
const parsedFieldMeta = field.fieldMeta
? ZTextFieldMeta.parse(field.fieldMeta)
: null;
return (
<TextField
key={field.id}
field={field}
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,21 +109,38 @@ export const SigningFieldContainer = ({
await onRemove?.();
};
const onClearCheckBoxValues = async (fieldType?: string) => {
if (!field.inserted) {
return;
}
await onRemove?.(fieldType);
};
return (
<div className={cn(type === 'Checkbox' ? 'group' : '')}>
<FieldRootContainer field={field}>
{!field.inserted && !loading && (
{!field.inserted && !loading && !readOnlyField && (
<button
type="submit"
className="absolute inset-0 z-10 h-full w-full"
className="absolute inset-0 z-10 h-full w-full rounded-md border"
onClick={async () => handleInsertField()}
/>
)}
{type === 'Date' && field.inserted && !loading && (
{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>
)}
{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 backdrop-blur-sm duration-200 group-hover:opacity-100"
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
@@ -127,9 +151,20 @@ export const SigningFieldContainer = ({
</Tooltip>
)}
{type !== 'Date' && field.inserted && !loading && (
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<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"
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
@@ -138,5 +173,6 @@ export const SigningFieldContainer = ({
{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;
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,10 +79,13 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
await pdf.embedFont(fontCaveat);
}
const isInsertingImage =
isSignatureField && typeof field.Signature?.signatureImageAsBase64 === 'string';
if (isSignatureField && isInsertingImage) {
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 ?? '');
let imageWidth = image.width;
@@ -117,7 +122,81 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
height: imageHeight,
rotate: degrees(pageRotationInDegrees),
});
} else {
},
)
.with({ type: FieldType.CHECKBOX }, (field) => {
const meta = ZCheckboxFieldMeta.safeParse(field.fieldMeta);
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];
@@ -156,7 +235,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
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) => ({
.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);
return createPortal(
<div
className={cn('absolute', className)}
style={{
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={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(
@@ -337,21 +434,33 @@ export const AddFieldsFormPartial = ({
};
return (
<>
{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 && (
<Card
<div
className={cn(
'bg-field-card/80 pointer-events-none fixed z-50 cursor-pointer border-2 backdrop-blur-[1px]',
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200',
selectedSignerStyles.default.base,
{
'border-field-card-border': isFieldWithinBounds,
'opacity-50': !isFieldWithinBounds,
'-rotate-6 scale-90 opacity-50': !isFieldWithinBounds,
},
)}
style={{
@@ -361,26 +470,36 @@ export const AddFieldsFormPartial = ({
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>
</div>
)}
{isDocumentPdfLoaded &&
localFields.map((field, index) => (
localFields.map((field, index) => {
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail);
return (
<FieldItem
key={index}
recipientIndex={recipientIndex === -1 ? 0 : recipientIndex}
field={field}
disabled={selectedSigner?.email !== field.signerEmail || hasSelectedSignerBeenSent}
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}
/>
))}
);
})}
{!hideRecipients && (
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
@@ -389,7 +508,10 @@ export const AddFieldsFormPartial = ({
type="button"
variant="outline"
role="combobox"
className="bg-background text-muted-foreground mb-12 justify-between font-normal"
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">
@@ -398,7 +520,9 @@ export const AddFieldsFormPartial = ({
)}
{!selectedSigner?.email && (
<span className="flex-1 truncate text-left">{selectedSigner?.email}</span>
<span className="gradie flex-1 truncate text-left">
{selectedSigner?.email}
</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4" />
@@ -415,13 +539,13 @@ export const AddFieldsFormPartial = ({
</span>
</CommandEmpty>
{recipientsByRoleToDisplay.map(([role, recipients], roleIndex) => (
{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>
{recipients.length === 0 && (
{roleRecipients.length === 0 && (
<div
key={`${role}-empty`}
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
@@ -430,12 +554,21 @@ export const AddFieldsFormPartial = ({
</div>
)}
{recipients.map((recipient) => (
{roleRecipients.map((recipient) => (
<CommandItem
key={recipient.id}
className={cn('px-2 last:mb-1 [&:not(:first-child)]:mt-1', {
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);
@@ -473,8 +606,8 @@ export const AddFieldsFormPartial = ({
</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.
This document has already been sent to this recipient. You can
no longer edit this recipient.
</TooltipContent>
</Tooltip>
)}
@@ -489,7 +622,7 @@ export const AddFieldsFormPartial = ({
)}
<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">
<fieldset disabled={isFieldsDisabled} className="my-2 grid grid-cols-3 gap-4">
<button
type="button"
className="group h-full w-full"
@@ -497,18 +630,21 @@ export const AddFieldsFormPartial = ({
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">
<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 w-full truncate text-3xl font-medium',
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-lg font-normal',
fontCaveat.className,
)}
>
{selectedSigner?.name || 'Signature'}
Signature
</p>
<p className="text-muted-foreground mt-2 text-center text-xs">Signature</p>
</CardContent>
</Card>
</button>
@@ -520,17 +656,21 @@ export const AddFieldsFormPartial = ({
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
<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'}
<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>
<p className="text-muted-foreground mt-2 text-xs">Email</p>
</CardContent>
</Card>
</button>
@@ -542,17 +682,21 @@ export const AddFieldsFormPartial = ({
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
<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'}
<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>
<p className="text-muted-foreground mt-2 text-xs">Name</p>
</CardContent>
</Card>
</button>
@@ -564,17 +708,21 @@ export const AddFieldsFormPartial = ({
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
<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'}
<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>
<p className="text-muted-foreground mt-2 text-xs">Date</p>
</CardContent>
</Card>
</button>
@@ -586,17 +734,125 @@ export const AddFieldsFormPartial = ({
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
<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'}
<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>
<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>
@@ -604,7 +860,21 @@ export const AddFieldsFormPartial = ({
</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}
@@ -615,6 +885,7 @@ export const AddFieldsFormPartial = ({
<DocumentFlowFormContainerActions
loading={isSubmitting}
disabled={isSubmitting}
disableNextStep={hasErrors}
onGoBackClick={() => {
previousStep();
remove();
@@ -630,5 +901,7 @@ export const AddFieldsFormPartial = ({
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,7 +393,16 @@ export const AddSignatureFormPartial = ({
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{localFields.map((field) =>
match(field.type)
.with(FieldType.DATE, FieldType.TEXT, FieldType.EMAIL, FieldType.NAME, () => {
.with(
FieldType.DATE,
FieldType.TEXT,
FieldType.EMAIL,
FieldType.NAME,
FieldType.NUMBER,
FieldType.CHECKBOX,
FieldType.RADIO,
FieldType.DROPDOWN,
() => {
return (
<SinglePlayerModeCustomTextField
onClick={insertField(field)}
@@ -382,7 +410,8 @@ export const AddSignatureFormPartial = ({
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,
})}
>
<CardContent
<div
className={cn(
'text-field-card-foreground flex h-full w-full flex-col items-center justify-center p-2',
'relative flex h-full w-full items-center justify-center bg-white',
signerStyles.default.base,
signerStyles.default.fieldItem,
)}
onClick={() => {
setSettingsActive((prev) => !prev);
}}
ref={$el}
>
{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}
/>
))}
{!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,
{
'text-field-card-foreground/50': disabled,
'!opacity-50': disabled || passive,
},
)}
>
{FRIENDLY_FIELD_TYPE[field.type]}
{(field.signerEmail?.charAt(0)?.toUpperCase() ?? '') +
(field.signerEmail?.charAt(1)?.toUpperCase() ?? '')}
</div>
</div>
)}
</div>
<p className="w-full truncate text-center text-xs">{field.signerEmail}</p>
</CardContent>
</Card>
{!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,6 +165,7 @@ export function SinglePlayerModeCustomTextField({
const fontSize = maxFontSize * scalingFactor;
return (
<>
<SinglePlayerModeFieldCardContainer field={field}>
{field.inserted ? (
<p
@@ -161,7 +175,10 @@ export function SinglePlayerModeCustomTextField({
fontFamily: `var(${fontVariable})`,
}}
>
{field.customText}
{field.customText ??
(field.fieldMeta && typeof field.fieldMeta === 'object' && 'label' in field.fieldMeta
? field.fieldMeta.label
: '')}
</p>
) : (
<button
@@ -169,15 +186,61 @@ export function SinglePlayerModeCustomTextField({
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')
.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,17 +356,37 @@ export const AddTemplateFieldsFormPartial = ({
);
}, [recipientsByRole]);
const handleAdvancedSettings = () => {
setShowAdvancedSettings((prev) => !prev);
};
return (
<>
{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 && (
<Card
<div
className={cn(
'bg-background pointer-events-none fixed z-50 cursor-pointer transition-opacity',
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200',
selectedSignerStyles.default.base,
{
'border-primary': isFieldWithinBounds,
'opacity-50': !isFieldWithinBounds,
'-rotate-6 scale-90 opacity-50': !isFieldWithinBounds,
},
)}
style={{
@@ -334,15 +396,17 @@ export const AddTemplateFieldsFormPartial = ({
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>
</div>
)}
{localFields.map((field, index) => (
{localFields.map((field, index) => {
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail);
return (
<FieldItem
key={index}
recipientIndex={recipientIndex === -1 ? 0 : recipientIndex}
field={field}
disabled={selectedSigner?.email !== field.signerEmail}
minHeight={fieldBounds.current.height}
@@ -351,8 +415,14 @@ export const AddTemplateFieldsFormPartial = ({
onResize={(options) => onFieldResize(options, index)}
onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)}
onAdvancedSettings={() => {
setCurrentField(field);
handleAdvancedSettings();
}}
hideRecipients={hideRecipients}
/>
))}
);
})}
{!hideRecipients && (
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
@@ -361,7 +431,10 @@ export const AddTemplateFieldsFormPartial = ({
type="button"
variant="outline"
role="combobox"
className="bg-background text-muted-foreground mb-12 justify-between font-normal"
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">
@@ -370,7 +443,9 @@ export const AddTemplateFieldsFormPartial = ({
)}
{!selectedSigner?.email && (
<span className="flex-1 truncate text-left">{selectedSigner?.email}</span>
<span className="gradie flex-1 truncate text-left">
{selectedSigner?.email}
</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4" />
@@ -378,21 +453,22 @@ export const AddTemplateFieldsFormPartial = ({
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<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) => (
{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>
{recipients.length === 0 && (
{roleRecipients.length === 0 && (
<div
key={`${role}-empty`}
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
@@ -401,10 +477,18 @@ export const AddTemplateFieldsFormPartial = ({
</div>
)}
{recipients.map((recipient) => (
{roleRecipients.map((recipient) => (
<CommandItem
key={recipient.id}
className={cn('px-2 last:mb-1 [&:not(:first-child)]:mt-1')}
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);
@@ -435,27 +519,29 @@ export const AddTemplateFieldsFormPartial = ({
)}
<div className="-mx-2 flex-1 overflow-y-auto px-2">
<div className="grid grid-cols-2 gap-x-4 gap-y-8">
<fieldset className="my-2 grid grid-cols-3 gap-4">
<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">
<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 w-full truncate text-3xl font-medium',
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-lg font-normal',
fontCaveat.className,
)}
>
{selectedSigner?.name || 'Signature'}
Signature
</p>
<p className="text-muted-foreground mt-2 text-center text-xs">Signature</p>
</CardContent>
</Card>
</button>
@@ -463,22 +549,25 @@ export const AddTemplateFieldsFormPartial = ({
<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
<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'}
<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>
<p className="text-muted-foreground mt-2 text-xs">Email</p>
</CardContent>
</Card>
</button>
@@ -486,22 +575,25 @@ export const AddTemplateFieldsFormPartial = ({
<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
<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'}
<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>
<p className="text-muted-foreground mt-2 text-xs">Name</p>
</CardContent>
</Card>
</button>
@@ -509,22 +601,25 @@ export const AddTemplateFieldsFormPartial = ({
<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
<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'}
<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>
<p className="text-muted-foreground mt-2 text-xs">Date</p>
</CardContent>
</Card>
</button>
@@ -536,21 +631,129 @@ export const AddTemplateFieldsFormPartial = ({
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
<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'}
<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>
<p className="text-muted-foreground mt-2 text-xs">Custom Text</p>
</CardContent>
</Card>
</button>
</div>
<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>
@@ -574,5 +777,7 @@ export const AddTemplateFieldsFormPartial = ({
/>
</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": {