From 0f0f198b44b39f2b007f034be5670f7ebc0c53e8 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Tue, 9 Apr 2024 21:17:30 +0000 Subject: [PATCH] feat: add checkbox field --- .../(signing)/sign/[token]/checkbox-field.tsx | 195 ++++++++++++++++++ .../sign/[token]/signing-page-view.tsx | 4 + .../field/sign-field-with-token.ts | 15 +- .../server-only/pdf/insert-field-in-pdf.ts | 37 ++-- packages/lib/types/document-audit-logs.ts | 4 + .../migration.sql | 2 + packages/prisma/schema.prisma | 1 + .../trpc/server/singleplayer-router/helper.ts | 1 + .../primitives/document-flow/add-fields.tsx | 22 ++ .../document-flow/add-signature.tsx | 5 + packages/ui/primitives/document-flow/types.ts | 1 + 11 files changed, 271 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/app/(signing)/sign/[token]/checkbox-field.tsx create mode 100644 packages/prisma/migrations/20240409193420_checkbox_field/migration.sql diff --git a/apps/web/src/app/(signing)/sign/[token]/checkbox-field.tsx b/apps/web/src/app/(signing)/sign/[token]/checkbox-field.tsx new file mode 100644 index 000000000..caf2293e6 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/checkbox-field.tsx @@ -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 ( + + {isLoading && ( +
+ +
+ )} + + {!field.inserted && ( +

+ Checkbox +

+ )} + + {field.inserted &&

{field.customText}

} + + {/* TODO : Avoid the whole dialog thing */} + + + Check Field + +
+ { + setLocalCustomText(checked ? '✓' : '𐄂'); + }} + /> + +
+ + +
+ + + +
+
+
+
+
+ ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx index c04679956..73ba3b349 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx @@ -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, () => ( )) + .with(FieldType.CHECKBOX, () => ( + + )) .otherwise(() => null), )} diff --git a/packages/lib/server-only/field/sign-field-with-token.ts b/packages/lib/server-only/field/sign-field-with-token.ts index b8a5ccf8f..5ee080cac 100644 --- a/packages/lib/server-only/field/sign-field-with-token.ts +++ b/packages/lib/server-only/field/sign-field-with-token.ts @@ -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 ? { diff --git a/packages/lib/server-only/pdf/insert-field-in-pdf.ts b/packages/lib/server-only/pdf/insert-field-in-pdf.ts index 1aabf96b8..1282b432c 100644 --- a/packages/lib/server-only/pdf/insert-field-in-pdf.ts +++ b/packages/lib/server-only/pdf/insert-field-in-pdf.ts @@ -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(); -}; diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts index cfdedd462..62305bb74 100644 --- a/packages/lib/types/document-audit-logs.ts +++ b/packages/lib/types/document-audit-logs.ts @@ -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(), diff --git a/packages/prisma/migrations/20240409193420_checkbox_field/migration.sql b/packages/prisma/migrations/20240409193420_checkbox_field/migration.sql new file mode 100644 index 000000000..adaf8c05d --- /dev/null +++ b/packages/prisma/migrations/20240409193420_checkbox_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "FieldType" ADD VALUE 'CHECKBOX'; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 35d429779..c99f0b4c0 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -379,6 +379,7 @@ enum FieldType { EMAIL DATE TEXT + CHECKBOX } model Field { diff --git a/packages/trpc/server/singleplayer-router/helper.ts b/packages/trpc/server/singleplayer-router/helper.ts index 32d03c0ac..64f3add06 100644 --- a/packages/trpc/server/singleplayer-router/helper.ts +++ b/packages/trpc/server/singleplayer-router/helper.ts @@ -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 { diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index 3031d6479..c77b014e7 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -577,6 +577,28 @@ export const AddFieldsFormPartial = ({ + + diff --git a/packages/ui/primitives/document-flow/add-signature.tsx b/packages/ui/primitives/document-flow/add-signature.tsx index f1ebc885e..802bf49ee 100644 --- a/packages/ui/primitives/document-flow/add-signature.tsx +++ b/packages/ui/primitives/document-flow/add-signature.tsx @@ -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; }; diff --git a/packages/ui/primitives/document-flow/types.ts b/packages/ui/primitives/document-flow/types.ts index 82f5706e6..850efe0cf 100644 --- a/packages/ui/primitives/document-flow/types.ts +++ b/packages/ui/primitives/document-flow/types.ts @@ -48,6 +48,7 @@ export const FRIENDLY_FIELD_TYPE: Record = { [FieldType.DATE]: 'Date', [FieldType.EMAIL]: 'Email', [FieldType.NAME]: 'Name', + [FieldType.CHECKBOX]: 'Checkbox', }; export interface DocumentFlowStep {