wip: create document workflow

This commit is contained in:
Mythie
2023-06-10 22:33:12 +10:00
parent 159bcade7b
commit cc182649c2
9 changed files with 506 additions and 54 deletions

View File

@ -30,7 +30,7 @@ export default async function DashboardPage() {
findDocuments({ findDocuments({
userId: session.id, userId: session.id,
perPage: 10, perPage: 10,
}).then((r) => ({ ...r, data: [] })), }),
]); ]);
return ( return (

View File

@ -1,7 +1,5 @@
'use client'; 'use client';
import { Suspense } from 'react';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
@ -15,7 +13,7 @@ export type LoadablePDFCard = PDFViewerProps & {
pdfClassName?: string; pdfClassName?: string;
}; };
const PDFCard = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), { const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), {
ssr: false, ssr: false,
loading: () => ( loading: () => (
<div className="flex min-h-[80vh] flex-col items-center justify-center bg-white/50"> <div className="flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
@ -30,7 +28,7 @@ export const LoadablePDFCard = ({ className, pdfClassName, ...props }: LoadableP
return ( return (
<Card className={className} gradient {...props}> <Card className={className} gradient {...props}>
<CardContent className="p-2"> <CardContent className="p-2">
<PDFCard className={pdfClassName} {...props} /> <PDFViewer className={pdfClassName} {...props} />
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@ -5,9 +5,8 @@ import { ChevronLeft } from 'lucide-react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { Button } from '@documenso/ui/primitives/button';
import { LoadablePDFCard } from './loadable-pdf-card'; import { EditDocumentForm } from '~/components/forms/edit-document';
export type DocumentPageProps = { export type DocumentPageProps = {
params: { params: {
@ -49,46 +48,7 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
Document.pdf Document.pdf
</h1> </h1>
<div className="mt-8 grid w-full grid-cols-12 gap-x-8"> <EditDocumentForm className="mt-8" document={document} user={session} />
<LoadablePDFCard
className="col-span-7 rounded-xl before:rounded-xl"
document={document.document}
/>
<div className="relative col-span-5">
<div className="sticky top-20 flex max-h-screen min-h-[calc(100vh-6rem)] flex-col rounded-xl border bg-[hsl(var(--widget))] px-4 py-6">
<h3 className="text-2xl font-semibold">Add Signers</h3>
<p className="mt-2 text-sm text-black/30">Add the people who will sign the document.</p>
<hr className="mb-8 mt-4" />
<div className="flex-1"></div>
<div className="">
<p className="text-sm text-black/30">Add Signers (1/3)</p>
<div className="relative mt-4 h-[2px] rounded-md bg-slate-300">
<div className="bg-primary absolute inset-y-0 left-0 w-1/3" />
</div>
<div className="mt-4 flex gap-x-4">
<Button
className="flex-1 bg-black/5 hover:bg-black/10"
size="lg"
variant="secondary"
>
Go Back
</Button>
<Button className="flex-1" size="lg">
Continue
</Button>
</div>
</div>
</div>
</div>
</div>
</div> </div>
); );
} }

View File

@ -13,7 +13,7 @@ export type CardMetricProps = {
export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricProps) => { export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricProps) => {
return ( return (
<div className={cn('overflow-hidden rounded-lg border border-slate-200 bg-white')}> <div className={cn('overflow-hidden rounded-lg border border-slate-200 bg-white', className)}>
<div className="px-4 pb-6 pt-4 sm:px-4 sm:pb-8 sm:pt-4"> <div className="px-4 pb-6 pt-4 sm:px-4 sm:pb-8 sm:pt-4">
<div className="flex items-start"> <div className="flex items-start">
{Icon && <Icon className="mr-2 h-4 w-4 text-slate-500" />} {Icon && <Icon className="mr-2 h-4 w-4 text-slate-500" />}

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf'; import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf';
@ -11,18 +11,32 @@ import { cn } from '@documenso/ui/lib/utils';
type LoadedPDFDocument = pdfjs.PDFDocumentProxy; type LoadedPDFDocument = pdfjs.PDFDocumentProxy;
/**
* This imports the worker from the `pdfjs-dist` package.
*/
pdfjs.GlobalWorkerOptions.workerSrc = new URL( pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.js', 'pdfjs-dist/build/pdf.worker.min.js',
import.meta.url, import.meta.url,
).toString(); ).toString();
export type OnPDFViewerPageClick = (_event: {
pageNumber: number;
numPages: number;
originalEvent: React.MouseEvent<HTMLDivElement, MouseEvent>;
pageHeight: number;
pageWidth: number;
pageX: number;
pageY: number;
}) => void | Promise<void>;
export type PDFViewerProps = { export type PDFViewerProps = {
className?: string; className?: string;
document: string; document: string;
onPageClick?: OnPDFViewerPageClick;
[key: string]: unknown; [key: string]: unknown;
}; };
export const PDFViewer = ({ className, document, ...props }: PDFViewerProps) => { export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFViewerProps) => {
const $el = useRef<HTMLDivElement>(null); const $el = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0); const [width, setWidth] = useState(0);
@ -32,6 +46,50 @@ export const PDFViewer = ({ className, document, ...props }: PDFViewerProps) =>
setNumPages(doc.numPages); setNumPages(doc.numPages);
}; };
const onDocumentPageClick = (
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
pageNumber: number,
) => {
const $el = event.target instanceof HTMLElement ? event.target : null;
if (!$el) {
return;
}
const $page = $el.closest('.react-pdf__Page');
if (!$page) {
return;
}
const { height, width, top, left } = $page.getBoundingClientRect();
const pageX = event.clientX - left;
const pageY = event.clientY - top;
console.log({
pageNumber,
numPages,
originalEvent: event,
pageHeight: height,
pageWidth: width,
pageX,
pageY,
});
if (onPageClick) {
onPageClick({
pageNumber,
numPages,
originalEvent: event,
pageHeight: height,
pageWidth: width,
pageX,
pageY,
});
}
};
useEffect(() => { useEffect(() => {
if ($el.current) { if ($el.current) {
const $current = $el.current; const $current = $el.current;
@ -55,9 +113,9 @@ export const PDFViewer = ({ className, document, ...props }: PDFViewerProps) =>
}, []); }, []);
return ( return (
<div ref={$el} className={cn('overflow-hidden', className)}> <div ref={$el} className={cn('overflow-hidden', className)} {...props}>
<PDFDocument <PDFDocument
file={`data:application/pdf;base64,${document}`} file={document}
className="w-full overflow-hidden rounded" className="w-full overflow-hidden rounded"
onLoadSuccess={(d) => onDocumentLoaded(d)} onLoadSuccess={(d) => onDocumentLoaded(d)}
externalLinkTarget="_blank" externalLinkTarget="_blank"
@ -74,9 +132,13 @@ export const PDFViewer = ({ className, document, ...props }: PDFViewerProps) =>
.map((_, i) => ( .map((_, i) => (
<div <div
key={i} key={i}
className="border-t-primary mt-8 border-t pt-8 first:mt-0 first:border-t-0 first:pt-0" className="border-primary/50 my-8 overflow-hidden rounded border first:mt-0 last:mb-0"
> >
<PDFPage pageNumber={i + 1} width={width} /> <PDFPage
pageNumber={i + 1}
width={width}
onClick={(e) => onDocumentPageClick(e, i + 1)}
/>
</div> </div>
))} ))}
</PDFDocument> </PDFDocument>

View File

@ -0,0 +1,131 @@
'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { Document, User } 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 { AddFieldsFormPartial } from './edit-document/add-fields';
import { AddSignersFormPartial } from './edit-document/add-signers';
import { TEditDocumentFormSchema, ZEditDocumentFormSchema } from './edit-document/types';
const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), {
ssr: false,
loading: () => (
<div className="flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
<Loader className="h-12 w-12 animate-spin text-slate-500" />
<p className="mt-4 text-slate-500">Loading document...</p>
</div>
),
});
const MAX_STEP = 2;
export type EditDocumentFormProps = {
className?: string;
user: User;
document: Document;
};
export const EditDocumentForm = ({ className, document, user: _user }: EditDocumentFormProps) => {
const documentUrl = `data:application/pdf;base64,${document.document}`;
const [step, setStep] = useState(0);
const {
control,
// handleSubmit,
watch,
formState: { errors, isSubmitting, isValid },
} = useForm<TEditDocumentFormSchema>({
mode: 'onBlur',
defaultValues: {
signers: [
{
name: '',
email: '',
},
],
},
resolver: zodResolver(ZEditDocumentFormSchema),
});
const canGoBack = step > 0;
const canGoNext = isValid && step < MAX_STEP;
const onGoBackClick = () => setStep((prev) => Math.max(0, prev - 1));
const onGoNextClick = () => setStep((prev) => Math.min(MAX_STEP, prev + 1));
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>
<CardContent className="p-2">
<PDFViewer document={documentUrl} />
</CardContent>
</Card>
<div className="relative col-span-5">
<div className="sticky top-20 flex h-[calc(100vh-6rem)] max-h-screen flex-col rounded-xl border bg-[hsl(var(--widget))] px-4 py-6">
{step === 0 && (
<AddSignersFormPartial
className="-mx-2 flex-1 overflow-y-hidden px-2"
control={control}
errors={errors}
isSubmitting={isSubmitting}
/>
)}
{step === 1 && (
<AddFieldsFormPartial
className="-mx-2 flex-1 overflow-y-hidden px-2"
control={control}
watch={watch}
errors={errors}
isSubmitting={isSubmitting}
/>
)}
<div className="mt-4 flex-shrink-0">
<p className="text-sm text-black/30">
Add Signers ({step + 1}/{MAX_STEP + 1})
</p>
<div className="relative mt-4 h-[2px] rounded-md bg-slate-300">
<div
className="bg-primary absolute inset-y-0 left-0"
style={{
width: `${(100 / (MAX_STEP + 1)) * (step + 1)}%`,
}}
/>
</div>
<div className="mt-4 flex gap-x-4">
<Button
className="flex-1 bg-black/5 hover:bg-black/10"
size="lg"
variant="secondary"
disabled={!canGoBack}
onClick={onGoBackClick}
>
Go Back
</Button>
<Button className="flex-1" size="lg" disabled={!canGoNext} onClick={onGoNextClick}>
Continue
</Button>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,163 @@
'use client';
import { useState } from 'react';
import { Caveat } from 'next/font/google';
import { Check, ChevronsUpDown } from 'lucide-react';
import { Control, FieldErrors, UseFormWatch } from 'react-hook-form';
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 { TEditDocumentFormSchema } from './types';
const fontCaveat = Caveat({
weight: ['500'],
subsets: ['latin'],
display: 'swap',
variable: '--font-caveat',
});
export type AddFieldsFormProps = {
className?: string;
control: Control<TEditDocumentFormSchema>;
watch: UseFormWatch<TEditDocumentFormSchema>;
errors: FieldErrors<TEditDocumentFormSchema>;
isSubmitting: boolean;
};
export const AddFieldsFormPartial = ({
className,
control: _control,
watch,
errors: _errors,
isSubmitting: _isSubmitting,
}: AddFieldsFormProps) => {
const signers = watch('signers');
const [selectedSigner, setSelectedSigner] = useState(() => signers[0]);
return (
<div className={cn('flex flex-col', className)}>
<h3 className="text-2xl font-semibold">Edit Document</h3>
<p className="mt-2 text-sm text-black/30">Add all relevant fields for each recipient.</p>
<hr className="mb-8 mt-4" />
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="justify-between bg-white font-normal text-slate-500"
>
{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
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="grid grid-cols-2 gap-x-4 gap-y-8">
<button className="group h-full w-full">
<Card className="group-focus:border-primary h-full w-full cursor-pointer">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-3xl font-medium text-slate-500 group-focus:text-slate-900',
fontCaveat.className,
)}
>
{selectedSigner.name || 'Signature'}
</p>
<p className="mt-2 text-center text-xs text-slate-500">Signature</p>
</CardContent>
</Card>
</button>
<button className="group h-full w-full">
<Card className="group-focus:border-primary h-full w-full cursor-pointer">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p className={cn('text-xl font-medium text-slate-500 group-focus:text-slate-900')}>
{'Email'}
</p>
<p className="mt-2 text-xs text-slate-500">Email</p>
</CardContent>
</Card>
</button>
<button className="group h-full w-full">
<Card className="group-focus:border-primary h-full w-full cursor-pointer">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p className={cn('text-xl font-medium text-slate-500 group-focus:text-slate-900')}>
{'Name'}
</p>
<p className="mt-2 text-xs text-slate-500">Name</p>
</CardContent>
</Card>
</button>
<button className="group h-full w-full">
<Card className="group-focus:border-primary h-full w-full cursor-pointer">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p className={cn('text-xl font-medium text-slate-500 group-focus:text-slate-900')}>
{'Date'}
</p>
<p className="mt-2 text-xs text-slate-500">Date</p>
</CardContent>
</Card>
</button>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,125 @@
'use client';
import { AnimatePresence, motion } from 'framer-motion';
import { Plus, Trash } from 'lucide-react';
import { Control, Controller, FieldErrors, useFieldArray } from 'react-hook-form';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { FormErrorMessage } from '~/components/form/form-error-message';
import { TEditDocumentFormSchema } from './types';
export type AddSignersFormProps = {
className?: string;
control: Control<TEditDocumentFormSchema>;
errors: FieldErrors<TEditDocumentFormSchema>;
isSubmitting: boolean;
};
export const AddSignersFormPartial = ({
className,
control,
errors,
isSubmitting,
}: AddSignersFormProps) => {
const {
append,
fields: signers,
remove,
} = useFieldArray({
control,
name: 'signers',
});
return (
<div className={cn('flex flex-col', className)}>
<h3 className="text-2xl font-semibold">Add Signers</h3>
<p className="mt-2 text-sm text-black/30">Add the people who will sign the document.</p>
<hr className="mb-8 mt-4" />
<div className="-mx-2 flex flex-1 flex-col overflow-y-scroll 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">
<div className="flex-1">
<Label htmlFor={`signer-${index}-email`}>Email</Label>
<Controller
control={control}
name={`signers.${index}.email`}
render={({ field }) => (
<Input
id={`signer-${index}-email`}
type="email"
className="mt-2 bg-white"
disabled={isSubmitting}
{...field}
/>
)}
/>
</div>
<div className="flex-1">
<Label htmlFor={`signer-${index}-name`}>Name</Label>
<Controller
control={control}
name={`signers.${index}.name`}
render={({ field }) => (
<Input
id={`signer-${index}-name`}
type="text"
className="mt-2 bg-white"
disabled={isSubmitting}
{...field}
/>
)}
/>
</div>
<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)}
>
<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>
<div className="mt-4">
<Button
type="button"
disabled={isSubmitting}
onClick={() =>
append({
email: '',
name: '',
})
}
>
<Plus className="-ml-1 mr-2 h-5 w-5" />
Add Signer
</Button>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,13 @@
import { z } from 'zod';
export const ZEditDocumentFormSchema = z.object({
signers: z.array(
z.object({
id: z.number().optional(),
email: z.string().min(1).email(),
name: z.string(),
}),
),
});
export type TEditDocumentFormSchema = z.infer<typeof ZEditDocumentFormSchema>;