mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
feat: persist fields and recipients for document editing
This commit is contained in:
@ -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"
|
||||
},
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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'}
|
||||
|
||||
@ -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>
|
||||
|
||||
111
apps/web/src/components/forms/edit-document/add-subject.tsx
Normal file
111
apps/web/src/components/forms/edit-document/add-subject.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
149
apps/web/src/components/forms/edit-document/field-item.tsx
Normal file
149
apps/web/src/components/forms/edit-document/field-item.tsx
Normal 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,
|
||||
);
|
||||
};
|
||||
53
apps/web/src/components/forms/edit-document/provider.tsx
Normal file
53
apps/web/src/components/forms/edit-document/provider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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',
|
||||
};
|
||||
|
||||
146
package-lock.json
generated
146
package-lock.json
generated
@ -64,6 +64,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",
|
||||
@ -75,6 +76,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"
|
||||
},
|
||||
@ -85,6 +87,23 @@
|
||||
"@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": {
|
||||
"version": "5.2.0",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
|
||||
@ -6606,6 +6630,19 @@
|
||||
"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": {
|
||||
"version": "14.2.3",
|
||||
"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": {
|
||||
"version": "1.5.0",
|
||||
"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",
|
||||
"@upstash/redis": "^1.20.6",
|
||||
"bcrypt": "^5.1.0",
|
||||
"nanoid": "^4.0.2",
|
||||
"next": "13.4.1",
|
||||
"next-auth": "^4.22.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
@ -8351,6 +8420,23 @@
|
||||
"@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": {
|
||||
"name": "@documenso/prettier-config",
|
||||
"version": "0.0.0",
|
||||
@ -8390,6 +8476,8 @@
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@tanstack/react-query": "^4.29.5",
|
||||
"@trpc/client": "^10.25.1",
|
||||
"@trpc/next": "^10.25.1",
|
||||
@ -8842,10 +8930,18 @@
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@upstash/redis": "^1.20.6",
|
||||
"bcrypt": "^5.1.0",
|
||||
"nanoid": "^4.0.2",
|
||||
"next": "13.4.1",
|
||||
"next-auth": "^4.22.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"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": {
|
||||
@ -8903,6 +8999,8 @@
|
||||
"@documenso/trpc": {
|
||||
"version": "file:packages/trpc",
|
||||
"requires": {
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@tanstack/react-query": "^4.29.5",
|
||||
"@trpc/client": "^10.25.1",
|
||||
"@trpc/next": "^10.25.1",
|
||||
@ -8978,6 +9076,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",
|
||||
@ -8989,8 +9088,16 @@
|
||||
"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"
|
||||
},
|
||||
"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": {
|
||||
@ -11693,6 +11800,11 @@
|
||||
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
|
||||
"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": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
|
||||
@ -13236,6 +13348,15 @@
|
||||
"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": {
|
||||
"version": "14.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz",
|
||||
@ -13299,6 +13420,31 @@
|
||||
"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": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-ssr-prepass/-/react-ssr-prepass-1.5.0.tgz",
|
||||
|
||||
@ -30,7 +30,6 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
const user = await getUserByEmail({ email }).catch(() => null);
|
||||
|
||||
if (!user || !user.password) {
|
||||
console.log('no user');
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
"@upstash/redis": "^1.20.6",
|
||||
"bcrypt": "^5.1.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"nanoid": "^4.0.2",
|
||||
"next": "13.4.1",
|
||||
"next-auth": "^4.22.1",
|
||||
"stripe": "^12.7.0"
|
||||
|
||||
19
packages/lib/server-only/field/get-fields-for-document.ts
Normal file
19
packages/lib/server-only/field/get-fields-for-document.ts
Normal 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;
|
||||
};
|
||||
127
packages/lib/server-only/field/set-fields-for-document.ts
Normal file
127
packages/lib/server-only/field/set-fields-for-document.ts
Normal 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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "FieldType" ADD VALUE 'NAME';
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "FieldType" ADD VALUE 'EMAIL';
|
||||
@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Field" ADD COLUMN "height" INTEGER NOT NULL DEFAULT -1,
|
||||
ADD COLUMN "width" INTEGER NOT NULL DEFAULT -1;
|
||||
@ -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");
|
||||
@ -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);
|
||||
@ -1,5 +1,6 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["extendedWhereUnique"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
@ -123,11 +124,15 @@ model Recipient {
|
||||
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
Field Field[]
|
||||
Signature Signature[]
|
||||
|
||||
@@unique([documentId, email])
|
||||
}
|
||||
|
||||
enum FieldType {
|
||||
SIGNATURE
|
||||
FREE_SIGNATURE
|
||||
NAME
|
||||
EMAIL
|
||||
DATE
|
||||
TEXT
|
||||
}
|
||||
@ -138,8 +143,10 @@ model Field {
|
||||
recipientId Int?
|
||||
type FieldType
|
||||
page Int
|
||||
positionX Int @default(0)
|
||||
positionY Int @default(0)
|
||||
positionX Decimal @default(0)
|
||||
positionY Decimal @default(0)
|
||||
width Decimal @default(-1)
|
||||
height Decimal @default(-1)
|
||||
customText String
|
||||
inserted Boolean
|
||||
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@ -7,6 +7,8 @@
|
||||
"scripts": {
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@tanstack/react-query": "^4.29.5",
|
||||
"@trpc/client": "^10.25.1",
|
||||
"@trpc/next": "^10.25.1",
|
||||
|
||||
55
packages/trpc/server/document-router/router.ts
Normal file
55
packages/trpc/server/document-router/router.ts
Normal 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.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
38
packages/trpc/server/document-router/schema.ts
Normal file
38
packages/trpc/server/document-router/schema.ts
Normal 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
|
||||
>;
|
||||
@ -1,4 +1,5 @@
|
||||
import { authRouter } from './auth-router/router';
|
||||
import { documentRouter } from './document-router/router';
|
||||
import { profileRouter } from './profile-router/router';
|
||||
import { procedure, router } from './trpc';
|
||||
|
||||
@ -6,6 +7,7 @@ export const appRouter = router({
|
||||
hello: procedure.query(() => 'Hello, world!'),
|
||||
auth: authRouter,
|
||||
profile: profileRouter,
|
||||
document: documentRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@ -10,22 +10,10 @@ export type CardProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
spotlight?: boolean;
|
||||
gradient?: boolean;
|
||||
degrees?: number;
|
||||
lightMode?: boolean;
|
||||
};
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
gradient = false,
|
||||
spotlight = false,
|
||||
degrees = 120,
|
||||
lightMode = true,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
({ className, children, gradient = false, spotlight = false, degrees = 120, ...props }, ref) => {
|
||||
const mouseX = useMotionValue(0);
|
||||
const mouseY = useMotionValue(0);
|
||||
|
||||
@ -46,12 +34,15 @@ const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||
} as React.CSSProperties
|
||||
}
|
||||
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 && 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%)]':
|
||||
lightMode,
|
||||
true,
|
||||
'dark:shadow-[0]': true,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user