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",
"lucide-react": "^0.214.0",
"micro": "^10.0.1",
"nanoid": "^4.0.2",
"next": "13.4.1",
"next-auth": "^4.22.1",
"next-plausible": "^3.7.2",
@ -32,6 +33,7 @@
"react-hook-form": "^7.43.9",
"react-icons": "^4.8.0",
"react-pdf": "^7.1.1",
"react-rnd": "^10.4.1",
"typescript": "5.0.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 { 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';
@ -34,6 +36,17 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
redirect('/documents');
}
const [recipients, fields] = await Promise.all([
await getRecipientsForDocument({
documentId,
userId: session.id,
}),
await getFieldsForDocument({
documentId,
userId: session.id,
}),
]);
return (
<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">
@ -48,7 +61,13 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
{document.title}
</h1>
<EditDocumentForm className="mt-8" document={document} user={session} />
<EditDocumentForm
className="mt-8"
document={document}
user={session}
recipients={recipients}
fields={fields}
/>
</div>
);
}

View File

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

View File

@ -1,21 +1,22 @@
'use client';
import { useState } from 'react';
import { useId, useState } from 'react';
import dynamic from 'next/dynamic';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader } from 'lucide-react';
import { useTheme } from 'next-themes';
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 { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { AddFieldsFormPartial } from './edit-document/add-fields';
import { AddSignersFormPartial } from './edit-document/add-signers';
import { AddSubjectFormPartial } from './edit-document/add-subject';
import { TEditDocumentFormSchema, ZEditDocumentFormSchema } from './edit-document/types';
const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), {
@ -35,57 +36,143 @@ export type EditDocumentFormProps = {
className?: string;
user: User;
document: Document;
recipients: Recipient[];
fields: Field[];
};
export const EditDocumentForm = ({ className, document, user: _user }: EditDocumentFormProps) => {
const documentUrl = `data:application/pdf;base64,${document.document}`;
export const EditDocumentForm = ({
className,
document,
recipients,
fields,
user: _user,
}: EditDocumentFormProps) => {
const initialId = useId();
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 {
control,
// handleSubmit,
handleSubmit,
watch,
formState: { errors, isSubmitting, isValid },
trigger,
formState: { errors, isSubmitting },
} = useForm<TEditDocumentFormSchema>({
mode: 'onBlur',
defaultValues: {
signers: [
{
name: '',
email: '',
},
],
signers: defaultSigners,
fields: defaultFields,
email: {
subject: '',
message: '',
},
},
resolver: zodResolver(ZEditDocumentFormSchema),
});
const { theme } = useTheme();
const signersFormValue = watch('signers');
const fieldsFormValue = watch('fields');
console.log({ state: watch(), errors });
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 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 (
<div className={cn('grid w-full grid-cols-12 gap-x-8', className)}>
<Card
className="col-span-7 rounded-xl before:rounded-xl"
gradient
lightMode={theme === 'light'}
>
<Card className="col-span-7 rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2">
<PDFViewer document={documentUrl} />
</CardContent>
</Card>
<div className="relative 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">
<div className="col-span-5">
<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 && (
<AddSignersFormPartial
className="-mx-2 flex-1 overflow-y-hidden px-2"
control={control}
watch={watch}
errors={errors}
isSubmitting={isSubmitting}
/>
@ -98,7 +185,16 @@ export const EditDocumentForm = ({ className, document, user: _user }: EditDocum
watch={watch}
errors={errors}
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">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
size="lg"
variant="secondary"
@ -127,17 +224,27 @@ export const EditDocumentForm = ({ className, document, user: _user }: EditDocum
Go Back
</Button>
<Button
className="bg-documenso flex-1"
size="lg"
disabled={!canGoNext}
onClick={onGoNextClick}
>
Continue
</Button>
{step < MAX_STEP && (
<Button
type="button"
className="bg-documenso flex-1"
size="lg"
disabled={!canGoNext}
onClick={onGoNextClick}
>
{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>
</form>
</div>
</div>
);

View File

@ -1,12 +1,14 @@
'use client';
import { useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Caveat } from 'next/font/google';
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 { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -19,7 +21,10 @@ import {
} from '@documenso/ui/primitives/command';
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({
weight: ['500'],
@ -28,30 +33,285 @@ const fontCaveat = 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 = {
className?: string;
control: Control<TEditDocumentFormSchema>;
watch: UseFormWatch<TEditDocumentFormSchema>;
errors: FieldErrors<TEditDocumentFormSchema>;
isSubmitting: boolean;
theme: string;
};
export const AddFieldsFormPartial = ({
className,
control: _control,
control: control,
watch,
errors: _errors,
isSubmitting: _isSubmitting,
theme,
}: AddFieldsFormProps) => {
const signers = watch('signers');
const fields = watch('fields');
const { append, remove, update } = useFieldArray({
control,
name: 'fields',
});
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 (
<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">
Add all relevant fields for each recipient.
@ -62,6 +322,7 @@ export const AddFieldsFormPartial = ({
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
className="bg-background text-muted-foreground justify-between font-normal"
@ -87,6 +348,7 @@ export const AddFieldsFormPartial = ({
{signers.map((signer, index) => (
<CommandItem key={index} onSelect={() => setSelectedSigner(signer)}>
<Check
aria-hidden={signer !== selectedSigner}
className={cn('mr-2 h-4 w-4', {
'opacity-0': 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="mt-4 grid grid-cols-2 gap-x-4 gap-y-8">
<button className="group h-full w-full">
<Card
className="group-focus:border-documenso h-full w-full cursor-pointer"
lightMode={theme === 'light'}
>
<button
type="button"
className="group h-full w-full"
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">
<p
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,
)}
>
@ -128,15 +392,17 @@ export const AddFieldsFormPartial = ({
</Card>
</button>
<button className="group h-full w-full">
<Card
className="group-focus:border-documenso h-full w-full cursor-pointer"
lightMode={theme === 'light'}
>
<button
type="button"
className="group h-full w-full"
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">
<p
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'}
@ -147,15 +413,17 @@ export const AddFieldsFormPartial = ({
</Card>
</button>
<button className="group h-full w-full">
<Card
className="group-focus:border-documenso h-full w-full cursor-pointer"
lightMode={theme === 'light'}
>
<button
type="button"
className="group h-full w-full"
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">
<p
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'}
@ -166,15 +434,17 @@ export const AddFieldsFormPartial = ({
</Card>
</button>
<button className="group h-full w-full">
<Card
className="group-focus:border-documenso h-full w-full cursor-pointer"
lightMode={theme === 'light'}
>
<button
type="button"
className="group h-full w-full"
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">
<p
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'}

View File

@ -1,8 +1,11 @@
'use client';
import React from 'react';
import { AnimatePresence, motion } from 'framer-motion';
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 { Button } from '@documenso/ui/primitives/button';
@ -16,6 +19,7 @@ import { TEditDocumentFormSchema } from './types';
export type AddSignersFormProps = {
className?: string;
control: Control<TEditDocumentFormSchema>;
watch: UseFormWatch<TEditDocumentFormSchema>;
errors: FieldErrors<TEditDocumentFormSchema>;
isSubmitting: boolean;
};
@ -27,14 +31,49 @@ export const AddSignersFormPartial = ({
isSubmitting,
}: AddSignersFormProps) => {
const {
append,
append: appendSigner,
fields: signers,
remove,
remove: removeSigner,
} = useFieldArray({
control,
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 (
<div className={cn('flex flex-col', className)}>
<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" />
<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">
<AnimatePresence>
{signers.map((field, index) => (
<motion.div key={field.id} className="flex flex-wrap items-end gap-x-4">
{signers.map((signer, index) => (
<motion.div key={signer.formId} className="flex flex-wrap items-end gap-x-4">
<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
control={control}
name={`signers.${index}.email`}
render={({ field }) => (
<Input
id={`signer-${index}-email`}
id={`signer-${signer.formId}-email`}
type="email"
className="bg-background mt-2"
disabled={isSubmitting}
onKeyDown={onKeyDown}
{...field}
/>
)}
@ -69,17 +112,18 @@ export const AddSignersFormPartial = ({
</div>
<div className="flex-1">
<Label htmlFor={`signer-${index}-name`}>Name</Label>
<Label htmlFor={`signer-${signer.formId}-name`}>Name</Label>
<Controller
control={control}
name={`signers.${index}.name`}
render={({ field }) => (
<Input
id={`signer-${index}-name`}
id={`signer-${signer.formId}-name`}
type="text"
className="bg-background mt-2"
disabled={isSubmitting}
onKeyDown={onKeyDown}
{...field}
/>
)}
@ -89,9 +133,9 @@ export const AddSignersFormPartial = ({
<div>
<button
type="button"
className="inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80"
disabled={isSubmitting}
onClick={() => remove(index)}
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 || signers.length === 1}
onClick={() => onRemoveSigner(index)}
>
<Trash className="h-5 w-5" />
</button>
@ -106,17 +150,10 @@ export const AddSignersFormPartial = ({
</AnimatePresence>
</div>
<FormErrorMessage className="mt-2" error={errors.signers} />
<div className="mt-4">
<Button
type="button"
disabled={isSubmitting}
onClick={() =>
append({
email: '',
name: '',
})
}
>
<Button type="button" disabled={isSubmitting} onClick={() => onAddSigner()}>
<Plus className="-ml-1 mr-2 h-5 w-5" />
Add Signer
</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 { FieldType } from '@documenso/prisma/client';
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({
id: z.number().optional(),
email: z.string().min(1).email(),
name: z.string(),
formId: z.string().min(1),
nativeId: z.number().optional(),
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 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',
};