refactor: extract common components into UI package

This commit is contained in:
David Nguyen
2023-08-23 11:22:13 +10:00
parent 07102588be
commit 2ab796910e
50 changed files with 113493 additions and 926 deletions

View File

@ -0,0 +1,548 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Caveat } from 'next/font/google';
import { Check, ChevronsUpDown, Info } from 'lucide-react';
import { nanoid } from 'nanoid';
import { useFieldArray, useForm } from 'react-hook-form';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { Document, Field, FieldType, Recipient, SendStatus } 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';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { TAddFieldsFormSchema } from './add-fields.types';
import {
DocumentFlowFormContainer,
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { FieldItem } from './field-item';
import { FRIENDLY_FIELD_TYPE } from './types';
const fontCaveat = Caveat({
weight: ['500'],
subsets: ['latin'],
display: 'swap',
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 = {
recipients: Recipient[];
fields: Field[];
document: Document;
onContinue?: () => void;
onGoBack?: () => void;
onSubmit: (_data: TAddFieldsFormSchema) => void;
};
export const AddFieldsFormPartial = ({
recipients,
fields,
onGoBack,
onSubmit,
}: AddFieldsFormProps) => {
const {
control,
handleSubmit,
formState: { isSubmitting },
} = useForm<TAddFieldsFormSchema>({
defaultValues: {
fields: 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 {
append,
remove,
update,
fields: localFields,
} = useFieldArray({
control,
name: 'fields',
});
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT;
const [visible, setVisible] = useState(false);
const [coords, setCoords] = useState({
x: 0,
y: 0,
});
const fieldBounds = useRef({
height: 0,
width: 0,
});
/**
* Given a mouse event, find the nearest pdf page element.
*/
const getPage = (event: MouseEvent) => {
if (!(event.target instanceof HTMLElement)) {
return null;
}
const target = event.target;
const $page =
target.closest<HTMLElement>(PDF_VIEWER_PAGE_SELECTOR) ??
target.querySelector<HTMLElement>(PDF_VIEWER_PAGE_SELECTOR);
if (!$page) {
return null;
}
return $page;
};
/**
* Provided a page and a field, calculate the position of the field
* as a percentage of the page width and height.
*/
const getFieldPosition = (page: HTMLElement, field: HTMLElement) => {
const {
top: pageTop,
left: pageLeft,
height: pageHeight,
width: pageWidth,
} = getBoundingClientRect(page);
const {
top: fieldTop,
left: fieldLeft,
height: fieldHeight,
width: fieldWidth,
} = getBoundingClientRect(field);
return {
x: ((fieldLeft - pageLeft) / pageWidth) * 100,
y: ((fieldTop - pageTop) / pageHeight) * 100,
width: (fieldWidth / pageWidth) * 100,
height: (fieldHeight / pageHeight) * 100,
};
};
/**
* Given a mouse event, determine if the mouse is within the bounds of the
* nearest pdf page element.
*/
const isWithinPageBounds = useCallback((event: MouseEvent) => {
const $page = getPage(event);
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 || !selectedSigner) {
return;
}
const $page = getPage(event);
if (!$page || !isWithinPageBounds(event)) {
return;
}
const { top, left, height, width } = getBoundingClientRect($page);
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],
);
const onFieldResize = useCallback(
(node: HTMLElement, index: number) => {
const field = localFields[index];
const $page = window.document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
);
if (!$page) {
return;
}
const {
x: pageX,
y: pageY,
width: pageWidth,
height: pageHeight,
} = getFieldPosition($page, node);
update(index, {
...field,
pageX,
pageY,
pageWidth,
pageHeight,
});
},
[localFields, update],
);
const onFieldMove = useCallback(
(node: HTMLElement, index: number) => {
const field = localFields[index];
const $page = window.document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
);
if (!$page) {
return;
}
const { x: pageX, y: pageY } = getFieldPosition($page, node);
update(index, {
...field,
pageX,
pageY,
});
},
[localFields, 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 = window.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),
};
}, []);
useEffect(() => {
setSelectedSigner(recipients.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipients[0]);
}, [recipients]);
return (
<DocumentFlowFormContainer>
<DocumentFlowFormContainerContent
title="Add Fields"
description="Add all relevant fields for each recipient."
>
<div className="flex flex-col">
{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>
)}
{localFields.map((field, index) => (
<FieldItem
key={index}
field={field}
disabled={selectedSigner?.email !== field.signerEmail || hasSelectedSignerBeenSent}
minHeight={fieldBounds.current.height}
minWidth={fieldBounds.current.width}
passive={visible && !!selectedField}
onResize={(options) => onFieldResize(options, index)}
onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)}
/>
))}
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
className="bg-background text-muted-foreground justify-between font-normal"
>
{selectedSigner?.email && (
<span className="flex-1 truncate text-left">
{selectedSigner?.email} ({selectedSigner?.email})
</span>
)}
{!selectedSigner?.email && (
<span className="flex-1 truncate text-left">{selectedSigner?.email}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput />
<CommandEmpty />
<CommandGroup>
{recipients.map((recipient, index) => (
<CommandItem
key={index}
className={cn({
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
})}
onSelect={() => setSelectedSigner(recipient)}
>
{recipient.sendStatus !== SendStatus.SENT ? (
<Check
aria-hidden={recipient !== selectedSigner}
className={cn('mr-2 h-4 w-4 flex-shrink-0', {
'opacity-0': recipient !== selectedSigner,
'opacity-100': recipient === selectedSigner,
})}
/>
) : (
<Tooltip>
<TooltipTrigger>
<Info className="mr-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
This document has already been sent to this recipient. You can no longer
edit this recipient.
</TooltipContent>
</Tooltip>
)}
{recipient.name && (
<span className="truncate" title={`${recipient.name} (${recipient.email})`}>
{recipient.name} ({recipient.email})
</span>
)}
{!recipient.name && (
<span className="truncate" title={recipient.email}>
{recipient.email}
</span>
)}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<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
type="button"
className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={() => setSelectedField(FieldType.SIGNATURE)}
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-3xl font-medium',
fontCaveat.className,
)}
>
{selectedSigner?.name || 'Signature'}
</p>
<p className="text-muted-foreground mt-2 text-center text-xs">Signature</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={() => setSelectedField(FieldType.EMAIL)}
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
)}
>
{'Email'}
</p>
<p className="text-muted-foreground mt-2 text-xs">Email</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={() => setSelectedField(FieldType.NAME)}
data-selected={selectedField === FieldType.NAME ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
)}
>
{'Name'}
</p>
<p className="text-muted-foreground mt-2 text-xs">Name</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={() => setSelectedField(FieldType.DATE)}
data-selected={selectedField === FieldType.DATE ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
)}
>
{'Date'}
</p>
<p className="text-muted-foreground mt-2 text-xs">Date</p>
</CardContent>
</Card>
</button>
</div>
</div>
</div>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep title="Add Fields" step={2} maxStep={3} />
<DocumentFlowFormContainerActions
loading={isSubmitting}
disabled={isSubmitting}
onGoNextClick={() => handleSubmit(onSubmit)()}
onGoBackClick={onGoBack}
/>
</DocumentFlowFormContainerFooter>
</DocumentFlowFormContainer>
);
};

