Compare commits

...

3 Commits

15 changed files with 350 additions and 69 deletions

View File

@ -0,0 +1,133 @@
'use client';
import { useCallback, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
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 type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { Form, FormControl, FormField } from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SigningFieldContainer } from './signing-field-container';
export type CheckboxFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
};
const CheckBoxSchema = z.object({
check: z.boolean().default(false).optional(),
});
export const CheckboxField = ({ field, recipient }: CheckboxFieldProps) => {
const router = useRouter();
const { toast } = useToast();
// const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const [isPending, startTransition] = useTransition();
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 form = useForm<z.infer<typeof CheckBoxSchema>>({
resolver: zodResolver(CheckBoxSchema),
defaultValues: {
check: false,
},
});
const onSign = useCallback(
async (authOptions?: TRecipientActionAuth) => {
try {
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: 'checked',
isBase64: true,
authOptions,
});
startTransition(() => router.refresh());
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
toast({
title: 'Error',
description: 'An error occurred while signing the document.',
variant: 'destructive',
});
}
},
[field.id, recipient.token, router, signFieldWithToken, toast],
);
const onRemove = async () => {
try {
await removeSignedFieldWithToken({
token: recipient.token,
fieldId: field.id,
});
startTransition(() => router.refresh());
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while removing the text.',
variant: 'destructive',
});
}
};
return (
<SigningFieldContainer
field={field}
onSign={onSign}
onRemove={onRemove}
type="Checkbox"
raw={true}
>
<Form {...form}>
<form>
<FormField
control={form.control}
name="check"
render={({ field }) => (
<FormControl>
<Checkbox
checked={field.value}
className="h-8 w-8"
onCheckedChange={field.onChange}
/>
</FormControl>
)}
/>
</form>
</Form>
</SigningFieldContainer>
);
};

View File

