feat: persist fields and recipients for document editing

This commit is contained in:
Mythie
2023-06-21 23:49:23 +10:00
parent 3aea62e898
commit eea09dcfac
28 changed files with 1432 additions and 113 deletions

View File

@ -21,6 +21,7 @@
"framer-motion": "^10.12.8", "framer-motion": "^10.12.8",
"lucide-react": "^0.214.0", "lucide-react": "^0.214.0",
"micro": "^10.0.1", "micro": "^10.0.1",
"nanoid": "^4.0.2",
"next": "13.4.1", "next": "13.4.1",
"next-auth": "^4.22.1", "next-auth": "^4.22.1",
"next-plausible": "^3.7.2", "next-plausible": "^3.7.2",
@ -32,6 +33,7 @@
"react-hook-form": "^7.43.9", "react-hook-form": "^7.43.9",
"react-icons": "^4.8.0", "react-icons": "^4.8.0",
"react-pdf": "^7.1.1", "react-pdf": "^7.1.1",
"react-rnd": "^10.4.1",
"typescript": "5.0.4", "typescript": "5.0.4",
"zod": "^3.21.4" "zod": "^3.21.4"
}, },

View File

@ -5,6 +5,8 @@ import { ChevronLeft } from 'lucide-react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { EditDocumentForm } from '~/components/forms/edit-document'; import { EditDocumentForm } from '~/components/forms/edit-document';
@ -34,6 +36,17 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
redirect('/documents'); redirect('/documents');
} }
const [recipients, fields] = await Promise.all([
await getRecipientsForDocument({
documentId,
userId: session.id,
}),
await getFieldsForDocument({
documentId,
userId: session.id,
}),
]);
return ( return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8"> <div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link href="/documents" className="flex items-center text-[#7AC455] hover:opacity-80"> <Link href="/documents" className="flex items-center text-[#7AC455] hover:opacity-80">
@ -48,7 +61,13 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
{document.title} {document.title}
</h1> </h1>
<EditDocumentForm className="mt-8" document={document} user={session} /> <EditDocumentForm
className="mt-8"
document={document}
user={session}
recipients={recipients}
fields={fields}
/>
</div> </div>
); );
} }

View File