View File

@ -0,0 +1,21 @@
import { z } from 'zod';
import { FieldType } from '@documenso/prisma/client';
export const ZAddFieldsFormSchema = z.object({
fields: z.array(
z.object({
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),
}),
),
});
export type TAddFieldsFormSchema = z.infer<typeof ZAddFieldsFormSchema>;

View File

@ -0,0 +1,219 @@
'use client';
import React, { useId } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { Plus, Trash } from 'lucide-react';
import { nanoid } from 'nanoid';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { Document, Field, Recipient, SendStatus } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { TAddSignersFormSchema } from './add-signers.types';
import {
DocumentFlowFormContainer,
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
DocumentFlowFormContainerStep,
} from './document-flow-root';
export type AddSignersFormProps = {
recipients: Recipient[];
fields: Field[];
document: Document;
onContinue?: () => void;
onGoBack?: () => void;
onSubmit: (_data: TAddSignersFormSchema) => void;
};
export const AddSignersFormPartial = ({
recipients,
fields: _fields,
onGoBack,
onSubmit,
}: AddSignersFormProps) => {
const { toast } = useToast();
const initialId = useId();
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TAddSignersFormSchema>({
defaultValues: {
signers:
recipients.length > 0
? recipients.map((recipient) => ({
nativeId: recipient.id,
formId: String(recipient.id),
name: recipient.name,
email: recipient.email,
}))
: [
{
formId: initialId,
name: '',
email: '',
},
],
},
});
const {
append: appendSigner,
fields: signers,
remove: removeSigner,
} = useFieldArray({
control,
name: 'signers',
});
const hasBeenSentToRecipientId = (id?: number) => {
if (!id) {
return false;
}
return recipients.some(
(recipient) => recipient.id === id && recipient.sendStatus === SendStatus.SENT,
);
};
const onAddSigner = () => {
appendSigner({
formId: nanoid(12),
name: '',
email: '',
});
};
const onRemoveSigner = (index: number) => {
const signer = signers[index];
if (hasBeenSentToRecipientId(signer.nativeId)) {
toast({
title: 'Cannot remove signer',
description: 'This signer has already received the document.',
variant: 'destructive',
});
return;
}
removeSigner(index);
};
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter' && event.target instanceof HTMLInputElement) {
onAddSigner();
}
};
return (
<DocumentFlowFormContainer onSubmit={handleSubmit(onSubmit)}>
<DocumentFlowFormContainerContent
title="Add Signers"
description="Add the people who will sign the document."
>
<div className="flex w-full flex-col gap-y-4">
<AnimatePresence>
{signers.map((signer, index) => (
<motion.div
key={signer.formId}
data-native-id={signer.nativeId}
className="flex flex-wrap items-end gap-x-4"
>
<div className="flex-1">
<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-${signer.formId}-email`}
type="email"
className="bg-background mt-2"
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
onKeyDown={onKeyDown}
{...field}
/>
)}
/>
</div>
<div className="flex-1">
<Label htmlFor={`signer-${signer.formId}-name`}>Name</Label>
<Controller
control={control}
name={`signers.${index}.name`}
render={({ field }) => (
<Input
id={`signer-${signer.formId}-name`}
type="text"
className="bg-background mt-2"
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
onKeyDown={onKeyDown}
{...field}
/>
)}
/>
</div>
<div>
<button
type="button"
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 ||
hasBeenSentToRecipientId(signer.nativeId) ||
signers.length === 1
}
onClick={() => onRemoveSigner(index)}
>
<Trash className="h-5 w-5" />
</button>
</div>
<div className="w-full">
<FormErrorMessage className="mt-2" error={errors.signers?.[index]?.email} />
<FormErrorMessage className="mt-2" error={errors.signers?.[index]?.name} />
</div>
</motion.div>
))}
</AnimatePresence>
</div>
<FormErrorMessage className="mt-2" error={errors.signers} />
<div className="mt-4">
<Button type="button" disabled={isSubmitting} onClick={() => onAddSigner()}>
<Plus className="-ml-1 mr-2 h-5 w-5" />
Add Signer
</Button>
</div>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep title="Add Signers" step={1} maxStep={3} />
<DocumentFlowFormContainerActions
loading={isSubmitting}
disabled={isSubmitting}
onGoNextClick={() => handleSubmit(onSubmit)()}
onGoBackClick={onGoBack}
/>
</DocumentFlowFormContainerFooter>
</DocumentFlowFormContainer>
);
};

View File

@ -0,0 +1,19 @@
import { z } from 'zod';
export const ZAddSignersFormSchema = z.object({
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'),
});
export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;

View File

@ -0,0 +1,137 @@
'use client';
import { useForm } from 'react-hook-form';
import { Document, DocumentStatus, Field, Recipient } from '@documenso/prisma/client';
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 { TAddSubjectFormSchema } from './add-subject.types';
import {
DocumentFlowFormContainer,
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
DocumentFlowFormContainerStep,
} from './document-flow-root';
export type AddSubjectFormProps = {
recipients: Recipient[];
fields: Field[];
document: Document;
onContinue?: () => void;
onGoBack?: () => void;
onSubmit: (_data: TAddSubjectFormSchema) => void;
};
export const AddSubjectFormPartial = ({
recipients: _recipients,
fields: _fields,
document,
onGoBack,
onSubmit,
}: AddSubjectFormProps) => {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TAddSubjectFormSchema>({
defaultValues: {
email: {
subject: '',
message: '',
},
},
});
return (
<DocumentFlowFormContainer>
<DocumentFlowFormContainerContent
title="Add Subject"
description="Add the subject and message you wish to send to signers."
>
<div className="flex flex-col">
<div className="flex flex-col gap-y-4">
<div>
<Label htmlFor="subject">
Subject <span className="text-muted-foreground">(Optional)</span>
</Label>
<Input
id="subject"
// placeholder="Subject"
className="bg-background mt-2"
disabled={isSubmitting}
{...register('email.subject')}
/>
<FormErrorMessage className="mt-2" error={errors.email?.subject} />
</div>
<div>
<Label htmlFor="message">
Message <span className="text-muted-foreground">(Optional)</span>
</Label>
<Textarea
id="message"
className="bg-background mt-2 h-32 resize-none"
disabled={isSubmitting}
{...register('email.message')}
/>
<FormErrorMessage
className="mt-2"
error={
typeof errors.email?.message !== 'string' ? errors.email?.message : undefined
}
/>
</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>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep title="Add Subject" step={3} maxStep={3} />
<DocumentFlowFormContainerActions
loading={isSubmitting}
disabled={isSubmitting}
goNextLabel={document.status === DocumentStatus.DRAFT ? 'Send' : 'Update'}
onGoNextClick={() => handleSubmit(onSubmit)()}
onGoBackClick={onGoBack}
/>
</DocumentFlowFormContainerFooter>
</DocumentFlowFormContainer>
);
};

View File

@ -0,0 +1,10 @@
import { z } from 'zod';
export const ZAddSubjectFormSchema = z.object({
email: z.object({
subject: z.string(),
message: z.string(),
}),
});
export type TAddSubjectFormSchema = z.infer<typeof ZAddSubjectFormSchema>;

View File

@ -0,0 +1,154 @@
'use client';
import React, { HTMLAttributes } from 'react';
import { Loader } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type DocumentFlowFormContainerProps = HTMLAttributes<HTMLFormElement> & {
children?: React.ReactNode;
};
export const DocumentFlowFormContainer = ({
children,
id = 'edit-document-form',
className,
...props
}: DocumentFlowFormContainerProps) => {
return (
<form
id={id}
className={cn(
'dark:bg-background border-border bg-widget sticky top-20 flex h-[calc(100vh-6rem)] max-h-screen flex-col rounded-xl border px-4 py-6',
className,
)}
{...props}
>
<div className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}>{children}</div>
</form>
);
};
export type DocumentFlowFormContainerContentProps = HTMLAttributes<HTMLDivElement> & {
title: string;
description: string;
children?: React.ReactNode;
};
export const DocumentFlowFormContainerContent = ({
children,
title,
description,
className,
...props
}: DocumentFlowFormContainerContentProps) => {
return (
<div className={cn('flex flex-1 flex-col', className)} {...props}>
<h3 className="text-foreground text-2xl font-semibold">{title}</h3>
<p className="text-muted-foreground mt-2 text-sm">{description}</p>
<hr className="border-border mb-8 mt-4" />
<div className="-mx-2 flex flex-1 flex-col overflow-y-auto px-2">{children}</div>
</div>
);
};
export type DocumentFlowFormContainerFooterProps = HTMLAttributes<HTMLDivElement> & {
children?: React.ReactNode;
};
export const DocumentFlowFormContainerFooter = ({
children,
className,
...props
}: DocumentFlowFormContainerFooterProps) => {
return (
<div className={cn('mt-4 flex-shrink-0', className)} {...props}>
{children}
</div>
);
};
export type DocumentFlowFormContainerStepProps = {
title: string;
step: number;
maxStep: number;
};
export const DocumentFlowFormContainerStep = ({
title,
step,
maxStep,
}: DocumentFlowFormContainerStepProps) => {
return (
<div>
<p className="text-muted-foreground text-sm">
{title}{' '}
<span>
({step}/{maxStep})
</span>
</p>
<div className="bg-muted relative mt-4 h-[2px] rounded-md">
<div
className="bg-documenso absolute inset-y-0 left-0"
style={{
width: `${(100 / maxStep) * step}%`,
}}
/>
</div>
</div>
);
};
export type DocumentFlowFormContainerActionsProps = {
canGoBack?: boolean;
canGoNext?: boolean;
goNextLabel?: string;
goBackLabel?: string;
onGoBackClick?: () => void;
onGoNextClick?: () => void;
loading?: boolean;
disabled?: boolean;
};
export const DocumentFlowFormContainerActions = ({
canGoBack = true,
canGoNext = true,
goNextLabel = 'Continue',
goBackLabel = 'Go Back',
onGoBackClick,
onGoNextClick,
loading,
disabled,
}: DocumentFlowFormContainerActionsProps) => {
return (
<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"
disabled={disabled || loading || !canGoBack}
onClick={onGoBackClick}
>
{goBackLabel}
</Button>
<Button
type="button"
className="bg-documenso flex-1"
size="lg"
disabled={disabled || loading || !canGoNext}
onClick={onGoNextClick}
>
{loading && <Loader className="mr-2 h-5 w-5 animate-spin" />}
{goNextLabel}
</Button>
</div>
);
};

View File

@ -0,0 +1,151 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Trash } from 'lucide-react';
import { createPortal } from 'react-dom';
import { Rnd } from 'react-rnd';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { FRIENDLY_FIELD_TYPE, TDocumentFlowFormSchema } from './types';
type Field = TDocumentFlowFormSchema['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: _minHeight,
minWidth: _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.getBoundingClientRect().top + window.scrollY;
const left = $page.getBoundingClientRect().left + window.scrollX;
// 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 z-10 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="text-muted-foreground/50 hover:text-muted-foreground/80 absolute -right-2 -top-2 z-[9999] flex h-8 w-8 items-center justify-center rounded-full border bg-white shadow-[0_0_0_2px_theme(colors.gray.100/70%)]"
onClick={() => onRemove?.()}
>
<Trash className="h-4 w-4" />
</button>
)}
<Card
className={cn('h-full w-full bg-white', {
'border-primary': !disabled,
'border-primary/80': 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,64 @@
import { useState } from 'react';
import { Loader } from 'lucide-react';
import { Button, ButtonProps } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
export type SendDocumentActionDialogProps = ButtonProps & {
loading?: boolean;
};
export const SendDocumentActionDialog = ({
loading,
className,
...props
}: SendDocumentActionDialogProps) => {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button type="button" className={className}>
{loading && <Loader className="text-documenso mr-2 h-5 w-5 animate-spin" />}
Send
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-center text-lg font-semibold">Send Document</DialogTitle>
<DialogDescription className="text-center text-base">
You are about to send this document to the recipients. Are you sure you want to
continue?
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-4 flex items-center gap-x-4">
<Button
className="dark:bg-muted dark:hover:bg-muted/80 dark:focus-visible:ring-muted/80 flex-1 border-none bg-black/5 hover:bg-black/10 focus-visible:ring-black/10"
type="button"
variant="secondary"
onClick={() => setOpen(false)}
>
Cancel
</Button>
{/* We would use DialogAction here but it interrupts the submit action */}
<Button className={className} {...props}>
{loading && <Loader className="mr-2 h-5 w-5 animate-spin" />}
Send
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,49 @@
import { z } from 'zod';
import { FieldType } from '@documenso/prisma/client';
export const ZDocumentFlowFormSchema = z.object({
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({
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 TDocumentFlowFormSchema = z.infer<typeof ZDocumentFlowFormSchema>;
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',
};