feat: update document flow

- Fixed z-index when dragging pre-existing fields
- Refactored document flow
- Added button spinner
- Added animation for document flow slider
- Updated drag and drop fields
- Updated document flow so it adjusts to the height of the PDF
- Updated claim plan dialog
This commit is contained in:
David Nguyen
2023-08-24 18:21:35 +10:00
committed by Mythie
parent 039cd11c49
commit 215eaebc1a
15 changed files with 460 additions and 319 deletions

View File

@ -5,7 +5,7 @@ import React, { useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Info, Loader } from 'lucide-react';
import { Info } from 'lucide-react';
import { usePlausible } from 'next-plausible';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@ -85,7 +85,7 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog open={open} onOpenChange={(value) => !isSubmitting && setOpen(value)}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
@ -97,50 +97,49 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
</DialogDescription>
</DialogHeader>
<form
className={cn('flex flex-col gap-y-4', className)}
onSubmit={handleSubmit(onFormSubmit)}
>
{params?.get('cancelled') === 'true' && (
<div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<Info className="h-5 w-5 text-yellow-400" />
</div>
<div className="ml-3">
<p className="text-sm leading-5 text-yellow-700">
You have cancelled the payment process. If you didn't mean to do this, please
try again.
</p>
<form onSubmit={handleSubmit(onFormSubmit)}>
<fieldset disabled={isSubmitting} className={cn('flex flex-col gap-y-4', className)}>
{params?.get('cancelled') === 'true' && (
<div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<Info className="h-5 w-5 text-yellow-400" />
</div>
<div className="ml-3">
<p className="text-sm leading-5 text-yellow-700">
You have cancelled the payment process. If you didn't mean to do this, please
try again.
</p>
</div>
</div>
</div>
)}
<div>
<Label className="text-slate-500">Name</Label>
<Input type="text" className="mt-2" {...register('name')} autoFocus />
<FormErrorMessage className="mt-1" error={errors.name} />
</div>
)}
<div>
<Label className="text-slate-500">Name</Label>
<div>
<Label className="text-slate-500">Email</Label>
<Input type="text" className="mt-2" {...register('name')} autoFocus />
<Input type="email" className="mt-2" {...register('email')} />
<FormErrorMessage className="mt-1" error={errors.name} />
</div>
<FormErrorMessage className="mt-1" error={errors.email} />
</div>
<div>
<Label className="text-slate-500">Email</Label>
<Input type="email" className="mt-2" {...register('email')} />
<FormErrorMessage className="mt-1" error={errors.email} />
</div>
<Button type="submit" size="lg" disabled={isSubmitting}>
{isSubmitting && <Loader className="mr-2 h-4 w-4 animate-spin" />}
Claim the Community Plan ({/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
{planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
? 'Monthly'
: 'Yearly'}
)
</Button>
<Button type="submit" size="lg" loading={isSubmitting}>
Claim the Community Plan (
{/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
{planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
? 'Monthly'
: 'Yearly'}
)
</Button>
</fieldset>
</form>
</DialogContent>
</Dialog>

View File

@ -13,6 +13,11 @@ import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/ad
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
import {
DocumentFlowFormContainer,
DocumentFlowFormContainerHeader,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { useToast } from '@documenso/ui/primitives/use-toast';
@ -28,6 +33,8 @@ export type EditDocumentFormProps = {
fields: Field[];
};
type EditDocumentStep = 'signers' | 'fields' | 'subject';
export const EditDocumentForm = ({
className,
document,
@ -38,29 +45,34 @@ export const EditDocumentForm = ({
const { toast } = useToast();
const router = useRouter();
const [step, setStep] = useState<'signers' | 'fields' | 'subject'>('signers');
const [step, setStep] = useState<EditDocumentStep>('signers');
const documentUrl = `data:application/pdf;base64,${document.document}`;
const onNextStep = () => {
if (step === 'signers') {
setStep('fields');
}
if (step === 'fields') {
setStep('subject');
}
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
signers: {
title: 'Add Signers',
description: 'Add the people who will sign the document.',
stepIndex: 1,
onSubmit: () => onAddSignersFormSubmit,
},
fields: {
title: 'Add Fields',
description: 'Add all relevant fields for each recipient.',
stepIndex: 2,
onBackStep: () => setStep('signers'),
onSubmit: () => onAddFieldsFormSubmit,
},
subject: {
title: 'Add Subject',
description: 'Add the subject and message you wish to send to signers.',
stepIndex: 3,
onBackStep: () => setStep('fields'),
onSubmit: () => onAddSubjectFormSubmit,
},
};
const onPreviousStep = () => {
if (step === 'fields') {
setStep('signers');
}
if (step === 'subject') {
setStep('fields');
}
};
const currentDocumentFlow = documentFlow[step];
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
try {
@ -72,7 +84,7 @@ export const EditDocumentForm = ({
router.refresh();
onNextStep();
setStep('fields');
} catch (err) {
console.error(err);
@ -94,7 +106,7 @@ export const EditDocumentForm = ({
router.refresh();
onNextStep();
setStep('subject');
} catch (err) {
console.error(err);
@ -119,8 +131,6 @@ export const EditDocumentForm = ({
});
router.refresh();
onNextStep();
} catch (err) {
console.error(err);
@ -144,38 +154,43 @@ export const EditDocumentForm = ({
</Card>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
{step === 'signers' && (
<AddSignersFormPartial
recipients={recipients}
fields={fields}
document={document}
onContinue={onNextStep}
onGoBack={onPreviousStep}
onSubmit={onAddSignersFormSubmit}
<DocumentFlowFormContainer onSubmit={(e) => e.preventDefault()}>
<DocumentFlowFormContainerHeader
title={currentDocumentFlow.title}
description={currentDocumentFlow.description}
/>
)}
{step === 'fields' && (
<AddFieldsFormPartial
recipients={recipients}
fields={fields}
document={document}
onContinue={onNextStep}
onGoBack={onPreviousStep}
onSubmit={onAddFieldsFormSubmit}
/>
)}
{step === 'signers' && (
<AddSignersFormPartial
documentFlow={documentFlow.signers}
recipients={recipients}
fields={fields}
numberOfSteps={Object.keys(documentFlow).length}
onSubmit={onAddSignersFormSubmit}
/>
)}
{step === 'subject' && (
<AddSubjectFormPartial
recipients={recipients}
fields={fields}
document={document}
onContinue={onNextStep}
onGoBack={onPreviousStep}
onSubmit={onAddSubjectFormSubmit}
/>
)}
{step === 'fields' && (
<AddFieldsFormPartial
documentFlow={documentFlow.fields}
recipients={recipients}
fields={fields}
numberOfSteps={Object.keys(documentFlow).length}
onSubmit={onAddFieldsFormSubmit}
/>
)}
{step === 'subject' && (
<AddSubjectFormPartial
documentFlow={documentFlow.subject}
document={document}
recipients={recipients}
fields={fields}
numberOfSteps={Object.keys(documentFlow).length}
onSubmit={onAddSubjectFormSubmit}
/>
)}
</DocumentFlowFormContainer>
</div>
</div>
);

View File

@ -13,9 +13,9 @@ export default function Loading() {
<h1 className="mt-4 max-w-xs grow-0 truncate text-2xl font-semibold md:text-3xl">
Loading Document...
</h1>
<div className="mt-8 grid min-h-[80vh] w-full grid-cols-12 gap-x-8">
<div className="mt-8 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
<div className="dark:bg-background border-border col-span-12 rounded-xl border-2 bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7">
<div className="flex min-h-[80vh] flex-col items-center justify-center">
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
<Loader className="text-documenso h-12 w-12 animate-spin" />
<p className="text-muted-foreground mt-4">Loading document...</p>

View File

@ -50,7 +50,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
return (
<form
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',
'dark:bg-background border-border bg-widget sticky top-20 flex h-full max-h-[80rem] flex-col rounded-xl border px-4 py-6',
)}
onSubmit={handleSubmit(onFormSubmit)}
>

View File

@ -0,0 +1,93 @@
'use client';
import { useCallback } from 'react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
export const useDocumentElement = () => {
/**
* Given a mouse event, find the nearest element found by the provided selector.
*/
const getPage = (event: MouseEvent, pageSelector: string) => {
if (!(event.target instanceof HTMLElement)) {
return null;
}
const target = event.target;
const $page =
target.closest<HTMLElement>(pageSelector) ?? target.querySelector<HTMLElement>(pageSelector);
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 element found by the provided selector.
*
* @param mouseWidth The artifical width of the mouse.
* @param mouseHeight The artifical height of the mouse.
*/
const isWithinPageBounds = useCallback(
(event: MouseEvent, pageSelector: string, mouseWidth = 0, mouseHeight = 0) => {
const $page = getPage(event, pageSelector);
if (!$page) {
return false;
}
const { top, left, height, width } = $page.getBoundingClientRect();
const halfMouseWidth = mouseWidth / 2;
const halfMouseHeight = mouseHeight / 2;
if (event.clientY > top + height - halfMouseHeight || event.clientY < top + halfMouseHeight) {
return false;
}
if (event.clientX > left + width - halfMouseWidth || event.clientX < left + halfMouseWidth) {
return false;
}
return true;
},
[],
);
return {
getPage,
getFieldPosition,
isWithinPageBounds,
};
};

View File

@ -2,6 +2,7 @@ import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { VariantProps, cva } from 'class-variance-authority';
import { Loader } from 'lucide-react';
import { cn } from '../lib/utils';
@ -30,17 +31,51 @@ const buttonVariants = cva(
},
);
const loaderVariants = cva('mr-2 animate-spin', {
variants: {
size: {
default: 'h-5 w-5',
sm: 'h-4 w-4',
lg: 'h-5 w-5',
},
},
defaultVariants: {
size: 'default',
},
});
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
/**
* Will display the loading spinner and disable the button.
*/
loading?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
if (asChild) {
return (
<Slot className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
}
const showLoader = props.loading === true;
const isDisabled = props.disabled || showLoader;
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
disabled={isDisabled}
>
{showLoader && <Loader className={cn('mr-2 animate-spin', loaderVariants({ size }))} />}
{props.children}
</button>
);
},
);

View File

@ -9,8 +9,9 @@ import { nanoid } from 'nanoid';
import { useFieldArray, useForm } from 'react-hook-form';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { Document, Field, FieldType, Recipient, SendStatus } from '@documenso/prisma/client';
import { 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';
@ -26,14 +27,13 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
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';
import { DocumentFlowStep, FRIENDLY_FIELD_TYPE } from './types';
const fontCaveat = Caveat({
weight: ['500'],
@ -49,20 +49,24 @@ const MIN_HEIGHT_PX = 60;
const MIN_WIDTH_PX = 200;
export type AddFieldsFormProps = {
documentFlow: DocumentFlowStep;
hideRecipients?: boolean;
recipients: Recipient[];
fields: Field[];
document: Document;
onContinue?: () => void;
onGoBack?: () => void;
numberOfSteps: number;
onSubmit: (_data: TAddFieldsFormSchema) => void;
};
export const AddFieldsFormPartial = ({
documentFlow,
hideRecipients = false,
recipients,
fields,
onGoBack,
numberOfSteps,
onSubmit,
}: AddFieldsFormProps) => {
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
const {
control,
handleSubmit,
@ -99,7 +103,7 @@ export const AddFieldsFormPartial = ({
const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT;
const [visible, setVisible] = useState(false);
const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
const [coords, setCoords] = useState({
x: 0,
y: 0,
@ -110,86 +114,17 @@ export const AddFieldsFormPartial = ({
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;
}
setIsFieldWithinBounds(
isWithinPageBounds(
event,
PDF_VIEWER_PAGE_SELECTOR,
fieldBounds.current.width,
fieldBounds.current.height,
),
);
setVisible(true);
setCoords({
x: event.clientX - fieldBounds.current.width / 2,
y: event.clientY - fieldBounds.current.height / 2,
@ -204,9 +139,18 @@ export const AddFieldsFormPartial = ({
return;
}
const $page = getPage(event);
const $page = getPage(event, PDF_VIEWER_PAGE_SELECTOR);
if (!$page || !isWithinPageBounds(event)) {
if (
!$page ||
!isWithinPageBounds(
event,
PDF_VIEWER_PAGE_SELECTOR,
fieldBounds.current.width,
fieldBounds.current.height,
)
) {
setSelectedField(null);
return;
}
@ -237,10 +181,10 @@ export const AddFieldsFormPartial = ({
signerEmail: selectedSigner.email,
});
setVisible(false);
setIsFieldWithinBounds(false);
setSelectedField(null);
},
[append, isWithinPageBounds, selectedField, selectedSigner],
[append, isWithinPageBounds, selectedField, selectedSigner, getPage],
);
const onFieldResize = useCallback(
@ -270,7 +214,7 @@ export const AddFieldsFormPartial = ({
pageHeight,
});
},
[localFields, update],
[getFieldPosition, localFields, update],
);
const onFieldMove = useCallback(
@ -293,7 +237,7 @@ export const AddFieldsFormPartial = ({
pageY,
});
},
[localFields, update],
[getFieldPosition, localFields, update],
);
useEffect(() => {
@ -328,15 +272,18 @@ export const AddFieldsFormPartial = ({
}, [recipients]);
return (
<DocumentFlowFormContainer>
<DocumentFlowFormContainerContent
title="Add Fields"
description="Add all relevant fields for each recipient."
>
<>
<DocumentFlowFormContainerContent>
<div className="flex flex-col">
{selectedField && visible && (
{selectedField && (
<Card
className="border-primary pointer-events-none fixed z-50 cursor-pointer bg-white"
className={cn(
'pointer-events-none fixed z-50 cursor-pointer bg-white transition-opacity',
{
'border-primary': isFieldWithinBounds,
'opacity-50': !isFieldWithinBounds,
},
)}
style={{
top: coords.y,
left: coords.x,
@ -357,94 +304,100 @@ export const AddFieldsFormPartial = ({
disabled={selectedSigner?.email !== field.signerEmail || hasSelectedSignerBeenSent}
minHeight={fieldBounds.current.height}
minWidth={fieldBounds.current.width}
passive={visible && !!selectedField}
passive={isFieldWithinBounds && !!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>
)}
{!hideRecipients && (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
className="bg-background text-muted-foreground mb-12 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>
)}
{!selectedSigner?.email && (
<span className="flex-1 truncate text-left">{selectedSigner?.email}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
<ChevronsUpDown className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput />
<CommandEmpty />
<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>
)}
<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.name} (${recipient.email})`}
>
{recipient.name} ({recipient.email})
</span>
)}
{!recipient.name && (
<span className="truncate" title={recipient.email}>
{recipient.email}
</span>
)}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
{!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">
<div className="-mx-2 flex-1 overflow-y-scroll px-2">
<div className="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)}
onClick={(e) => e.stopPropagation()}
onMouseDown={() => 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">
@ -467,7 +420,8 @@ export const AddFieldsFormPartial = ({
type="button"
className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={() => setSelectedField(FieldType.EMAIL)}
onClick={(e) => e.stopPropagation()}
onMouseDown={() => 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">
@ -489,7 +443,8 @@ export const AddFieldsFormPartial = ({
type="button"
className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={() => setSelectedField(FieldType.NAME)}
onClick={(e) => e.stopPropagation()}
onMouseDown={() => 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">
@ -511,7 +466,8 @@ export const AddFieldsFormPartial = ({
type="button"
className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={() => setSelectedField(FieldType.DATE)}
onClick={(e) => e.stopPropagation()}
onMouseDown={() => 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">
@ -534,15 +490,19 @@ export const AddFieldsFormPartial = ({
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep title="Add Fields" step={2} maxStep={3} />
<DocumentFlowFormContainerStep
title={documentFlow.title}
step={documentFlow.stepIndex}
maxStep={numberOfSteps}
/>
<DocumentFlowFormContainerActions
loading={isSubmitting}
disabled={isSubmitting}
onGoBackClick={documentFlow.onBackStep}
onGoNextClick={() => handleSubmit(onSubmit)()}
onGoBackClick={onGoBack}
/>
</DocumentFlowFormContainerFooter>
</DocumentFlowFormContainer>
</>
);
};

View File

@ -2,40 +2,41 @@
import React, { useId } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
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 { 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 { TAddSignersFormSchema, ZAddSignersFormSchema } from './add-signers.types';
import {
DocumentFlowFormContainer,
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { DocumentFlowStep } from './types';
export type AddSignersFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
fields: Field[];
document: Document;
onContinue?: () => void;
onGoBack?: () => void;
numberOfSteps: number;
onSubmit: (_data: TAddSignersFormSchema) => void;
};
export const AddSignersFormPartial = ({
documentFlow,
numberOfSteps,
recipients,
fields: _fields,
onGoBack,
onSubmit,
}: AddSignersFormProps) => {
const { toast } = useToast();
@ -47,6 +48,7 @@ export const AddSignersFormPartial = ({
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TAddSignersFormSchema>({
resolver: zodResolver(ZAddSignersFormSchema),
defaultValues: {
signers:
recipients.length > 0
@ -116,11 +118,8 @@ export const AddSignersFormPartial = ({
};
return (
<DocumentFlowFormContainer onSubmit={handleSubmit(onSubmit)}>
<DocumentFlowFormContainerContent
title="Add Signers"
description="Add the people who will sign the document."
>
<>
<DocumentFlowFormContainerContent>
<div className="flex w-full flex-col gap-y-4">
<AnimatePresence>
{signers.map((signer, index) => (
@ -205,15 +204,19 @@ export const AddSignersFormPartial = ({
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep title="Add Signers" step={1} maxStep={3} />
<DocumentFlowFormContainerStep
title={documentFlow.title}
step={documentFlow.stepIndex}
maxStep={numberOfSteps}
/>
<DocumentFlowFormContainerActions
loading={isSubmitting}
disabled={isSubmitting}
onGoBackClick={documentFlow.onBackStep}
onGoNextClick={() => handleSubmit(onSubmit)()}
onGoBackClick={onGoBack}
/>
</DocumentFlowFormContainerFooter>
</DocumentFlowFormContainer>
</>
);
};

View File

@ -3,35 +3,35 @@
import { useForm } from 'react-hook-form';
import { Document, DocumentStatus, Field, Recipient } from '@documenso/prisma/client';
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 { 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';
import { DocumentFlowStep } from './types';
export type AddSubjectFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
fields: Field[];
document: Document;
onContinue?: () => void;
onGoBack?: () => void;
numberOfSteps: number;
onSubmit: (_data: TAddSubjectFormSchema) => void;
};
export const AddSubjectFormPartial = ({
documentFlow,
recipients: _recipients,
fields: _fields,
document,
onGoBack,
numberOfSteps,
onSubmit,
}: AddSubjectFormProps) => {
const {
@ -48,11 +48,8 @@ export const AddSubjectFormPartial = ({
});
return (
<DocumentFlowFormContainer>
<DocumentFlowFormContainerContent
title="Add Subject"
description="Add the subject and message you wish to send to signers."
>
<>
<DocumentFlowFormContainerContent>
<div className="flex flex-col">
<div className="flex flex-col gap-y-4">
<div>
@ -122,16 +119,20 @@ export const AddSubjectFormPartial = ({
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep title="Add Subject" step={3} maxStep={3} />
<DocumentFlowFormContainerStep
title={documentFlow.title}
step={documentFlow.stepIndex}
maxStep={numberOfSteps}
/>
<DocumentFlowFormContainerActions
loading={isSubmitting}
disabled={isSubmitting}
goNextLabel={document.status === DocumentStatus.DRAFT ? 'Send' : 'Update'}
onGoBackClick={documentFlow.onBackStep}
onGoNextClick={() => handleSubmit(onSubmit)()}
onGoBackClick={onGoBack}
/>
</DocumentFlowFormContainerFooter>
</DocumentFlowFormContainer>
</>
);
};

View File

@ -2,7 +2,7 @@
import React, { HTMLAttributes } from 'react';
import { Loader } from 'lucide-react';
import { motion } from 'framer-motion';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -21,7 +21,7 @@ export const DocumentFlowFormContainer = ({
<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',
'dark:bg-background border-border bg-widget sticky top-20 flex h-full max-h-[80rem] flex-col rounded-xl border px-4 py-6',
className,
)}
{...props}
@ -31,27 +31,37 @@ export const DocumentFlowFormContainer = ({
);
};
export type DocumentFlowFormContainerContentProps = HTMLAttributes<HTMLDivElement> & {
export type DocumentFlowFormContainerHeaderProps = {
title: string;
description: string;
children?: React.ReactNode;
};
export const DocumentFlowFormContainerContent = ({
children,
export const DocumentFlowFormContainerHeader = ({
title,
description,
className,
...props
}: DocumentFlowFormContainerContentProps) => {
}: DocumentFlowFormContainerHeaderProps) => {
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" />
</>
);
};
export type DocumentFlowFormContainerContentProps = HTMLAttributes<HTMLDivElement> & {
children?: React.ReactNode;
};
export const DocumentFlowFormContainerContent = ({
children,
className,
...props
}: DocumentFlowFormContainerContentProps) => {
return (
<div className={cn('flex flex-1 flex-col', className)} {...props}>
<div className="-mx-2 flex flex-1 flex-col overflow-y-auto px-2">{children}</div>
</div>
);
@ -94,7 +104,9 @@ export const DocumentFlowFormContainerStep = ({
</p>
<div className="bg-muted relative mt-4 h-[2px] rounded-md">
<div
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="bg-documenso absolute inset-y-0 left-0"
style={{
width: `${(100 / maxStep) * step}%`,
@ -133,20 +145,19 @@ export const DocumentFlowFormContainerActions = ({
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}
disabled={disabled || loading || !canGoBack || !onGoBackClick}
onClick={onGoBackClick}
>
{goBackLabel}
</Button>
<Button
type="button"
type="submit"
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

@ -91,9 +91,10 @@ export const FieldItem = ({
return createPortal(
<Rnd
key={coords.pageX + coords.pageY + coords.pageHeight + coords.pageWidth}
className={cn('absolute z-20', {
className={cn(' z-20', {
'pointer-events-none': passive,
'pointer-events-none z-10 opacity-75': disabled,
'pointer-events-none opacity-75': disabled,
'z-10': !active || disabled,
})}
// minHeight={minHeight}
// minWidth={minWidth}
@ -117,7 +118,7 @@ export const FieldItem = ({
>
{!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%)]"
className="text-muted-foreground/50 hover:text-muted-foreground/80 absolute -right-2 -top-2 z-20 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" />

View File

@ -47,3 +47,12 @@ export const FRIENDLY_FIELD_TYPE: Record<FieldType, string> = {
[FieldType.EMAIL]: 'Email',
[FieldType.NAME]: 'Name',
};
export interface DocumentFlowStep {
title: string;
description: string;
stepIndex: number;
onSubmit?: () => void;
onBackStep?: () => void;
onNextStep?: () => void;
}

View File

@ -7,7 +7,7 @@ import { Loader } from 'lucide-react';
export const LazyPDFViewer = dynamic(async () => import('./pdf-viewer'), {
ssr: false,
loading: () => (
<div className="dark:bg-background flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
<div className="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50">
<Loader className="text-documenso h-12 w-12 animate-spin" />
<p className="text-muted-foreground mt-4">Loading document...</p>

View File

@ -104,11 +104,13 @@ export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFVie
<div ref={$el} className={cn('overflow-hidden', className)} {...props}>
<PDFDocument
file={document}
className="w-full overflow-hidden rounded"
className={cn('w-full overflow-hidden rounded', {
'h-[80vh] max-h-[60rem]': numPages === 0,
})}
onLoadSuccess={(d) => onDocumentLoaded(d)}
externalLinkTarget="_blank"
loading={
<div className="dark:bg-background flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
<div className="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50">
<Loader className="text-documenso h-12 w-12 animate-spin" />
<p className="text-muted-foreground mt-4">Loading document...</p>

View File

@ -0,0 +1,12 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const baseConfig = require('@documenso/tailwind-config');
module.exports = {
...baseConfig,
content: [
...baseConfig.content,
'./primitives/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./lib/**/*.{ts,tsx}',
],
};