feat: use server-actions for authoring flow

This change actually makes the authoring flow work for
the most part by tying in emailing and more.

We have also done a number of quality of life updates to
simplify the codebase overall making it easier to continue
work on the refresh.
This commit is contained in:
Mythie
2023-07-26 18:52:53 +10:00
parent 60b150cc58
commit b3fa837967
54 changed files with 2891 additions and 859 deletions

View File

@ -3,12 +3,13 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Caveat } from 'next/font/google';
import { useRouter } from 'next/navigation';
import { Check, ChevronsUpDown } from 'lucide-react';
import { Check, ChevronsUpDown, Info } from 'lucide-react';
import { nanoid } from 'nanoid';
import { Control, FieldErrors, UseFormWatch, useFieldArray } from 'react-hook-form';
import { useFieldArray, useForm } from 'react-hook-form';
import { FieldType } from '@documenso/prisma/client';
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';
@ -20,11 +21,23 @@ import {
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 { useToast } from '@documenso/ui/primitives/use-toast';
import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types';
import { getBoundingClientRect } from '~/helpers/getBoundingClientRect';
import { addFields } from './add-fields.action';
import { TAddFieldsFormSchema } from './add-fields.types';
import {
EditDocumentFormContainer,
EditDocumentFormContainerActions,
EditDocumentFormContainerContent,
EditDocumentFormContainerFooter,
EditDocumentFormContainerStep,
} from './container';
import { FieldItem } from './field-item';
import { FRIENDLY_FIELD_TYPE, TEditDocumentFormSchema } from './types';
import { FRIENDLY_FIELD_TYPE } from './types';
const fontCaveat = Caveat({
weight: ['500'],
@ -40,31 +53,58 @@ 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;
recipients: Recipient[];
fields: Field[];
document: Document;
onContinue?: () => void;
onGoBack?: () => void;
};
export const AddFieldsFormPartial = ({
className,
control: control,
watch,
errors: _errors,
isSubmitting: _isSubmitting,
recipients,
fields,
document,
onContinue,
onGoBack,
}: AddFieldsFormProps) => {
const signers = watch('signers');
const fields = watch('fields');
const { toast } = useToast();
const router = useRouter();
const { append, remove, update } = useFieldArray({
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 [selectedSigner, setSelectedSigner] = useState(() => signers[0]);
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({
@ -77,14 +117,60 @@ export const AddFieldsFormPartial = ({
width: 0,
});
const isWithinPageBounds = useCallback((event: MouseEvent) => {
/**
* Given a mouse event, find the nearest pdf page element.
*/
const getPage = (event: MouseEvent) => {
if (!(event.target instanceof HTMLElement)) {
return false;
return null;
}
const target = event.target;
const $page =
target.closest(PDF_VIEWER_PAGE_SELECTOR) ?? target.querySelector(PDF_VIEWER_PAGE_SELECTOR);
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;
@ -121,28 +207,17 @@ export const AddFieldsFormPartial = ({
const onMouseClick = useCallback(
(event: MouseEvent) => {
if (!selectedField) {
if (!selectedField || !selectedSigner) {
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);
const $page = getPage(event);
if (!$page || !isWithinPageBounds(event)) {
return;
}
const { height, width } = $page.getBoundingClientRect();
const top = $page.offsetTop;
const left = $page.offsetLeft;
const { top, left, height, width } = getBoundingClientRect($page);
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
@ -172,14 +247,14 @@ export const AddFieldsFormPartial = ({
setVisible(false);
setSelectedField(null);
},
[append, isWithinPageBounds, selectedField, selectedSigner.email],
[append, isWithinPageBounds, selectedField, selectedSigner],
);
const onFieldResize = useCallback(
(node: HTMLElement, index: number) => {
const field = fields[index];
const field = localFields[index];
const $page = document.querySelector<HTMLElement>(
const $page = window.document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
);
@ -187,71 +262,69 @@ export const AddFieldsFormPartial = ({
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;
const {
x: pageX,
y: pageY,
width: pageWidth,
height: pageHeight,
} = getFieldPosition($page, node);
update(index, {
...field,
pageX: newX,
pageY: newY,
pageWidth: newWidth,
pageHeight: newHeight,
pageX,
pageY,
pageWidth,
pageHeight,
});
},
[fields, update],
[localFields, update],
);
const onFieldMove = useCallback(
(node: HTMLElement, index: number) => {
const field = fields[index];
const field = localFields[index];
const $page = document.querySelector(
const $page = window.document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
);
if (!$page || !($page instanceof HTMLElement)) {
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();
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;
const { x: pageX, y: pageY } = getFieldPosition($page, node);
update(index, {
...field,
pageX: newX,
pageY: newY,
pageX,
pageY,
});
},
[fields, update],
[localFields, update],
);
const onFormSubmit = handleSubmit(async (data: TAddFieldsFormSchema) => {
try {
// Custom invocation server action
await addFields({
documentId: document.id,
fields: data.fields,
});
router.refresh();
onContinue?.();
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while adding signers.',
variant: 'destructive',
});
}
});
useEffect(() => {
if (selectedField) {
window.addEventListener('mousemove', onMouseMove);
@ -265,7 +338,7 @@ export const AddFieldsFormPartial = ({
}, [onMouseClick, onMouseMove, selectedField]);
useEffect(() => {
const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
const $page = window.document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
if (!$page) {
return;
@ -279,183 +352,226 @@ export const AddFieldsFormPartial = ({
};
}, []);
useEffect(() => {
setSelectedSigner(recipients.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipients[0]);
}, [recipients]);
return (
<div className={cn('flex flex-col', className)}>
{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.
</p>
<hr className="mb-8 mt-4" />
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
className="bg-background text-muted-foreground justify-between font-normal"
>
{selectedSigner.name && (
<span>
{selectedSigner.name} ({selectedSigner.email})
</span>
)}
{!selectedSigner.name && <span>{selectedSigner.email}</span>}
<ChevronsUpDown className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput />
<CommandEmpty />
<CommandGroup>
{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,
})}
/>
{signer.name && (
<span>
{signer.name} ({signer.email})
</span>
)}
{!signer.name && <span>{signer.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"
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-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>
<EditDocumentFormContainer>
<EditDocumentFormContainerContent
title="Add Fields"
description="Add all relevant fields for each recipient."
>
<div className="flex-col flex">
{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>
</button>
)}
<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-data-[selected]:text-foreground text-xl font-medium',
)}
>
{'Email'}
</p>
{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)}
/>
))}
<p className="text-muted-foreground mt-2 text-xs">Email</p>
</CardContent>
</Card>
</button>
<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>
)}
<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-data-[selected]:text-foreground text-xl font-medium',
)}
>
{'Name'}
</p>
{!selectedSigner?.email && (
<span className="flex-1 truncate text-left">{selectedSigner?.email}</span>
)}
<p className="text-muted-foreground mt-2 text-xs">Name</p>
</CardContent>
</Card>
</button>
<ChevronsUpDown className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
<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-data-[selected]:text-foreground text-xl font-medium',
)}
>
{'Date'}
</p>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput />
<CommandEmpty />
<p className="text-muted-foreground mt-2 text-xs">Date</p>
</CardContent>
</Card>
</button>
<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-col flex 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-col flex 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-col flex 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-col flex 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>
</div>
</div>
</EditDocumentFormContainerContent>
<EditDocumentFormContainerFooter>
<EditDocumentFormContainerStep title="Add Fields" step={2} maxStep={3} />
<EditDocumentFormContainerActions
loading={isSubmitting}
disabled={isSubmitting}
onGoNextClick={() => onFormSubmit()}
onGoBackClick={onGoBack}
/>
</EditDocumentFormContainerFooter>
</EditDocumentFormContainer>
);
};