@ -1,11 +1,10 @@
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { FieldError } from 'react-hook-form';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
export type FormErrorMessageProps = { export type FormErrorMessageProps = {
className?: string; className?: string;
error: FieldError | undefined; error: { message?: string } | undefined;
}; };
export const FormErrorMessage = ({ error, className }: FormErrorMessageProps) => { export const FormErrorMessage = ({ error, className }: FormErrorMessageProps) => {

View File

@ -1,21 +1,22 @@
'use client'; 'use client';
import { useState } from 'react'; import { useId, useState } from 'react';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { useTheme } from 'next-themes';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Document, User } from '@documenso/prisma/client'; import { Document, Field, Recipient, User } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { AddFieldsFormPartial } from './edit-document/add-fields'; import { AddFieldsFormPartial } from './edit-document/add-fields';
import { AddSignersFormPartial } from './edit-document/add-signers'; import { AddSignersFormPartial } from './edit-document/add-signers';
import { AddSubjectFormPartial } from './edit-document/add-subject';
import { TEditDocumentFormSchema, ZEditDocumentFormSchema } from './edit-document/types'; import { TEditDocumentFormSchema, ZEditDocumentFormSchema } from './edit-document/types';
const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), { const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), {
@ -35,57 +36,143 @@ export type EditDocumentFormProps = {
className?: string; className?: string;
user: User; user: User;
document: Document; document: Document;
recipients: Recipient[];
fields: Field[];
}; };
export const EditDocumentForm = ({ className, document, user: _user }: EditDocumentFormProps) => { export const EditDocumentForm = ({
const documentUrl = `data:application/pdf;base64,${document.document}`; className,
document,
recipients,
fields,
user: _user,
}: EditDocumentFormProps) => {
const initialId = useId();
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [nextStepLoading, setNextStepLoading] = useState(false);
const documentUrl = `data:application/pdf;base64,${document.document}`;
const defaultSigners =
recipients.length > 0
? recipients.map((recipient) => ({
nativeId: recipient.id,
formId: `${recipient.id}-${recipient.documentId}`,
name: recipient.name,
email: recipient.email,
}))
: [
{
formId: initialId,
name: '',
email: '',
},
];
const defaultFields = fields.map((field) => ({
nativeId: field.id,
formId: `${field.id}-${field.documentId}`,
pageNumber: field.page,
type: field.type,
pageX: Number(field.positionX),
pageY: Number(field.positionY),
pageWidth: Number(field.width),
pageHeight: Number(field.height),
signerEmail: recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
}));
const { mutateAsync: setRecipientsForDocument } =
trpc.document.setRecipientsForDocument.useMutation();
const { mutateAsync: setFieldsForDocument } = trpc.document.setFieldsForDocument.useMutation();
const { const {
control, control,
// handleSubmit, handleSubmit,
watch, watch,
formState: { errors, isSubmitting, isValid }, trigger,
formState: { errors, isSubmitting },
} = useForm<TEditDocumentFormSchema>({ } = useForm<TEditDocumentFormSchema>({
mode: 'onBlur', mode: 'onBlur',
defaultValues: { defaultValues: {
signers: [ signers: defaultSigners,
{ fields: defaultFields,
name: '', email: {
email: '', subject: '',
}, message: '',
], },
}, },
resolver: zodResolver(ZEditDocumentFormSchema), resolver: zodResolver(ZEditDocumentFormSchema),
}); });
const { theme } = useTheme(); const signersFormValue = watch('signers');
const fieldsFormValue = watch('fields');
console.log({ state: watch(), errors });
const canGoBack = step > 0; const canGoBack = step > 0;
const canGoNext = isValid && step < MAX_STEP; const canGoNext = step < MAX_STEP;
const onGoBackClick = () => setStep((prev) => Math.max(0, prev - 1)); const onGoBackClick = () => setStep((prev) => Math.max(0, prev - 1));
const onGoNextClick = () => setStep((prev) => Math.min(MAX_STEP, prev + 1)); const onGoNextClick = async () => {
setNextStepLoading(true);
const passes = await trigger();
if (step === 0) {
await setRecipientsForDocument({
documentId: document.id,
recipients: signersFormValue.map((signer) => ({
id: signer.nativeId ?? undefined,
name: signer.name,
email: signer.email,
})),
}).catch((err: unknown) => console.error(err));
}
if (step === 1) {
await setFieldsForDocument({
documentId: document.id,
fields: fieldsFormValue.map((field) => ({
id: field.nativeId ?? undefined,
type: field.type,
signerEmail: field.signerEmail,
pageNumber: field.pageNumber,
pageX: field.pageX,
pageY: field.pageY,
pageWidth: field.pageWidth,
pageHeight: field.pageHeight,
})),
}).catch((err: unknown) => console.error(err));
}
if (passes) {
setStep((prev) => Math.min(MAX_STEP, prev + 1));
}
console.log({ passes });
setNextStepLoading(false);
};
return ( return (
<div className={cn('grid w-full grid-cols-12 gap-x-8', className)}> <div className={cn('grid w-full grid-cols-12 gap-x-8', className)}>
<Card <Card className="col-span-7 rounded-xl before:rounded-xl" gradient>
className="col-span-7 rounded-xl before:rounded-xl"
gradient
lightMode={theme === 'light'}
>
<CardContent className="p-2"> <CardContent className="p-2">
<PDFViewer document={documentUrl} /> <PDFViewer document={documentUrl} />
</CardContent> </CardContent>
</Card> </Card>
<div className="relative col-span-5"> <div className="col-span-5">
<div className="dark:bg-background border-border sticky top-20 flex h-[calc(100vh-6rem)] max-h-screen flex-col rounded-xl border bg-[hsl(var(--widget))] px-4 py-6"> <form
className="dark:bg-background border-border sticky top-20 flex h-[calc(100vh-6rem)] max-h-screen flex-col rounded-xl border bg-[hsl(var(--widget))] px-4 py-6"
onSubmit={handleSubmit(console.log)}
>
{step === 0 && ( {step === 0 && (
<AddSignersFormPartial <AddSignersFormPartial
className="-mx-2 flex-1 overflow-y-hidden px-2" className="-mx-2 flex-1 overflow-y-hidden px-2"
control={control} control={control}
watch={watch}
errors={errors} errors={errors}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
/> />
@ -98,7 +185,16 @@ export const EditDocumentForm = ({ className, document, user: _user }: EditDocum
watch={watch} watch={watch}
errors={errors} errors={errors}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
theme={theme || 'dark'} />
)}
{step === 2 && (
<AddSubjectFormPartial
className="-mx-2 flex-1 overflow-y-hidden px-2"
control={control}
watch={watch}
errors={errors}
isSubmitting={isSubmitting}
/> />
)} )}
@ -118,6 +214,7 @@ export const EditDocumentForm = ({ className, document, user: _user }: EditDocum
<div className="mt-4 flex gap-x-4"> <div className="mt-4 flex gap-x-4">
<Button <Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10" className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
size="lg" size="lg"
variant="secondary" variant="secondary"
@ -127,17 +224,27 @@ export const EditDocumentForm = ({ className, document, user: _user }: EditDocum
Go Back Go Back
</Button> </Button>
<Button {step < MAX_STEP && (
className="bg-documenso flex-1" <Button
size="lg" type="button"
disabled={!canGoNext} className="bg-documenso flex-1"
onClick={onGoNextClick} size="lg"
> disabled={!canGoNext}
Continue onClick={onGoNextClick}
</Button> >
{nextStepLoading && <Loader className="mr-2 h-5 w-5 animate-spin" />}
Continue
</Button>
)}
{step === MAX_STEP && (
<Button type="submit" className="bg-documenso flex-1" size="lg">
Complete
</Button>
)}
</div> </div>
</div> </div>
</div> </form>
</div> </div>
</div> </div>
); );

View File

@ -1,12 +1,14 @@
'use client'; 'use client';
import { useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Caveat } from 'next/font/google'; import { Caveat } from 'next/font/google';
import { Check, ChevronsUpDown } from 'lucide-react'; import { Check, ChevronsUpDown } from 'lucide-react';
import { Control, FieldErrors, UseFormWatch } from 'react-hook-form'; import { nanoid } from 'nanoid';
import { Control, FieldErrors, UseFormWatch, useFieldArray } from 'react-hook-form';
import { FieldType } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -19,7 +21,10 @@ import {
} from '@documenso/ui/primitives/command'; } from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { TEditDocumentFormSchema } from './types'; import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types';
import { FieldItem } from './field-item';
import { FRIENDLY_FIELD_TYPE, TEditDocumentFormSchema } from './types';
const fontCaveat = Caveat({ const fontCaveat = Caveat({
weight: ['500'], weight: ['500'],
@ -28,30 +33,285 @@ const fontCaveat = Caveat({
variable: '--font-caveat', variable: '--font-caveat',
}); });
const DEFAULT_HEIGHT_PERCENT = 5;
const DEFAULT_WIDTH_PERCENT = 15;
const MIN_HEIGHT_PX = 60;
const MIN_WIDTH_PX = 200;
export type AddFieldsFormProps = { export type AddFieldsFormProps = {
className?: string; className?: string;
control: Control<TEditDocumentFormSchema>; control: Control<TEditDocumentFormSchema>;
watch: UseFormWatch<TEditDocumentFormSchema>; watch: UseFormWatch<TEditDocumentFormSchema>;
errors: FieldErrors<TEditDocumentFormSchema>; errors: FieldErrors<TEditDocumentFormSchema>;
isSubmitting: boolean; isSubmitting: boolean;
theme: string;
}; };
export const AddFieldsFormPartial = ({ export const AddFieldsFormPartial = ({
className, className,
control: _control, control: control,
watch, watch,
errors: _errors, errors: _errors,
isSubmitting: _isSubmitting, isSubmitting: _isSubmitting,
theme,
}: AddFieldsFormProps) => { }: AddFieldsFormProps) => {
const signers = watch('signers'); const signers = watch('signers');
const fields = watch('fields');
const { append, remove, update } = useFieldArray({
control,
name: 'fields',
});
const [selectedSigner, setSelectedSigner] = useState(() => signers[0]); const [selectedSigner, setSelectedSigner] = useState(() => signers[0]);
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
const [visible, setVisible] = useState(false);
const [coords, setCoords] = useState({
x: 0,
y: 0,
});
const fieldBounds = useRef({
height: 0,
width: 0,
});
const isWithinPageBounds = useCallback((event: MouseEvent) => {
if (!(event.target instanceof HTMLElement)) {
return false;
}
const target = event.target;
const $page =
target.closest(PDF_VIEWER_PAGE_SELECTOR) ?? target.querySelector(PDF_VIEWER_PAGE_SELECTOR);
if (!$page) {
return false;
}
const { top, left, height, width } = $page.getBoundingClientRect();
if (event.clientY > top + height || event.clientY < top) {
return false;
}
if (event.clientX > left + width || event.clientX < left) {
return false;
}
return true;
}, []);
const onMouseMove = useCallback(
(event: MouseEvent) => {
if (!isWithinPageBounds(event)) {
setVisible(false);
return;
}
setVisible(true);
setCoords({
x: event.clientX - fieldBounds.current.width / 2,
y: event.clientY - fieldBounds.current.height / 2,
});
},
[isWithinPageBounds],
);
const onMouseClick = useCallback(
(event: MouseEvent) => {
if (!selectedField) {
return;
}
if (!(event.target instanceof HTMLElement)) {
return;
}
const target = event.target;
const $page =
target.closest<HTMLElement>(PDF_VIEWER_PAGE_SELECTOR) ??
target.querySelector<HTMLElement>(PDF_VIEWER_PAGE_SELECTOR);
if (!$page || !isWithinPageBounds(event)) {
return;
}
const { height, width } = $page.getBoundingClientRect();
const top = $page.offsetTop;
const left = $page.offsetLeft;
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
// Calculate x and y as a percentage of the page width and height
let pageX = ((event.pageX - left) / width) * 100;
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;
// And center it based on the bounds
pageX -= fieldPageWidth / 2;
pageY -= fieldPageHeight / 2;
append({
formId: nanoid(12),
type: selectedField,
pageNumber,
pageX,
pageY,
pageWidth: fieldPageWidth,
pageHeight: fieldPageHeight,
signerEmail: selectedSigner.email,
});
setVisible(false);
setSelectedField(null);
},
[append, isWithinPageBounds, selectedField, selectedSigner.email],
);
const onFieldResize = useCallback(
(node: HTMLElement, index: number) => {
const field = fields[index];
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
);
if (!$page) {
return;
}
const { height: pageHeight, width: pageWidth } = $page.getBoundingClientRect();
const pageTop = $page.offsetTop;
const pageLeft = $page.offsetLeft;
let { top: nodeTop, left: nodeLeft } = node.getBoundingClientRect();
const { height, width } = node.getBoundingClientRect();
nodeTop += window.scrollY;
nodeLeft += window.scrollX;
// Calculate width and height as a percentage of the page width and height
const newWidth = (width / pageWidth) * 100;
const newHeight = (height / pageHeight) * 100;
// Calculate the new position as a percentage of the page width and height
const newX = ((nodeLeft - pageLeft) / pageWidth) * 100;
const newY = ((nodeTop - pageTop) / pageHeight) * 100;
update(index, {
...field,
pageX: newX,
pageY: newY,
pageWidth: newWidth,
pageHeight: newHeight,
});
},
[fields, update],
);
const onFieldMove = useCallback(
(node: HTMLElement, index: number) => {
const field = fields[index];
const $page = document.querySelector(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
);
if (!$page || !($page instanceof HTMLElement)) {
return;
}
const { height: pageHeight, width: pageWidth } = $page.getBoundingClientRect();
const pageTop = $page.offsetTop;
const pageLeft = $page.offsetLeft;
let { top: nodeTop, left: nodeLeft } = node.getBoundingClientRect();
nodeTop += window.scrollY;
nodeLeft += window.scrollX;
// Calculate the new position as a percentage of the page width and height
const newX = ((nodeLeft - pageLeft) / pageWidth) * 100;
const newY = ((nodeTop - pageTop) / pageHeight) * 100;
update(index, {
...field,
pageX: newX,
pageY: newY,
});
},
[fields, update],
);
useEffect(() => {
if (selectedField) {
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('click', onMouseClick);
}
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('click', onMouseClick);
};
}, [onMouseClick, onMouseMove, selectedField]);
useEffect(() => {
const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
if (!$page) {
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),
};
}, []);
return ( return (
<div className={cn('flex flex-col', className)}> <div className={cn('flex flex-col', className)}>
<h3 className="text-2xl font-semibold">Edit Document</h3> {selectedField && visible && (
<Card
className="border-primary pointer-events-none fixed z-50 cursor-pointer bg-white"
style={{
top: coords.y,
left: coords.x,
height: fieldBounds.current.height,
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>
)}
{fields.map((field, index) => (
<FieldItem
key={index}
field={field}
disabled={selectedSigner.email !== field.signerEmail}
minHeight={fieldBounds.current.height}
minWidth={fieldBounds.current.width}
passive={visible && !!selectedField}
onResize={(options) => onFieldResize(options, index)}
onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)}
/>
))}
<h3 className="text-2xl font-semibold">Add Fields</h3>
<p className="text-muted-foreground mt-2 text-sm"> <p className="text-muted-foreground mt-2 text-sm">
Add all relevant fields for each recipient. Add all relevant fields for each recipient.
@ -62,6 +322,7 @@ export const AddFieldsFormPartial = ({
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
type="button"
variant="outline" variant="outline"
role="combobox" role="combobox"
className="bg-background text-muted-foreground justify-between font-normal" className="bg-background text-muted-foreground justify-between font-normal"
@ -87,6 +348,7 @@ export const AddFieldsFormPartial = ({
{signers.map((signer, index) => ( {signers.map((signer, index) => (
<CommandItem key={index} onSelect={() => setSelectedSigner(signer)}> <CommandItem key={index} onSelect={() => setSelectedSigner(signer)}>
<Check <Check
aria-hidden={signer !== selectedSigner}
className={cn('mr-2 h-4 w-4', { className={cn('mr-2 h-4 w-4', {
'opacity-0': signer !== selectedSigner, 'opacity-0': signer !== selectedSigner,
'opacity-100': signer === selectedSigner, 'opacity-100': signer === selectedSigner,
@ -108,15 +370,17 @@ export const AddFieldsFormPartial = ({
<div className="-mx-2 mt-8 flex-1 overflow-y-scroll px-2"> <div className="-mx-2 mt-8 flex-1 overflow-y-scroll px-2">
<div className="mt-4 grid grid-cols-2 gap-x-4 gap-y-8"> <div className="mt-4 grid grid-cols-2 gap-x-4 gap-y-8">
<button className="group h-full w-full"> <button
<Card type="button"
className="group-focus:border-documenso h-full w-full cursor-pointer" className="group h-full w-full"
lightMode={theme === 'light'} onClick={() => setSelectedField(FieldType.SIGNATURE)}
> data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer">
<CardContent className="flex flex-col items-center justify-center px-6 py-4"> <CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p <p
className={cn( className={cn(
'text-muted-foreground group-focus:text-foreground text-3xl font-medium', 'text-muted-foreground group-data-[selected]:text-foreground text-3xl font-medium',
fontCaveat.className, fontCaveat.className,
)} )}
> >
@ -128,15 +392,17 @@ export const AddFieldsFormPartial = ({
</Card> </Card>
</button> </button>
<button className="group h-full w-full"> <button
<Card type="button"
className="group-focus:border-documenso h-full w-full cursor-pointer" className="group h-full w-full"
lightMode={theme === 'light'} onClick={() => setSelectedField(FieldType.EMAIL)}
> data-selected={selectedField === FieldType.EMAIL ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer">
<CardContent className="flex flex-col items-center justify-center px-6 py-4"> <CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p <p
className={cn( className={cn(
'text-muted-foreground group-focus:text-foreground text-xl font-medium', 'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
)} )}
> >
{'Email'} {'Email'}
@ -147,15 +413,17 @@ export const AddFieldsFormPartial = ({
</Card> </Card>
</button> </button>
<button className="group h-full w-full"> <button
<Card type="button"
className="group-focus:border-documenso h-full w-full cursor-pointer" className="group h-full w-full"
lightMode={theme === 'light'} onClick={() => setSelectedField(FieldType.NAME)}
> data-selected={selectedField === FieldType.NAME ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer">
<CardContent className="flex flex-col items-center justify-center px-6 py-4"> <CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p <p
className={cn( className={cn(
'text-muted-foreground group-focus:text-foreground text-xl font-medium', 'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
)} )}
> >
{'Name'} {'Name'}
@ -166,15 +434,17 @@ export const AddFieldsFormPartial = ({
</Card> </Card>
</button> </button>
<button className="group h-full w-full"> <button
<Card type="button"
className="group-focus:border-documenso h-full w-full cursor-pointer" className="group h-full w-full"
lightMode={theme === 'light'} onClick={() => setSelectedField(FieldType.DATE)}
> data-selected={selectedField === FieldType.DATE ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer">
<CardContent className="flex flex-col items-center justify-center px-6 py-4"> <CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p <p
className={cn( className={cn(
'text-muted-foreground group-focus:text-foreground text-xl font-medium', 'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
)} )}
> >
{'Date'} {'Date'}

View File

@ -1,8 +1,11 @@
'use client'; 'use client';
import React from 'react';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { Plus, Trash } from 'lucide-react'; import { Plus, Trash } from 'lucide-react';
import { Control, Controller, FieldErrors, useFieldArray } from 'react-hook-form'; import { nanoid } from 'nanoid';
import { Control, Controller, FieldErrors, UseFormWatch, useFieldArray } from 'react-hook-form';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -16,6 +19,7 @@ import { TEditDocumentFormSchema } from './types';
export type AddSignersFormProps = { export type AddSignersFormProps = {
className?: string; className?: string;
control: Control<TEditDocumentFormSchema>; control: Control<TEditDocumentFormSchema>;
watch: UseFormWatch<TEditDocumentFormSchema>;
errors: FieldErrors<TEditDocumentFormSchema>; errors: FieldErrors<TEditDocumentFormSchema>;
isSubmitting: boolean; isSubmitting: boolean;
}; };
@ -27,14 +31,49 @@ export const AddSignersFormPartial = ({
isSubmitting, isSubmitting,
}: AddSignersFormProps) => { }: AddSignersFormProps) => {
const { const {
append, append: appendSigner,
fields: signers, fields: signers,
remove, remove: removeSigner,
} = useFieldArray({ } = useFieldArray({
control, control,
name: 'signers', name: 'signers',
}); });
const { remove: removeField, fields: fields } = useFieldArray({
name: 'fields',
control,
});
const onAddSigner = () => {
appendSigner({
formId: nanoid(12),
name: '',
email: '',
});
};
const onRemoveSigner = (index: number) => {
const signer = signers[index];
removeSigner(index);
const fieldsToRemove: number[] = [];
fields.forEach((field, fieldIndex) => {
if (field.signerEmail === signer.email) {
fieldsToRemove.push(fieldIndex);
}
});
removeField(fieldsToRemove);
};
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter' && event.target instanceof HTMLInputElement) {
onAddSigner();
}
};
return ( return (
<div className={cn('flex flex-col', className)}> <div className={cn('flex flex-col', className)}>
<h3 className="text-foreground text-2xl font-semibold">Add Signers</h3> <h3 className="text-foreground text-2xl font-semibold">Add Signers</h3>
@ -45,23 +84,27 @@ export const AddSignersFormPartial = ({
<hr className="border-border mb-8 mt-4" /> <hr className="border-border mb-8 mt-4" />
<div className="-mx-2 flex flex-1 flex-col overflow-y-scroll px-2"> <div className="-mx-2 flex flex-1 flex-col overflow-y-auto px-2">
<div className="flex w-full flex-col gap-y-4"> <div className="flex w-full flex-col gap-y-4">
<AnimatePresence> <AnimatePresence>
{signers.map((field, index) => ( {signers.map((signer, index) => (
<motion.div key={field.id} className="flex flex-wrap items-end gap-x-4"> <motion.div key={signer.formId} className="flex flex-wrap items-end gap-x-4">
<div className="flex-1"> <div className="flex-1">
<Label htmlFor={`signer-${index}-email`}>Email</Label> <Label htmlFor={`signer-${signer.formId}-email`}>
Email
<span className="text-destructive ml-1 inline-block font-medium">*</span>
</Label>
<Controller <Controller
control={control} control={control}
name={`signers.${index}.email`} name={`signers.${index}.email`}
render={({ field }) => ( render={({ field }) => (
<Input <Input
id={`signer-${index}-email`} id={`signer-${signer.formId}-email`}
type="email" type="email"
className="bg-background mt-2" className="bg-background mt-2"
disabled={isSubmitting} disabled={isSubmitting}
onKeyDown={onKeyDown}
{...field} {...field}
/> />
)} )}
@ -69,17 +112,18 @@ export const AddSignersFormPartial = ({
</div> </div>
<div className="flex-1"> <div className="flex-1">
<Label htmlFor={`signer-${index}-name`}>Name</Label> <Label htmlFor={`signer-${signer.formId}-name`}>Name</Label>
<Controller <Controller
control={control} control={control}
name={`signers.${index}.name`} name={`signers.${index}.name`}
render={({ field }) => ( render={({ field }) => (
<Input <Input
id={`signer-${index}-name`} id={`signer-${signer.formId}-name`}
type="text" type="text"
className="bg-background mt-2" className="bg-background mt-2"
disabled={isSubmitting} disabled={isSubmitting}
onKeyDown={onKeyDown}
{...field} {...field}
/> />
)} )}
@ -89,9 +133,9 @@ export const AddSignersFormPartial = ({
<div> <div>
<button <button
type="button" type="button"
className="inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80" className="inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSubmitting} disabled={isSubmitting || signers.length === 1}
onClick={() => remove(index)} onClick={() => onRemoveSigner(index)}
> >
<Trash className="h-5 w-5" /> <Trash className="h-5 w-5" />
</button> </button>
@ -106,17 +150,10 @@ export const AddSignersFormPartial = ({
</AnimatePresence> </AnimatePresence>
</div> </div>
<FormErrorMessage className="mt-2" error={errors.signers} />
<div className="mt-4"> <div className="mt-4">
<Button <Button type="button" disabled={isSubmitting} onClick={() => onAddSigner()}>
type="button"
disabled={isSubmitting}
onClick={() =>
append({
email: '',
name: '',
})
}
>
<Plus className="-ml-1 mr-2 h-5 w-5" /> <Plus className="-ml-1 mr-2 h-5 w-5" />
Add Signer Add Signer
</Button> </Button>

View File

@ -0,0 +1,111 @@
'use client';
import { Control, Controller, FieldErrors, UseFormWatch } from 'react-hook-form';
import { cn } from '@documenso/ui/lib/utils';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { FormErrorMessage } from '~/components/form/form-error-message';
import { TEditDocumentFormSchema } from './types';
export type AddSubjectFormProps = {
className?: string;
control: Control<TEditDocumentFormSchema>;
watch: UseFormWatch<TEditDocumentFormSchema>;
errors: FieldErrors<TEditDocumentFormSchema>;
isSubmitting: boolean;
};
export const AddSubjectFormPartial = ({
className,
control,
errors,
isSubmitting,
}: AddSubjectFormProps) => {
return (
<div className={cn('flex flex-col', className)}>
<h3 className="text-foreground text-2xl font-semibold">Add Subject</h3>
<p className="text-muted-foreground mt-2 text-sm">
Add the subject and message you wish to send to signers.
</p>
<hr className="border-border mb-8 mt-4" />
<div className="flex flex-col gap-y-4">
<div>
<Label htmlFor="subject">
Subject <span className="text-muted-foreground">(Optional)</span>
</Label>
<Controller
control={control}
name="email.subject"
render={({ field }) => (
<Input
id="subject"
// placeholder="Subject"
className="bg-background mt-2"
disabled={isSubmitting}
{...field}
/>
)}
/>
<FormErrorMessage className="mt-2" errors={errors} />
</div>
<div>
<Label htmlFor="message">
Message <span className="text-muted-foreground">(Optional)</span>
</Label>
<Controller
control={control}
name="email.message"
render={({ field }) => (
<Textarea
id="message"
className="bg-background mt-2 h-32 resize-none"
disabled={isSubmitting}
{...field}
/>
)}
/>
<FormErrorMessage className="mt-2" errors={errors} />
</div>
<div>
<p className="text-muted-foreground text-sm">
You can use the following variables in your message:
</p>
<ul className="mt-2 flex list-inside list-disc flex-col gap-y-2 text-sm">
<li className="text-muted-foreground">
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
{'{signer.name}'}
</code>{' '}
- The signer's name
</li>
<li className="text-muted-foreground">
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
{'{signer.email}'}
</code>{' '}
- The signer's email
</li>
<li className="text-muted-foreground">
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
{'{document.name}'}
</code>{' '}
- The document's name
</li>
</ul>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,149 @@
import { useCallback, useEffect, useState } from 'react';
import { X } from 'lucide-react';
import { createPortal } from 'react-dom';
import { Rnd } from 'react-rnd';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types';
import { FRIENDLY_FIELD_TYPE, TEditDocumentFormSchema } from './types';
type Field = TEditDocumentFormSchema['fields'][0];
export type FieldItemProps = {
field: Field;
passive?: boolean;
disabled?: boolean;
minHeight?: number;
minWidth?: number;
onResize?: (_node: HTMLElement) => void;
onMove?: (_node: HTMLElement) => void;
onRemove?: () => void;
};
export const FieldItem = ({
field,
passive,
disabled,
minHeight,
minWidth,
onResize,
onMove,
onRemove,
}: FieldItemProps) => {
const [active, setActive] = useState(false);
const [coords, setCoords] = useState({
pageX: 0,
pageY: 0,
pageHeight: 0,
pageWidth: 0,
});
const calculateCoords = useCallback(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
);
if (!$page) {
return;
}
const { height, width } = $page.getBoundingClientRect();
const top = $page.offsetTop;
const left = $page.offsetLeft;
// X and Y are percentages of the page's height and width
const pageX = (field.pageX / 100) * width + left;
const pageY = (field.pageY / 100) * height + top;
const pageHeight = (field.pageHeight / 100) * height;
const pageWidth = (field.pageWidth / 100) * width;
setCoords({
pageX: pageX,
pageY: pageY,
pageHeight: pageHeight,
pageWidth: pageWidth,
});
}, [field.pageHeight, field.pageNumber, field.pageWidth, field.pageX, field.pageY]);
useEffect(() => {
calculateCoords();
}, [calculateCoords]);
useEffect(() => {
const onResize = () => {
calculateCoords();
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, [calculateCoords]);
return createPortal(
<Rnd
key={coords.pageX + coords.pageY + coords.pageHeight + coords.pageWidth}
className={cn('absolute z-20', {
'pointer-events-none': passive,
'pointer-events-none opacity-75': disabled,
})}
minHeight={minHeight}
minWidth={minWidth}
default={{
x: coords.pageX,
y: coords.pageY,
height: coords.pageHeight,
width: coords.pageWidth,
}}
bounds={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`}
onDragStart={() => setActive(true)}
onResizeStart={() => setActive(true)}
onResizeStop={(_e, _d, ref) => {
setActive(false);
onResize?.(ref);
}}
onDragStop={(_e, d) => {
setActive(false);
onMove?.(d.node);
}}
>
{!disabled && (
<button
className="bg-destructive absolute -right-2 -top-2 z-[9999] flex h-5 w-5 items-center justify-center rounded-full"
onClick={() => onRemove?.()}
>
<X className="text-destructive-foreground h-4 w-4" />
</button>
)}
<Card
className={cn('hover:border-primary/50 h-full w-full bg-white', {
'border-primary hover:border-primary': active,
})}
>
<CardContent
className={cn(
'text-foreground flex h-full w-full flex-col items-center justify-center p-2',
{
'text-muted-foreground/50': disabled,
},
)}
>
{FRIENDLY_FIELD_TYPE[field.type]}
<p className="text-muted-foreground/50 w-full truncate text-center text-xs">
{field.signerEmail}
</p>
</CardContent>
</Card>
</Rnd>,
document.body,
);
};

View File

@ -0,0 +1,53 @@
import React, { createContext, useRef } from 'react';
import { OnPDFViewerPageClick } from '~/components/(dashboard)/pdf-viewer/pdf-viewer';
type EditFormContextValue = {
firePageClickEvent: OnPDFViewerPageClick;
registerPageClickHandler: (_handler: OnPDFViewerPageClick) => void;
unregisterPageClickHandler: (_handler: OnPDFViewerPageClick) => void;
} | null;
const EditFormContext = createContext<EditFormContextValue>(null);
export type EditFormProviderProps = {
children: React.ReactNode;
};
export const useEditForm = () => {
const context = React.useContext(EditFormContext);
if (!context) {
throw new Error('useEditForm must be used within a EditFormProvider');
}
return context;
};
export const EditFormProvider = ({ children }: EditFormProviderProps) => {
const handlers = useRef(new Set<OnPDFViewerPageClick>());
const firePageClickEvent: OnPDFViewerPageClick = (event) => {
handlers.current.forEach((handler) => handler(event));
};
const registerPageClickHandler = (handler: OnPDFViewerPageClick) => {
handlers.current.add(handler);
};
const unregisterPageClickHandler = (handler: OnPDFViewerPageClick) => {
handlers.current.delete(handler);
};
return (
<EditFormContext.Provider
value={{
firePageClickEvent,
registerPageClickHandler,
unregisterPageClickHandler,
}}
>
{children}
</EditFormContext.Provider>
);
};

View File

@ -1,13 +1,49 @@
import { z } from 'zod'; import { z } from 'zod';
import { FieldType } from '@documenso/prisma/client';
export const ZEditDocumentFormSchema = z.object({ export const ZEditDocumentFormSchema = z.object({
signers: z.array( signers: z
.array(
z.object({
formId: z.string().min(1),
nativeId: z.number().optional(),
email: z.string().min(1).email(),
name: z.string(),
}),
)
.refine((signers) => {
const emails = signers.map((signer) => signer.email);
return new Set(emails).size === emails.length;
}, 'Signers must have unique emails'),
fields: z.array(
z.object({ z.object({
id: z.number().optional(), formId: z.string().min(1),
email: z.string().min(1).email(), nativeId: z.number().optional(),
name: z.string(), type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1),
pageNumber: z.number().min(1),
pageX: z.number().min(0),
pageY: z.number().min(0),
pageWidth: z.number().min(0),
pageHeight: z.number().min(0),
}), }),
), ),
email: z.object({
subject: z.string(),
message: z.string(),
}),
}); });
export type TEditDocumentFormSchema = z.infer<typeof ZEditDocumentFormSchema>; export type TEditDocumentFormSchema = z.infer<typeof ZEditDocumentFormSchema>;
export const FRIENDLY_FIELD_TYPE: Record<FieldType, string> = {
[FieldType.SIGNATURE]: 'Signature',
[FieldType.FREE_SIGNATURE]: 'Free Signature',
[FieldType.TEXT]: 'Text',
[FieldType.DATE]: 'Date',
[FieldType.EMAIL]: 'Email',
[FieldType.NAME]: 'Name',
};

146
package-lock.json generated
View File

@ -64,6 +64,7 @@
"framer-motion": "^10.12.8", "framer-motion": "^10.12.8",
"lucide-react": "^0.214.0", "lucide-react": "^0.214.0",
"micro": "^10.0.1", "micro": "^10.0.1",
"nanoid": "^4.0.2",
"next": "13.4.1", "next": "13.4.1",
"next-auth": "^4.22.1", "next-auth": "^4.22.1",
"next-plausible": "^3.7.2", "next-plausible": "^3.7.2",
@ -75,6 +76,7 @@
"react-hook-form": "^7.43.9", "react-hook-form": "^7.43.9",
"react-icons": "^4.8.0", "react-icons": "^4.8.0",
"react-pdf": "^7.1.1", "react-pdf": "^7.1.1",
"react-rnd": "^10.4.1",
"typescript": "5.0.4", "typescript": "5.0.4",
"zod": "^3.21.4" "zod": "^3.21.4"
}, },
@ -85,6 +87,23 @@
"@types/react-dom": "18.2.4" "@types/react-dom": "18.2.4"
} }
}, },
"apps/web/node_modules/nanoid": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz",
"integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^14 || ^16 || >=18"
}
},
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@ -4199,6 +4218,11 @@
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="
}, },
"node_modules/fast-memoize": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/fast-memoize/-/fast-memoize-2.5.2.tgz",
"integrity": "sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw=="
},
"node_modules/fastq": { "node_modules/fastq": {
"version": "1.15.0", "version": "1.15.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
@ -6606,6 +6630,19 @@
"react": "^18.2.0" "react": "^18.2.0"
} }
}, },
"node_modules/react-draggable": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.5.tgz",
"integrity": "sha512-OMHzJdyJbYTZo4uQE393fHcqqPYsEtkjfMgvCHr6rejT+Ezn4OZbNyGH50vv+SunC1RMvwOTSWkEODQLzw1M9g==",
"dependencies": {
"clsx": "^1.1.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-dropzone": { "node_modules/react-dropzone": {
"version": "14.2.3", "version": "14.2.3",
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz", "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz",
@ -6717,6 +6754,37 @@
} }
} }
}, },
"node_modules/react-rnd": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/react-rnd/-/react-rnd-10.4.1.tgz",
"integrity": "sha512-0m887AjQZr6p2ADLNnipquqsDq4XJu/uqVqI3zuoGD19tRm6uB83HmZWydtkilNp5EWsOHbLGF4IjWMdd5du8Q==",
"dependencies": {
"re-resizable": "6.9.6",
"react-draggable": "4.4.5",
"tslib": "2.3.1"
},
"peerDependencies": {
"react": ">=16.3.0",
"react-dom": ">=16.3.0"
}
},
"node_modules/react-rnd/node_modules/re-resizable": {
"version": "6.9.6",
"resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.9.6.tgz",
"integrity": "sha512-0xYKS5+Z0zk+vICQlcZW+g54CcJTTmHluA7JUUgvERDxnKAnytylcyPsA+BSFi759s5hPlHmBRegFrwXs2FuBQ==",
"dependencies": {
"fast-memoize": "^2.5.1"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-rnd/node_modules/tslib": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
},
"node_modules/react-ssr-prepass": { "node_modules/react-ssr-prepass": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/react-ssr-prepass/-/react-ssr-prepass-1.5.0.tgz", "resolved": "https://registry.npmjs.org/react-ssr-prepass/-/react-ssr-prepass-1.5.0.tgz",
@ -8342,6 +8410,7 @@
"@pdf-lib/fontkit": "^1.1.1", "@pdf-lib/fontkit": "^1.1.1",
"@upstash/redis": "^1.20.6", "@upstash/redis": "^1.20.6",
"bcrypt": "^5.1.0", "bcrypt": "^5.1.0",
"nanoid": "^4.0.2",
"next": "13.4.1", "next": "13.4.1",
"next-auth": "^4.22.1", "next-auth": "^4.22.1",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
@ -8351,6 +8420,23 @@
"@types/bcrypt": "^5.0.0" "@types/bcrypt": "^5.0.0"
} }
}, },
"packages/lib/node_modules/nanoid": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz",
"integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^14 || ^16 || >=18"
}
},
"packages/prettier-config": { "packages/prettier-config": {
"name": "@documenso/prettier-config", "name": "@documenso/prettier-config",
"version": "0.0.0", "version": "0.0.0",
@ -8390,6 +8476,8 @@
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@tanstack/react-query": "^4.29.5", "@tanstack/react-query": "^4.29.5",
"@trpc/client": "^10.25.1", "@trpc/client": "^10.25.1",
"@trpc/next": "^10.25.1", "@trpc/next": "^10.25.1",
@ -8842,10 +8930,18 @@
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@upstash/redis": "^1.20.6", "@upstash/redis": "^1.20.6",
"bcrypt": "^5.1.0", "bcrypt": "^5.1.0",
"nanoid": "^4.0.2",
"next": "13.4.1", "next": "13.4.1",
"next-auth": "^4.22.1", "next-auth": "^4.22.1",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"stripe": "^12.7.0" "stripe": "^12.7.0"
},
"dependencies": {
"nanoid": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz",
"integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw=="
}
} }
}, },
"@documenso/marketing": { "@documenso/marketing": {
@ -8903,6 +8999,8 @@
"@documenso/trpc": { "@documenso/trpc": {
"version": "file:packages/trpc", "version": "file:packages/trpc",
"requires": { "requires": {
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@tanstack/react-query": "^4.29.5", "@tanstack/react-query": "^4.29.5",
"@trpc/client": "^10.25.1", "@trpc/client": "^10.25.1",
"@trpc/next": "^10.25.1", "@trpc/next": "^10.25.1",
@ -8978,6 +9076,7 @@
"framer-motion": "^10.12.8", "framer-motion": "^10.12.8",
"lucide-react": "^0.214.0", "lucide-react": "^0.214.0",
"micro": "^10.0.1", "micro": "^10.0.1",
"nanoid": "^4.0.2",
"next": "13.4.1", "next": "13.4.1",
"next-auth": "^4.22.1", "next-auth": "^4.22.1",
"next-plausible": "^3.7.2", "next-plausible": "^3.7.2",
@ -8989,8 +9088,16 @@
"react-hook-form": "^7.43.9", "react-hook-form": "^7.43.9",
"react-icons": "^4.8.0", "react-icons": "^4.8.0",
"react-pdf": "^7.1.1", "react-pdf": "^7.1.1",
"react-rnd": "^10.4.1",
"typescript": "5.0.4", "typescript": "5.0.4",
"zod": "^3.21.4" "zod": "^3.21.4"
},
"dependencies": {
"nanoid": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz",
"integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw=="
}
} }
}, },
"@emotion/is-prop-valid": { "@emotion/is-prop-valid": {
@ -11693,6 +11800,11 @@
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="
}, },
"fast-memoize": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/fast-memoize/-/fast-memoize-2.5.2.tgz",
"integrity": "sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw=="
},
"fastq": { "fastq": {
"version": "1.15.0", "version": "1.15.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
@ -13236,6 +13348,15 @@
"scheduler": "^0.23.0" "scheduler": "^0.23.0"
} }
}, },
"react-draggable": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.5.tgz",
"integrity": "sha512-OMHzJdyJbYTZo4uQE393fHcqqPYsEtkjfMgvCHr6rejT+Ezn4OZbNyGH50vv+SunC1RMvwOTSWkEODQLzw1M9g==",
"requires": {
"clsx": "^1.1.1",
"prop-types": "^15.8.1"
}
},
"react-dropzone": { "react-dropzone": {
"version": "14.2.3", "version": "14.2.3",
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz", "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz",
@ -13299,6 +13420,31 @@
"tslib": "^2.0.0" "tslib": "^2.0.0"
} }
}, },
"react-rnd": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/react-rnd/-/react-rnd-10.4.1.tgz",
"integrity": "sha512-0m887AjQZr6p2ADLNnipquqsDq4XJu/uqVqI3zuoGD19tRm6uB83HmZWydtkilNp5EWsOHbLGF4IjWMdd5du8Q==",
"requires": {
"re-resizable": "6.9.6",
"react-draggable": "4.4.5",
"tslib": "2.3.1"
},
"dependencies": {
"re-resizable": {
"version": "6.9.6",
"resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.9.6.tgz",
"integrity": "sha512-0xYKS5+Z0zk+vICQlcZW+g54CcJTTmHluA7JUUgvERDxnKAnytylcyPsA+BSFi759s5hPlHmBRegFrwXs2FuBQ==",
"requires": {
"fast-memoize": "^2.5.1"
}
},
"tslib": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
}
}
},
"react-ssr-prepass": { "react-ssr-prepass": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/react-ssr-prepass/-/react-ssr-prepass-1.5.0.tgz", "resolved": "https://registry.npmjs.org/react-ssr-prepass/-/react-ssr-prepass-1.5.0.tgz",

View File

@ -30,7 +30,6 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
const user = await getUserByEmail({ email }).catch(() => null); const user = await getUserByEmail({ email }).catch(() => null);
if (!user || !user.password) { if (!user || !user.password) {
console.log('no user');
return null; return null;
} }

View File

@ -19,6 +19,7 @@
"@upstash/redis": "^1.20.6", "@upstash/redis": "^1.20.6",
"bcrypt": "^5.1.0", "bcrypt": "^5.1.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"nanoid": "^4.0.2",
"next": "13.4.1", "next": "13.4.1",
"next-auth": "^4.22.1", "next-auth": "^4.22.1",
"stripe": "^12.7.0" "stripe": "^12.7.0"

View File

@ -0,0 +1,19 @@
import { prisma } from '@documenso/prisma';
export interface GetFieldsForDocumentOptions {
documentId: number;
userId: number;
}
export const getFieldsForDocument = async ({ documentId, userId }: GetFieldsForDocumentOptions) => {
const fields = await prisma.field.findMany({
where: {
documentId,
Document: {
userId,
},
},
});
return fields;
};

View File

@ -0,0 +1,127 @@
import { prisma } from '@documenso/prisma';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
export interface SetFieldsForDocumentOptions {
userId: number;
documentId: number;
fields: {
id?: number | null;
signerEmail: string;
pageNumber: number;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
}[];
}
export const setFieldsForDocument = async ({
userId,
documentId,
fields,
}: SetFieldsForDocumentOptions) => {
const document = await prisma.document.findFirst({
where: {
id: documentId,
userId,
},
});
if (!document) {
throw new Error('Document not found');
}
const existingFields = await prisma.field.findMany({
where: {
documentId,
},
include: {
Recipient: true,
},
});
const removedFields = existingFields.filter(
(existingField) =>
!fields.find(
(field) =>
field.id === existingField.id || field.signerEmail === existingField.Recipient?.email,
),
);
const linkedFields = fields.map((field) => {
const existing = existingFields.find((existingField) => existingField.id === field.id);
return {
...field,
...existing,
};
});
for (const field of linkedFields) {
if (
field.Recipient?.sendStatus === SendStatus.SENT ||
field.Recipient?.signingStatus === SigningStatus.SIGNED
) {
throw new Error('Cannot modify fields after sending');
}
}
const persistedFields = await prisma.$transaction(
linkedFields.map((field) =>
field.id
? prisma.field.update({
where: {
id: field.id,
recipientId: field.recipientId,
documentId,
},
data: {
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
},
})
: prisma.field.create({
data: {
type: field.type!,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
customText: '',
inserted: false,
Document: {
connect: {
id: document.id,
},
},
Recipient: {
connect: {
documentId_email: {
documentId: document.id,
email: field.signerEmail,
},
},
},
},
}),
),
);
if (removedFields.length > 0) {
await prisma.field.deleteMany({
where: {
id: {
in: removedFields.map((field) => field.id),
},
},
});
}
return persistedFields;
};

View File

@ -0,0 +1,22 @@
import { prisma } from '@documenso/prisma';
export interface GetRecipientsForDocumentOptions {
documentId: number;
userId: number;
}
export const getRecipientsForDocument = async ({
documentId,
userId,
}: GetRecipientsForDocumentOptions) => {
const recipients = await prisma.recipient.findMany({
where: {
documentId,
Document: {
userId,
},
},
});
return recipients;
};

View File

@ -0,0 +1,103 @@
import { nanoid } from 'nanoid';
import { prisma } from '@documenso/prisma';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
export interface SetRecipientsForDocumentOptions {
userId: number;
documentId: number;
recipients: {
id?: number | null;
email: string;
name: string;
}[];
}
export const setRecipientsForDocument = async ({
userId,
documentId,
recipients,
}: SetRecipientsForDocumentOptions) => {
const document = await prisma.document.findFirst({
where: {
id: documentId,
userId,
},
});
if (!document) {
throw new Error('Document not found');
}
const existingRecipients = await prisma.recipient.findMany({
where: {
documentId,
},
});
const removedRecipients = existingRecipients.filter(
(existingRecipient) =>
!recipients.find(
(recipient) =>
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
),
);
const linkedRecipients = recipients.map((recipient) => {
const existing = existingRecipients.find(
(existingRecipient) =>
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
);
return {
...recipient,
...existing,
};
});
for (const recipient of linkedRecipients) {
if (
recipient.sendStatus === SendStatus.SENT ||
recipient.signingStatus === SigningStatus.SIGNED
) {
throw new Error('Cannot modify recipients after sending');
}
}
const persistedRecipients = await prisma.$transaction(
linkedRecipients.map((recipient) =>
recipient.id
? prisma.recipient.update({
where: {
id: recipient.id,
documentId,
},
data: {
name: recipient.name,
email: recipient.email,
documentId,
},
})
: prisma.recipient.create({
data: {
name: recipient.name,
email: recipient.email,
token: nanoid(),
documentId,
},
}),
),
);
if (removedRecipients.length > 0) {
await prisma.recipient.deleteMany({
where: {
id: {
in: removedRecipients.map((recipient) => recipient.id),
},
},
});
}
return persistedRecipients;
};

View File

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

View File

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

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Field" ADD COLUMN "height" INTEGER NOT NULL DEFAULT -1,
ADD COLUMN "width" INTEGER NOT NULL DEFAULT -1;

View File

@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[documentId,email]` on the table `Recipient` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "Recipient_documentId_email_key" ON "Recipient"("documentId", "email");

View File

@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "Field" ALTER COLUMN "positionX" SET DEFAULT 0,
ALTER COLUMN "positionX" SET DATA TYPE DECIMAL(65,30),
ALTER COLUMN "positionY" SET DEFAULT 0,
ALTER COLUMN "positionY" SET DATA TYPE DECIMAL(65,30),
ALTER COLUMN "height" SET DEFAULT -1,
ALTER COLUMN "height" SET DATA TYPE DECIMAL(65,30),
ALTER COLUMN "width" SET DEFAULT -1,
ALTER COLUMN "width" SET DATA TYPE DECIMAL(65,30);

View File

@ -1,5 +1,6 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
previewFeatures = ["extendedWhereUnique"]
} }
datasource db { datasource db {
@ -123,11 +124,15 @@ model Recipient {
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
Field Field[] Field Field[]
Signature Signature[] Signature Signature[]
@@unique([documentId, email])
} }
enum FieldType { enum FieldType {
SIGNATURE SIGNATURE
FREE_SIGNATURE FREE_SIGNATURE
NAME
EMAIL
DATE DATE
TEXT TEXT
} }
@ -138,8 +143,10 @@ model Field {
recipientId Int? recipientId Int?
type FieldType type FieldType
page Int page Int
positionX Int @default(0) positionX Decimal @default(0)
positionY Int @default(0) positionY Decimal @default(0)
width Decimal @default(-1)
height Decimal @default(-1)
customText String customText String
inserted Boolean inserted Boolean
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)

View File

@ -7,6 +7,8 @@
"scripts": { "scripts": {
}, },
"dependencies": { "dependencies": {
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@tanstack/react-query": "^4.29.5", "@tanstack/react-query": "^4.29.5",
"@trpc/client": "^10.25.1", "@trpc/client": "^10.25.1",
"@trpc/next": "^10.25.1", "@trpc/next": "^10.25.1",

View File

@ -0,0 +1,55 @@
import { TRPCError } from '@trpc/server';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { authenticatedProcedure, router } from '../trpc';
import {
ZSetFieldsForDocumentMutationSchema,
ZSetRecipientsForDocumentMutationSchema,
} from './schema';
export const documentRouter = router({
setRecipientsForDocument: authenticatedProcedure
.input(ZSetRecipientsForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { documentId, recipients } = input;
return await setRecipientsForDocument({
userId: ctx.user.id,
documentId,
recipients,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'We were unable to set the recipients for this document. Please try again later.',
});
}
}),
setFieldsForDocument: authenticatedProcedure
.input(ZSetFieldsForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { documentId, fields } = input;
return await setFieldsForDocument({
userId: ctx.user.id,
documentId,
fields,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to set the fields for this document. Please try again later.',
});
}
}),
});

View File

@ -0,0 +1,38 @@
import { z } from 'zod';
import { FieldType } from '@documenso/prisma/client';
export const ZSetRecipientsForDocumentMutationSchema = z.object({
documentId: z.number(),
recipients: z.array(
z.object({
id: z.number().nullish(),
email: z.string().min(1).email(),
name: z.string(),
}),
),
});
export type TSetRecipientsForDocumentMutationSchema = z.infer<
typeof ZSetRecipientsForDocumentMutationSchema
>;
export const ZSetFieldsForDocumentMutationSchema = z.object({
documentId: z.number(),
fields: z.array(
z.object({
id: z.number().nullish(),
type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1),
pageNumber: z.number().min(1),
pageX: z.number().min(0),
pageY: z.number().min(0),
pageWidth: z.number().min(0),
pageHeight: z.number().min(0),
}),
),
});
export type TSetFieldsForDocumentMutationSchema = z.infer<
typeof ZSetFieldsForDocumentMutationSchema
>;

View File

@ -1,4 +1,5 @@
import { authRouter } from './auth-router/router'; import { authRouter } from './auth-router/router';
import { documentRouter } from './document-router/router';
import { profileRouter } from './profile-router/router'; import { profileRouter } from './profile-router/router';
import { procedure, router } from './trpc'; import { procedure, router } from './trpc';
@ -6,6 +7,7 @@ export const appRouter = router({
hello: procedure.query(() => 'Hello, world!'), hello: procedure.query(() => 'Hello, world!'),
auth: authRouter, auth: authRouter,
profile: profileRouter, profile: profileRouter,
document: documentRouter,
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

View File

@ -10,22 +10,10 @@ export type CardProps = React.HTMLAttributes<HTMLDivElement> & {
spotlight?: boolean; spotlight?: boolean;
gradient?: boolean; gradient?: boolean;
degrees?: number; degrees?: number;
lightMode?: boolean;
}; };
const Card = React.forwardRef<HTMLDivElement, CardProps>( const Card = React.forwardRef<HTMLDivElement, CardProps>(
( ({ className, children, gradient = false, spotlight = false, degrees = 120, ...props }, ref) => {
{
className,
children,
gradient = false,
spotlight = false,
degrees = 120,
lightMode = true,
...props
},
ref,
) => {
const mouseX = useMotionValue(0); const mouseX = useMotionValue(0);
const mouseY = useMotionValue(0); const mouseY = useMotionValue(0);
@ -46,12 +34,15 @@ const Card = React.forwardRef<HTMLDivElement, CardProps>(
} as React.CSSProperties } as React.CSSProperties
} }
className={cn( className={cn(
'bg-background text-foreground dark:hover:border-documenso group relative rounded-lg border-2 backdrop-blur-[2px]', 'bg-background text-foreground group relative rounded-lg border-2 backdrop-blur-[2px]',
{ {
'gradient-border-mask before:pointer-events-none before:absolute before:-inset-[2px] before:rounded-lg before:p-[2px] before:[background:linear-gradient(var(--card-gradient-degrees),theme(colors.documenso.DEFAULT/50%)_5%,theme(colors.border/80%)_30%)]': 'gradient-border-mask before:pointer-events-none before:absolute before:-inset-[2px] before:rounded-lg before:p-[2px] before:[background:linear-gradient(var(--card-gradient-degrees),theme(colors.documenso.DEFAULT/50%)_5%,theme(colors.border/80%)_30%)]':
gradient && lightMode, gradient,
'dark:gradient-border-mask before:pointer-events-none before:absolute before:-inset-[2px] before:rounded-lg before:p-[2px] before:[background:linear-gradient(var(--card-gradient-degrees),theme(colors.documenso.DEFAULT/70%)_5%,theme(colors.border/80%)_30%)]':
gradient,
'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%)]': '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%)]':
lightMode, true,
'dark:shadow-[0]': true,
}, },
className, className,
)} )}