mirror of
https://github.com/documenso/documenso.git
synced 2025-11-16 01:32:06 +10:00
feat: add checkbox field
This commit is contained in:
195
apps/web/src/app/(signing)/sign/[token]/checkbox-field.tsx
Normal file
195
apps/web/src/app/(signing)/sign/[token]/checkbox-field.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -12,6 +12,7 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
|||||||
|
|
||||||
import { truncateTitle } from '~/helpers/truncate-title';
|
import { truncateTitle } from '~/helpers/truncate-title';
|
||||||
|
|
||||||
|
import { CheckboxField } from './checkbox-field';
|
||||||
import { DateField } from './date-field';
|
import { DateField } from './date-field';
|
||||||
import { EmailField } from './email-field';
|
import { EmailField } from './email-field';
|
||||||
import { SigningForm } from './form';
|
import { SigningForm } from './form';
|
||||||
@ -94,6 +95,9 @@ export const SigningPageView = ({ document, recipient, fields }: SigningPageView
|
|||||||
.with(FieldType.TEXT, () => (
|
.with(FieldType.TEXT, () => (
|
||||||
<TextField key={field.id} field={field} recipient={recipient} />
|
<TextField key={field.id} field={field} recipient={recipient} />
|
||||||
))
|
))
|
||||||
|
.with(FieldType.CHECKBOX, () => (
|
||||||
|
<CheckboxField key={field.id} field={field} recipient={recipient} />
|
||||||
|
))
|
||||||
.otherwise(() => null),
|
.otherwise(() => null),
|
||||||
)}
|
)}
|
||||||
</ElementVisible>
|
</ElementVisible>
|
||||||
|
|||||||
@ -188,10 +188,17 @@ export const signFieldWithToken = async ({
|
|||||||
type,
|
type,
|
||||||
data: signatureImageAsBase64 || typedSignature || '',
|
data: signatureImageAsBase64 || typedSignature || '',
|
||||||
}))
|
}))
|
||||||
.with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, FieldType.TEXT, (type) => ({
|
.with(
|
||||||
|
FieldType.DATE,
|
||||||
|
FieldType.EMAIL,
|
||||||
|
FieldType.NAME,
|
||||||
|
FieldType.TEXT,
|
||||||
|
FieldType.CHECKBOX,
|
||||||
|
(type) => ({
|
||||||
type,
|
type,
|
||||||
data: updatedField.customText,
|
data: updatedField.customText,
|
||||||
}))
|
}),
|
||||||
|
)
|
||||||
.exhaustive(),
|
.exhaustive(),
|
||||||
fieldSecurity: derivedRecipientActionAuth
|
fieldSecurity: derivedRecipientActionAuth
|
||||||
? {
|
? {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
|
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
|
||||||
import fontkit from '@pdf-lib/fontkit';
|
import fontkit from '@pdf-lib/fontkit';
|
||||||
import { PDFDocument, StandardFonts } from 'pdf-lib';
|
import type { PDFDocument } from 'pdf-lib';
|
||||||
|
import { StandardFonts } from 'pdf-lib';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_HANDWRITING_FONT_SIZE,
|
DEFAULT_HANDWRITING_FONT_SIZE,
|
||||||
@ -18,6 +19,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isSignatureField = isSignatureFieldType(field.type);
|
const isSignatureField = isSignatureFieldType(field.type);
|
||||||
|
const isCheckboxField = field.type === FieldType.CHECKBOX;
|
||||||
|
|
||||||
pdf.registerFontkit(fontkit);
|
pdf.registerFontkit(fontkit);
|
||||||
|
|
||||||
@ -73,6 +75,28 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
|||||||
width: imageWidth,
|
width: imageWidth,
|
||||||
height: imageHeight,
|
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 {
|
} else {
|
||||||
const longestLineInTextForWidth = field.customText
|
const longestLineInTextForWidth = field.customText
|
||||||
.split('\n')
|
.split('\n')
|
||||||
@ -102,14 +126,3 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
|||||||
|
|
||||||
return pdf;
|
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();
|
|
||||||
};
|
|
||||||
|
|||||||
@ -231,6 +231,10 @@ export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = z.object({
|
|||||||
type: z.literal(FieldType.TEXT),
|
type: z.literal(FieldType.TEXT),
|
||||||
data: z.string(),
|
data: z.string(),
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal(FieldType.CHECKBOX),
|
||||||
|
data: z.string(),
|
||||||
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
type: z.union([z.literal(FieldType.SIGNATURE), z.literal(FieldType.FREE_SIGNATURE)]),
|
type: z.union([z.literal(FieldType.SIGNATURE), z.literal(FieldType.FREE_SIGNATURE)]),
|
||||||
data: z.string(),
|
data: z.string(),
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "FieldType" ADD VALUE 'CHECKBOX';
|
||||||
@ -379,6 +379,7 @@ enum FieldType {
|
|||||||
EMAIL
|
EMAIL
|
||||||
DATE
|
DATE
|
||||||
TEXT
|
TEXT
|
||||||
|
CHECKBOX
|
||||||
}
|
}
|
||||||
|
|
||||||
model Field {
|
model Field {
|
||||||
|
|||||||
@ -23,6 +23,7 @@ export const mapField = (
|
|||||||
.with(FieldType.EMAIL, () => signer.email)
|
.with(FieldType.EMAIL, () => signer.email)
|
||||||
.with(FieldType.NAME, () => signer.name)
|
.with(FieldType.NAME, () => signer.name)
|
||||||
.with(FieldType.TEXT, () => signer.customText)
|
.with(FieldType.TEXT, () => signer.customText)
|
||||||
|
.with(FieldType.CHECKBOX, () => signer.customText)
|
||||||
.otherwise(() => '');
|
.otherwise(() => '');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -577,6 +577,28 @@ export const AddFieldsFormPartial = ({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</button>
|
</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>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -147,6 +147,11 @@ export const AddSignatureFormPartial = ({
|
|||||||
return !form.formState.errors.customText;
|
return !form.formState.errors.customText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fieldType === FieldType.CHECKBOX) {
|
||||||
|
await form.trigger('customText');
|
||||||
|
return !form.formState.errors.customText;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -48,6 +48,7 @@ export const FRIENDLY_FIELD_TYPE: Record<FieldType, string> = {
|
|||||||
[FieldType.DATE]: 'Date',
|
[FieldType.DATE]: 'Date',
|
||||||
[FieldType.EMAIL]: 'Email',
|
[FieldType.EMAIL]: 'Email',
|
||||||
[FieldType.NAME]: 'Name',
|
[FieldType.NAME]: 'Name',
|
||||||
|
[FieldType.CHECKBOX]: 'Checkbox',
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface DocumentFlowStep {
|
export interface DocumentFlowStep {
|
||||||
|
|||||||
Reference in New Issue
Block a user