feat: add checkbox field

This commit is contained in:
Ephraim Atta-Duncan
2024-04-09 21:17:30 +00:00
parent 6285ef2cc0
commit 0f0f198b44
11 changed files with 271 additions and 16 deletions

View File

@ -0,0 +1,195 @@
'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 type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { SigningFieldContainer } from './signing-field-container';
export type CheckboxFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
};
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 [showCustomTextModal, setShowCustomTextModal] = useState(false);
const [localText, setLocalCustomText] = useState('');
useEffect(() => {
if (!showCustomTextModal) {
setLocalCustomText('');
}
}, [showCustomTextModal]);
/**
* When the user clicks the sign button in the dialog where they enter the text field.
*/
const onDialogSignClick = () => {
setShowCustomTextModal(false);
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
};
const onPreSign = () => {
if (!localText) {
setShowCustomTextModal(true);
return false;
}
return true;
};
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
if (!localText) {
return;
}
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: localText,
isBase64: true,
authOptions,
});
setLocalCustomText('');
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 {
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}
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="group-hover:text-primary text-muted-foreground text-lg duration-200">
Checkbox
</p>
)}
{field.inserted && <p className="text-muted-foreground duration-200">{field.customText}</p>}
{/* TODO : Avoid the whole dialog thing */}
<Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}>
<DialogContent>
<DialogTitle>Check Field</DialogTitle>
<div className="mt-4 flex items-center space-x-2">
<Checkbox
id="checkbox-field"
onCheckedChange={(checked) => {
setLocalCustomText(checked ? '✓' : '𐄂');
}}
/>
<label
htmlFor="checkbox-field"
className="font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Check Field
</label>
</div>
<DialogFooter>
<div className="mt-4 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={() => {
setShowCustomTextModal(false);
setLocalCustomText('');
}}
>
Cancel
</Button>
<Button type="button" className="flex-1" onClick={() => onDialogSignClick()}>
Save Checkbox
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</SigningFieldContainer>
);
};

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

@ -577,6 +577,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

@ -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 {