@ -35,8 +35,10 @@ export type SignatureFieldProps = {
*/
onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise<void> | void;
onRemove?: () => Promise<void> | void;
type?: 'Date' | 'Email' | 'Name' | 'Signature';
type?: 'Date' | 'Email' | 'Name' | 'Signature' | 'Checkbox';
tooltipText?: string | null;
raw?: boolean;
};
export const SigningFieldContainer = ({
@ -48,6 +50,7 @@ export const SigningFieldContainer = ({
children,
type,
tooltipText,
raw = false,
}: SignatureFieldProps) => {
const { executeActionAuthProcedure, isAuthRedirectRequired } = useRequiredDocumentAuthContext();
@ -103,8 +106,8 @@ export const SigningFieldContainer = ({
};
return (
<FieldRootContainer field={field}>
{!field.inserted && !loading && (
<FieldRootContainer raw={raw} field={field}>
{!field.inserted && !loading && !raw && (
<button
type="submit"
className="absolute inset-0 z-10 h-full w-full"

View File

@ -12,6 +12,7 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { truncateTitle } from '~/helpers/truncate-title';
import { CheckboxField } from './checkbox-field';
import { DateField } from './date-field';
import { EmailField } from './email-field';
import { SigningForm } from './form';
@ -94,6 +95,9 @@ export const SigningPageView = ({ document, recipient, fields }: SigningPageView
.with(FieldType.TEXT, () => (
<TextField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.CHECKBOX, () => (
<CheckboxField key={field.id} field={field} recipient={recipient} />
))
.otherwise(() => null),
)}
</ElementVisible>

View File

@ -188,10 +188,17 @@ export const signFieldWithToken = async ({
type,
data: signatureImageAsBase64 || typedSignature || '',
}))
.with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, FieldType.TEXT, (type) => ({
type,
data: updatedField.customText,
}))
.with(
FieldType.DATE,
FieldType.EMAIL,
FieldType.NAME,
FieldType.TEXT,
FieldType.CHECKBOX,
(type) => ({
type,
data: updatedField.customText,
}),
)
.exhaustive(),
fieldSecurity: derivedRecipientActionAuth
? {

View File

@ -1,6 +1,7 @@
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
import fontkit from '@pdf-lib/fontkit';
import { PDFDocument, StandardFonts } from 'pdf-lib';
import type { PDFDocument } from 'pdf-lib';
import { StandardFonts } from 'pdf-lib';
import {
DEFAULT_HANDWRITING_FONT_SIZE,
@ -18,6 +19,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
);
const isSignatureField = isSignatureFieldType(field.type);
const isCheckboxField = field.type === FieldType.CHECKBOX;
pdf.registerFontkit(fontkit);
@ -73,6 +75,28 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
width: imageWidth,
height: imageHeight,
});
} else if (isCheckboxField) {
const form = pdf.getForm();
const checkBox = form.createCheckBox(`checkBox.field.${field.id}`);
const textX = fieldX + fieldWidth / 2;
let textY = fieldY + fieldHeight / 2;
textY = pageHeight - textY;
checkBox.addToPage(page, {
x: textX,
y: textY,
width: 16,
height: 16,
borderWidth: 1,
});
if (field.customText === '✓') {
checkBox.check();
}
form.getField(`checkBox.field.${field.id}`).enableReadOnly();
} else {
const longestLineInTextForWidth = field.customText
.split('\n')
@ -102,14 +126,3 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
return pdf;
};
export const insertFieldInPDFBytes = async (
pdf: ArrayBuffer | Uint8Array | string,
field: FieldWithSignature,
) => {
const pdfDoc = await PDFDocument.load(pdf);
await insertFieldInPDF(pdfDoc, field);
return await pdfDoc.save();
};

View File

@ -231,6 +231,10 @@ export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = z.object({
type: z.literal(FieldType.TEXT),
data: z.string(),
}),
z.object({
type: z.literal(FieldType.CHECKBOX),
data: z.string(),
}),
z.object({
type: z.union([z.literal(FieldType.SIGNATURE), z.literal(FieldType.FREE_SIGNATURE)]),
data: z.string(),

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "FieldType" ADD VALUE 'CHECKBOX';

View File

@ -379,6 +379,7 @@ enum FieldType {
EMAIL
DATE
TEXT
CHECKBOX
}
model Field {

View File

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

View File

@ -18,6 +18,7 @@ export type FieldRootContainerProps = {
export type FieldContainerPortalProps = {
field: Field;
className?: string;
raw?: boolean;
children: React.ReactNode;
};
@ -44,7 +45,7 @@ export function FieldContainerPortal({
);
}
export function FieldRootContainer({ field, children }: FieldContainerPortalProps) {
export function FieldRootContainer({ field, children, raw = false }: FieldContainerPortalProps) {
const [isValidating, setIsValidating] = useState(false);
const ref = React.useRef<HTMLDivElement>(null);
@ -71,21 +72,36 @@ export function FieldRootContainer({ field, children }: FieldContainerPortalProp
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',
{
{!raw && (
<Card
id={`field-${field.id}`}
className={cn(
'field-card-container bg-background relative z-20 h-full w-full transition-all',
{
'border-orange-300 ring-1 ring-orange-300': !field.inserted && isValidating,
},
)}
ref={ref}
data-inserted={field.inserted ? 'true' : 'false'}
>
<CardContent className="text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2">
{children}
</CardContent>
</Card>
)}
{raw && (
<div
id={`field-${field.id}`}
className={cn('field-card-container bg-background relative z-20 transition-all', {
'border-orange-300 ring-1 ring-orange-300': !field.inserted && isValidating,
},
)}
ref={ref}
data-inserted={field.inserted ? 'true' : 'false'}
>
<CardContent className="text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2">
})}
ref={ref}
data-inserted={field.inserted ? 'true' : 'false'}
>
{children}
</CardContent>
</Card>
</div>
)}
</FieldContainerPortal>
);
}

View File

@ -19,6 +19,7 @@ import { FieldType, SendStatus } from '@documenso/prisma/client';
import { cn } from '../../lib/utils';
import { Button } from '../button';
import { Card, CardContent } from '../card';
import { Checkbox } from '../checkbox';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from '../command';
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
import { useStep } from '../stepper';
@ -135,11 +136,17 @@ export const AddFieldsFormPartial = ({
);
setCoords({
x: event.clientX - fieldBounds.current.width / 2,
y: event.clientY - fieldBounds.current.height / 2,
x:
selectedField === FieldType.CHECKBOX
? event.clientX - 16
: event.clientX - fieldBounds.current.width / 2,
y:
selectedField === FieldType.CHECKBOX
? event.clientY - 16
: event.clientY - fieldBounds.current.height / 2,
});
},
[isWithinPageBounds],
[isWithinPageBounds, selectedField],
);
const onMouseClick = useCallback(
@ -149,6 +156,7 @@ export const AddFieldsFormPartial = ({
}
const $page = getPage(event, PDF_VIEWER_PAGE_SELECTOR);
const isCheckboxField = selectedField === FieldType.CHECKBOX;
if (
!$page ||
@ -172,8 +180,8 @@ export const AddFieldsFormPartial = ({
let pageY = ((event.pageY - top) / height) * 100;
// Get the bounds as a percentage of the page width and height
const fieldPageWidth = (fieldBounds.current.width / width) * 100;
const fieldPageHeight = (fieldBounds.current.height / height) * 100;
const fieldPageWidth = ((isCheckboxField ? 32 : fieldBounds.current.width) / width) * 100;
const fieldPageHeight = ((isCheckboxField ? 32 : fieldBounds.current.height) / height) * 100;
// And center it based on the bounds
pageX -= fieldPageWidth / 2;
@ -322,7 +330,8 @@ export const AddFieldsFormPartial = ({
<DocumentFlowFormContainerContent>
<div className="flex flex-col">
{selectedField && (
{/* When it is not a checkbox field */}
{selectedField && selectedField !== FieldType.CHECKBOX && (
<Card
className={cn(
'bg-field-card/80 pointer-events-none fixed z-50 cursor-pointer border-2 backdrop-blur-[1px]',
@ -344,6 +353,27 @@ export const AddFieldsFormPartial = ({
</Card>
)}
{/* Checkbox Field */}
{selectedField && selectedField === FieldType.CHECKBOX && (
<div
className="pointer-events-none fixed z-50"
style={{
top: coords.y,
left: coords.x,
height: 6 * 4,
width: 6 * 4,
}}
>
<Checkbox
className={cn(
'bg-field-card/80 h-8 w-8 border-2 backdrop-blur-[1px]',
'shadow-[0_0_0_4px_theme(colors.gray.100/70%),0_0_0_1px_theme(colors.gray.100/70%),0_0_0_0.5px_theme(colors.primary.DEFAULT/70%)]',
'border-field-card-border',
)}
/>
</div>
)}
{isDocumentPdfLoaded &&
localFields.map((field, index) => (
<FieldItem
@ -577,6 +607,28 @@ export const AddFieldsFormPartial = ({
</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="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
)}
>
{'Checkbox'}
</p>
<p className="text-muted-foreground mt-2 text-xs">Checkbox</p>
</CardContent>
</Card>
</button>
</fieldset>
</div>
</div>

View File

@ -147,6 +147,11 @@ export const AddSignatureFormPartial = ({
return !form.formState.errors.customText;
}
if (fieldType === FieldType.CHECKBOX) {
await form.trigger('customText');
return !form.formState.errors.customText;
}
return true;
};

View File

@ -10,8 +10,10 @@ import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { cn } from '../../lib/utils';
import { Card, CardContent } from '../card';
import { Checkbox } from '../checkbox';
import type { TDocumentFlowFormSchema } from './types';
import { FRIENDLY_FIELD_TYPE } from './types';
import { FieldType } from '.prisma/client';
type Field = TDocumentFlowFormSchema['fields'][0];
@ -44,6 +46,8 @@ export const FieldItem = ({
pageWidth: 0,
});
const isCheckboxField = field.type === FieldType.CHECKBOX;
const calculateCoords = useCallback(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
@ -102,8 +106,8 @@ export const FieldItem = ({
default={{
x: coords.pageX,
y: coords.pageY,
height: coords.pageHeight,
width: coords.pageWidth,
height: isCheckboxField ? 32 : coords.pageHeight,
width: isCheckboxField ? 32 : coords.pageWidth,
}}
bounds={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`}
onDragStart={() => setActive(true)}
@ -119,7 +123,13 @@ export const FieldItem = ({
>
{!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"
className={cn(
'text-muted-foreground/50 hover:text-muted-foreground/80 bg-background absolute -right-2 -top-2 z-20 flex items-center justify-center rounded-full border',
{
'h-8 w-8': !isCheckboxField,
'h-6 w-6': isCheckboxField,
},
)}
onClick={() => onRemove?.()}
onTouchEnd={() => onRemove?.()}
>
@ -127,25 +137,40 @@ export const FieldItem = ({
</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
{!isCheckboxField && (
<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
className={cn(
'text-field-card-foreground flex h-full w-full flex-col items-center justify-center p-2',
{
'text-field-card-foreground/50': disabled,
},
)}
>
{FRIENDLY_FIELD_TYPE[field.type]}
<p className="w-full truncate text-center text-xs">{field.signerEmail}</p>
</CardContent>
</Card>
)}
{isCheckboxField && (
<Checkbox
className={cn(
'text-field-card-foreground flex h-full w-full flex-col items-center justify-center p-2',
'bg-field-card/80 h-8 w-8 border-2 backdrop-blur-[1px]',
'shadow-[0_0_0_4px_theme(colors.gray.100/70%),0_0_0_1px_theme(colors.gray.100/70%),0_0_0_0.5px_theme(colors.primary.DEFAULT/70%)]',
{
'text-field-card-foreground/50': disabled,
'border-field-card-border': !disabled,
'border-field-card-border/80': active,
},
)}
>
{FRIENDLY_FIELD_TYPE[field.type]}
<p className="w-full truncate text-center text-xs">{field.signerEmail}</p>
</CardContent>
</Card>
/>
)}
</Rnd>,
document.body,
);

View File

@ -4,9 +4,11 @@ import type { Prisma } from '@prisma/client';
import { createPortal } from 'react-dom';
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
import { FieldType } from '@documenso/prisma/client';
import { cn } from '../../lib/utils';
import { Card, CardContent } from '../card';
import { Checkbox } from '../checkbox';
import { FRIENDLY_FIELD_TYPE } from './types';
export type ShowFieldItemProps = {
@ -19,6 +21,7 @@ export const ShowFieldItem = ({ field, recipients }: ShowFieldItemProps) => {
const signerEmail =
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '';
const isCheckboxField = field.type === FieldType.CHECKBOX;
return createPortal(
<div
@ -30,19 +33,30 @@ export const ShowFieldItem = ({ field, recipients }: ShowFieldItemProps) => {
width: `${coords.width}px`,
}}
>
<Card className={cn('bg-background h-full w-full')}>
<CardContent
className={cn(
'text-muted-foreground/50 flex h-full w-full flex-col items-center justify-center p-2',
)}
>
{FRIENDLY_FIELD_TYPE[field.type]}
{!isCheckboxField && (
<Card className={cn('bg-background h-full w-full')}>
<CardContent
className={cn(
'text-muted-foreground/50 flex h-full w-full flex-col items-center justify-center p-2',
)}
>
{FRIENDLY_FIELD_TYPE[field.type]}
<p className="text-muted-foreground/50 w-full truncate text-center text-xs">
{signerEmail}
</p>
</CardContent>
</Card>
<p className="text-muted-foreground/50 w-full truncate text-center text-xs">
{signerEmail}
</p>
</CardContent>
</Card>
)}
{isCheckboxField && (
<Checkbox
className={cn(
'h-8 w-8',
'shadow-[0_0_0_4px_theme(colors.gray.100/70%),0_0_0_1px_theme(colors.gray.100/70%),0_0_0_0.5px_theme(colors.primary.DEFAULT/70%)]',
)}
/>
)}
</div>,
document.body,
);

View File

@ -48,6 +48,7 @@ export const FRIENDLY_FIELD_TYPE: Record<FieldType, string> = {
[FieldType.DATE]: 'Date',
[FieldType.EMAIL]: 'Email',
[FieldType.NAME]: 'Name',
[FieldType.CHECKBOX]: 'Checkbox',
};
export interface DocumentFlowStep {