mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 20:42:34 +10:00
Merge pull request #300 from documenso/feat/update-document-flow
feat: update document flow
This commit is contained in:
@ -5,7 +5,7 @@ import React, { useState } from 'react';
|
|||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Info, Loader } from 'lucide-react';
|
import { Info } from 'lucide-react';
|
||||||
import { usePlausible } from 'next-plausible';
|
import { usePlausible } from 'next-plausible';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@ -85,7 +85,7 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={(value) => !isSubmitting && setOpen(value)}>
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@ -97,50 +97,49 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
className={cn('flex flex-col gap-y-4', className)}
|
<fieldset disabled={isSubmitting} 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">
|
||||||
{params?.get('cancelled') === 'true' && (
|
<div className="flex">
|
||||||
<div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4">
|
<div className="flex-shrink-0">
|
||||||
<div className="flex">
|
<Info className="h-5 w-5 text-yellow-400" />
|
||||||
<div className="flex-shrink-0">
|
</div>
|
||||||
<Info className="h-5 w-5 text-yellow-400" />
|
<div className="ml-3">
|
||||||
</div>
|
<p className="text-sm leading-5 text-yellow-700">
|
||||||
<div className="ml-3">
|
You have cancelled the payment process. If you didn't mean to do this, please
|
||||||
<p className="text-sm leading-5 text-yellow-700">
|
try again.
|
||||||
You have cancelled the payment process. If you didn't mean to do this, please
|
</p>
|
||||||
try again.
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
</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>
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-slate-500">Name</Label>
|
<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} />
|
<FormErrorMessage className="mt-1" error={errors.email} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<Button type="submit" size="lg" loading={isSubmitting}>
|
||||||
<Label className="text-slate-500">Email</Label>
|
Claim the Community Plan (
|
||||||
|
{/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
|
||||||
<Input type="email" className="mt-2" {...register('email')} />
|
{planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
|
||||||
|
? 'Monthly'
|
||||||
<FormErrorMessage className="mt-1" error={errors.email} />
|
: 'Yearly'}
|
||||||
</div>
|
)
|
||||||
|
</Button>
|
||||||
<Button type="submit" size="lg" disabled={isSubmitting}>
|
</fieldset>
|
||||||
{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>
|
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@ -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 { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||||
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
|
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
|
||||||
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
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 { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@ -28,6 +33,8 @@ export type EditDocumentFormProps = {
|
|||||||
fields: Field[];
|
fields: Field[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type EditDocumentStep = 'signers' | 'fields' | 'subject';
|
||||||
|
|
||||||
export const EditDocumentForm = ({
|
export const EditDocumentForm = ({
|
||||||
className,
|
className,
|
||||||
document,
|
document,
|
||||||
@ -38,29 +45,34 @@ export const EditDocumentForm = ({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
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 documentUrl = `data:application/pdf;base64,${document.document}`;
|
||||||
|
|
||||||
const onNextStep = () => {
|
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
||||||
if (step === 'signers') {
|
signers: {
|
||||||
setStep('fields');
|
title: 'Add Signers',
|
||||||
}
|
description: 'Add the people who will sign the document.',
|
||||||
|
stepIndex: 1,
|
||||||
if (step === 'fields') {
|
onSubmit: () => onAddSignersFormSubmit,
|
||||||
setStep('subject');
|
},
|
||||||
}
|
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 = () => {
|
const currentDocumentFlow = documentFlow[step];
|
||||||
if (step === 'fields') {
|
|
||||||
setStep('signers');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (step === 'subject') {
|
|
||||||
setStep('fields');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
||||||
try {
|
try {
|
||||||
@ -72,7 +84,7 @@ export const EditDocumentForm = ({
|
|||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
||||||
onNextStep();
|
setStep('fields');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
@ -94,7 +106,7 @@ export const EditDocumentForm = ({
|
|||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
||||||
onNextStep();
|
setStep('subject');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
@ -119,8 +131,6 @@ export const EditDocumentForm = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
||||||
onNextStep();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
@ -144,38 +154,43 @@ export const EditDocumentForm = ({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
{step === 'signers' && (
|
<DocumentFlowFormContainer onSubmit={(e) => e.preventDefault()}>
|
||||||
<AddSignersFormPartial
|
<DocumentFlowFormContainerHeader
|
||||||
recipients={recipients}
|
title={currentDocumentFlow.title}
|
||||||
fields={fields}
|
description={currentDocumentFlow.description}
|
||||||
document={document}
|
|
||||||
onContinue={onNextStep}
|
|
||||||
onGoBack={onPreviousStep}
|
|
||||||
onSubmit={onAddSignersFormSubmit}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'fields' && (
|
{step === 'signers' && (
|
||||||
<AddFieldsFormPartial
|
<AddSignersFormPartial
|
||||||
recipients={recipients}
|
documentFlow={documentFlow.signers}
|
||||||
fields={fields}
|
recipients={recipients}
|
||||||
document={document}
|
fields={fields}
|
||||||
onContinue={onNextStep}
|
numberOfSteps={Object.keys(documentFlow).length}
|
||||||
onGoBack={onPreviousStep}
|
onSubmit={onAddSignersFormSubmit}
|
||||||
onSubmit={onAddFieldsFormSubmit}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'subject' && (
|
{step === 'fields' && (
|
||||||
<AddSubjectFormPartial
|
<AddFieldsFormPartial
|
||||||
recipients={recipients}
|
documentFlow={documentFlow.fields}
|
||||||
fields={fields}
|
recipients={recipients}
|
||||||
document={document}
|
fields={fields}
|
||||||
onContinue={onNextStep}
|
numberOfSteps={Object.keys(documentFlow).length}
|
||||||
onGoBack={onPreviousStep}
|
onSubmit={onAddFieldsFormSubmit}
|
||||||
onSubmit={onAddSubjectFormSubmit}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
|
||||||
|
{step === 'subject' && (
|
||||||
|
<AddSubjectFormPartial
|
||||||
|
documentFlow={documentFlow.subject}
|
||||||
|
document={document}
|
||||||
|
recipients={recipients}
|
||||||
|
fields={fields}
|
||||||
|
numberOfSteps={Object.keys(documentFlow).length}
|
||||||
|
onSubmit={onAddSubjectFormSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DocumentFlowFormContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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">
|
<h1 className="mt-4 max-w-xs grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||||
Loading Document...
|
Loading Document...
|
||||||
</h1>
|
</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="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" />
|
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4">Loading document...</p>
|
<p className="text-muted-foreground mt-4">Loading document...</p>
|
||||||
|
|||||||
@ -50,7 +50,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className={cn(
|
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)}
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
>
|
>
|
||||||
|
|||||||
93
packages/lib/client-only/hooks/use-document-element.ts
Normal file
93
packages/lib/client-only/hooks/use-document-element.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -2,6 +2,7 @@ import * as React from 'react';
|
|||||||
|
|
||||||
import { Slot } from '@radix-ui/react-slot';
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
import { VariantProps, cva } from 'class-variance-authority';
|
import { VariantProps, cva } from 'class-variance-authority';
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '../lib/utils';
|
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
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
VariantProps<typeof buttonVariants> {
|
VariantProps<typeof buttonVariants> {
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Will display the loading spinner and disable the button.
|
||||||
|
*/
|
||||||
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
({ 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 (
|
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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -9,8 +9,9 @@ import { nanoid } from 'nanoid';
|
|||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
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 { 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 { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
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 { TAddFieldsFormSchema } from './add-fields.types';
|
||||||
import {
|
import {
|
||||||
DocumentFlowFormContainer,
|
|
||||||
DocumentFlowFormContainerActions,
|
DocumentFlowFormContainerActions,
|
||||||
DocumentFlowFormContainerContent,
|
DocumentFlowFormContainerContent,
|
||||||
DocumentFlowFormContainerFooter,
|
DocumentFlowFormContainerFooter,
|
||||||
DocumentFlowFormContainerStep,
|
DocumentFlowFormContainerStep,
|
||||||
} from './document-flow-root';
|
} from './document-flow-root';
|
||||||
import { FieldItem } from './field-item';
|
import { FieldItem } from './field-item';
|
||||||
import { FRIENDLY_FIELD_TYPE } from './types';
|
import { DocumentFlowStep, FRIENDLY_FIELD_TYPE } from './types';
|
||||||
|
|
||||||
const fontCaveat = Caveat({
|
const fontCaveat = Caveat({
|
||||||
weight: ['500'],
|
weight: ['500'],
|
||||||
@ -49,20 +49,24 @@ const MIN_HEIGHT_PX = 60;
|
|||||||
const MIN_WIDTH_PX = 200;
|
const MIN_WIDTH_PX = 200;
|
||||||
|
|
||||||
export type AddFieldsFormProps = {
|
export type AddFieldsFormProps = {
|
||||||
|
documentFlow: DocumentFlowStep;
|
||||||
|
hideRecipients?: boolean;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
document: Document;
|
numberOfSteps: number;
|
||||||
onContinue?: () => void;
|
|
||||||
onGoBack?: () => void;
|
|
||||||
onSubmit: (_data: TAddFieldsFormSchema) => void;
|
onSubmit: (_data: TAddFieldsFormSchema) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddFieldsFormPartial = ({
|
export const AddFieldsFormPartial = ({
|
||||||
|
documentFlow,
|
||||||
|
hideRecipients = false,
|
||||||
recipients,
|
recipients,
|
||||||
fields,
|
fields,
|
||||||
onGoBack,
|
numberOfSteps,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: AddFieldsFormProps) => {
|
}: AddFieldsFormProps) => {
|
||||||
|
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@ -99,7 +103,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
|
|
||||||
const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT;
|
const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT;
|
||||||
|
|
||||||
const [visible, setVisible] = useState(false);
|
const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
|
||||||
const [coords, setCoords] = useState({
|
const [coords, setCoords] = useState({
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
@ -110,86 +114,17 @@ export const AddFieldsFormPartial = ({
|
|||||||
width: 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(
|
const onMouseMove = useCallback(
|
||||||
(event: MouseEvent) => {
|
(event: MouseEvent) => {
|
||||||
if (!isWithinPageBounds(event)) {
|
setIsFieldWithinBounds(
|
||||||
setVisible(false);
|
isWithinPageBounds(
|
||||||
return;
|
event,
|
||||||
}
|
PDF_VIEWER_PAGE_SELECTOR,
|
||||||
|
fieldBounds.current.width,
|
||||||
|
fieldBounds.current.height,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
setVisible(true);
|
|
||||||
setCoords({
|
setCoords({
|
||||||
x: event.clientX - fieldBounds.current.width / 2,
|
x: event.clientX - fieldBounds.current.width / 2,
|
||||||
y: event.clientY - fieldBounds.current.height / 2,
|
y: event.clientY - fieldBounds.current.height / 2,
|
||||||
@ -204,9 +139,18 @@ export const AddFieldsFormPartial = ({
|
|||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,10 +181,10 @@ export const AddFieldsFormPartial = ({
|
|||||||
signerEmail: selectedSigner.email,
|
signerEmail: selectedSigner.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
setVisible(false);
|
setIsFieldWithinBounds(false);
|
||||||
setSelectedField(null);
|
setSelectedField(null);
|
||||||
},
|
},
|
||||||
[append, isWithinPageBounds, selectedField, selectedSigner],
|
[append, isWithinPageBounds, selectedField, selectedSigner, getPage],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onFieldResize = useCallback(
|
const onFieldResize = useCallback(
|
||||||
@ -270,7 +214,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
pageHeight,
|
pageHeight,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[localFields, update],
|
[getFieldPosition, localFields, update],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onFieldMove = useCallback(
|
const onFieldMove = useCallback(
|
||||||
@ -293,7 +237,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
pageY,
|
pageY,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[localFields, update],
|
[getFieldPosition, localFields, update],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -328,15 +272,18 @@ export const AddFieldsFormPartial = ({
|
|||||||
}, [recipients]);
|
}, [recipients]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentFlowFormContainer>
|
<>
|
||||||
<DocumentFlowFormContainerContent
|
<DocumentFlowFormContainerContent>
|
||||||
title="Add Fields"
|
|
||||||
description="Add all relevant fields for each recipient."
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{selectedField && visible && (
|
{selectedField && (
|
||||||
<Card
|
<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={{
|
style={{
|
||||||
top: coords.y,
|
top: coords.y,
|
||||||
left: coords.x,
|
left: coords.x,
|
||||||
@ -357,94 +304,100 @@ export const AddFieldsFormPartial = ({
|
|||||||
disabled={selectedSigner?.email !== field.signerEmail || hasSelectedSignerBeenSent}
|
disabled={selectedSigner?.email !== field.signerEmail || hasSelectedSignerBeenSent}
|
||||||
minHeight={fieldBounds.current.height}
|
minHeight={fieldBounds.current.height}
|
||||||
minWidth={fieldBounds.current.width}
|
minWidth={fieldBounds.current.width}
|
||||||
passive={visible && !!selectedField}
|
passive={isFieldWithinBounds && !!selectedField}
|
||||||
onResize={(options) => onFieldResize(options, index)}
|
onResize={(options) => onFieldResize(options, index)}
|
||||||
onMove={(options) => onFieldMove(options, index)}
|
onMove={(options) => onFieldMove(options, index)}
|
||||||
onRemove={() => remove(index)}
|
onRemove={() => remove(index)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Popover>
|
{!hideRecipients && (
|
||||||
<PopoverTrigger asChild>
|
<Popover>
|
||||||
<Button
|
<PopoverTrigger asChild>
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
role="combobox"
|
variant="outline"
|
||||||
className="bg-background text-muted-foreground justify-between font-normal"
|
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} ({selectedSigner?.email})
|
<span className="flex-1 truncate text-left">
|
||||||
</span>
|
{selectedSigner?.email} ({selectedSigner?.email})
|
||||||
)}
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{!selectedSigner?.email && (
|
{!selectedSigner?.email && (
|
||||||
<span className="flex-1 truncate text-left">{selectedSigner?.email}</span>
|
<span className="flex-1 truncate text-left">{selectedSigner?.email}</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
||||||
<PopoverContent className="p-0" align="start">
|
<PopoverContent className="p-0" align="start">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput />
|
<CommandInput />
|
||||||
<CommandEmpty />
|
<CommandEmpty />
|
||||||
|
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{recipients.map((recipient, index) => (
|
{recipients.map((recipient, index) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={index}
|
key={index}
|
||||||
className={cn({
|
className={cn({
|
||||||
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
|
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
|
||||||
})}
|
})}
|
||||||
onSelect={() => setSelectedSigner(recipient)}
|
onSelect={() => setSelectedSigner(recipient)}
|
||||||
>
|
>
|
||||||
{recipient.sendStatus !== SendStatus.SENT ? (
|
{recipient.sendStatus !== SendStatus.SENT ? (
|
||||||
<Check
|
<Check
|
||||||
aria-hidden={recipient !== selectedSigner}
|
aria-hidden={recipient !== selectedSigner}
|
||||||
className={cn('mr-2 h-4 w-4 flex-shrink-0', {
|
className={cn('mr-2 h-4 w-4 flex-shrink-0', {
|
||||||
'opacity-0': recipient !== selectedSigner,
|
'opacity-0': recipient !== selectedSigner,
|
||||||
'opacity-100': recipient === selectedSigner,
|
'opacity-100': recipient === selectedSigner,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<Info className="mr-2 h-4 w-4" />
|
<Info className="mr-2 h-4 w-4" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="max-w-xs">
|
<TooltipContent className="max-w-xs">
|
||||||
This document has already been sent to this recipient. You can no longer
|
This document has already been sent to this recipient. You can no
|
||||||
edit this recipient.
|
longer edit this recipient.
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recipient.name && (
|
{recipient.name && (
|
||||||
<span className="truncate" title={`${recipient.name} (${recipient.email})`}>
|
<span
|
||||||
{recipient.name} ({recipient.email})
|
className="truncate"
|
||||||
</span>
|
title={`${recipient.name} (${recipient.email})`}
|
||||||
)}
|
>
|
||||||
|
{recipient.name} ({recipient.email})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{!recipient.name && (
|
{!recipient.name && (
|
||||||
<span className="truncate" title={recipient.email}>
|
<span className="truncate" title={recipient.email}>
|
||||||
{recipient.email}
|
{recipient.email}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="-mx-2 mt-8 flex-1 overflow-y-scroll px-2">
|
<div className="-mx-2 flex-1 overflow-y-scroll px-2">
|
||||||
<div className="mt-4 grid grid-cols-2 gap-x-4 gap-y-8">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-8">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="group h-full w-full"
|
className="group h-full w-full"
|
||||||
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
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}
|
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
|
||||||
>
|
>
|
||||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
<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"
|
type="button"
|
||||||
className="group h-full w-full"
|
className="group h-full w-full"
|
||||||
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
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}
|
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
|
||||||
>
|
>
|
||||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
<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"
|
type="button"
|
||||||
className="group h-full w-full"
|
className="group h-full w-full"
|
||||||
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
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}
|
data-selected={selectedField === FieldType.NAME ? true : undefined}
|
||||||
>
|
>
|
||||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
<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"
|
type="button"
|
||||||
className="group h-full w-full"
|
className="group h-full w-full"
|
||||||
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
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}
|
data-selected={selectedField === FieldType.DATE ? true : undefined}
|
||||||
>
|
>
|
||||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
<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>
|
</DocumentFlowFormContainerContent>
|
||||||
|
|
||||||
<DocumentFlowFormContainerFooter>
|
<DocumentFlowFormContainerFooter>
|
||||||
<DocumentFlowFormContainerStep title="Add Fields" step={2} maxStep={3} />
|
<DocumentFlowFormContainerStep
|
||||||
|
title={documentFlow.title}
|
||||||
|
step={documentFlow.stepIndex}
|
||||||
|
maxStep={numberOfSteps}
|
||||||
|
/>
|
||||||
|
|
||||||
<DocumentFlowFormContainerActions
|
<DocumentFlowFormContainerActions
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
onGoBackClick={documentFlow.onBackStep}
|
||||||
onGoNextClick={() => handleSubmit(onSubmit)()}
|
onGoNextClick={() => handleSubmit(onSubmit)()}
|
||||||
onGoBackClick={onGoBack}
|
|
||||||
/>
|
/>
|
||||||
</DocumentFlowFormContainerFooter>
|
</DocumentFlowFormContainerFooter>
|
||||||
</DocumentFlowFormContainer>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,40 +2,41 @@
|
|||||||
|
|
||||||
import React, { useId } from 'react';
|
import React, { useId } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Plus, Trash } from 'lucide-react';
|
import { Plus, Trash } from 'lucide-react';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
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 { Button } from '@documenso/ui/primitives/button';
|
||||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { TAddSignersFormSchema } from './add-signers.types';
|
import { TAddSignersFormSchema, ZAddSignersFormSchema } from './add-signers.types';
|
||||||
import {
|
import {
|
||||||
DocumentFlowFormContainer,
|
|
||||||
DocumentFlowFormContainerActions,
|
DocumentFlowFormContainerActions,
|
||||||
DocumentFlowFormContainerContent,
|
DocumentFlowFormContainerContent,
|
||||||
DocumentFlowFormContainerFooter,
|
DocumentFlowFormContainerFooter,
|
||||||
DocumentFlowFormContainerStep,
|
DocumentFlowFormContainerStep,
|
||||||
} from './document-flow-root';
|
} from './document-flow-root';
|
||||||
|
import { DocumentFlowStep } from './types';
|
||||||
|
|
||||||
export type AddSignersFormProps = {
|
export type AddSignersFormProps = {
|
||||||
|
documentFlow: DocumentFlowStep;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
document: Document;
|
numberOfSteps: number;
|
||||||
onContinue?: () => void;
|
|
||||||
onGoBack?: () => void;
|
|
||||||
onSubmit: (_data: TAddSignersFormSchema) => void;
|
onSubmit: (_data: TAddSignersFormSchema) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddSignersFormPartial = ({
|
export const AddSignersFormPartial = ({
|
||||||
|
documentFlow,
|
||||||
|
numberOfSteps,
|
||||||
recipients,
|
recipients,
|
||||||
fields: _fields,
|
fields: _fields,
|
||||||
onGoBack,
|
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: AddSignersFormProps) => {
|
}: AddSignersFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@ -47,6 +48,7 @@ export const AddSignersFormPartial = ({
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<TAddSignersFormSchema>({
|
} = useForm<TAddSignersFormSchema>({
|
||||||
|
resolver: zodResolver(ZAddSignersFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
signers:
|
signers:
|
||||||
recipients.length > 0
|
recipients.length > 0
|
||||||
@ -116,11 +118,8 @@ export const AddSignersFormPartial = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentFlowFormContainer onSubmit={handleSubmit(onSubmit)}>
|
<>
|
||||||
<DocumentFlowFormContainerContent
|
<DocumentFlowFormContainerContent>
|
||||||
title="Add Signers"
|
|
||||||
description="Add the people who will sign the document."
|
|
||||||
>
|
|
||||||
<div className="flex w-full flex-col gap-y-4">
|
<div className="flex w-full flex-col gap-y-4">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{signers.map((signer, index) => (
|
{signers.map((signer, index) => (
|
||||||
@ -205,15 +204,19 @@ export const AddSignersFormPartial = ({
|
|||||||
</DocumentFlowFormContainerContent>
|
</DocumentFlowFormContainerContent>
|
||||||
|
|
||||||
<DocumentFlowFormContainerFooter>
|
<DocumentFlowFormContainerFooter>
|
||||||
<DocumentFlowFormContainerStep title="Add Signers" step={1} maxStep={3} />
|
<DocumentFlowFormContainerStep
|
||||||
|
title={documentFlow.title}
|
||||||
|
step={documentFlow.stepIndex}
|
||||||
|
maxStep={numberOfSteps}
|
||||||
|
/>
|
||||||
|
|
||||||
<DocumentFlowFormContainerActions
|
<DocumentFlowFormContainerActions
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
onGoBackClick={documentFlow.onBackStep}
|
||||||
onGoNextClick={() => handleSubmit(onSubmit)()}
|
onGoNextClick={() => handleSubmit(onSubmit)()}
|
||||||
onGoBackClick={onGoBack}
|
|
||||||
/>
|
/>
|
||||||
</DocumentFlowFormContainerFooter>
|
</DocumentFlowFormContainerFooter>
|
||||||
</DocumentFlowFormContainer>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,35 +3,35 @@
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { Document, DocumentStatus, Field, Recipient } from '@documenso/prisma/client';
|
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 { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||||
|
|
||||||
import { FormErrorMessage } from '~/components/form/form-error-message';
|
|
||||||
|
|
||||||
import { TAddSubjectFormSchema } from './add-subject.types';
|
import { TAddSubjectFormSchema } from './add-subject.types';
|
||||||
import {
|
import {
|
||||||
DocumentFlowFormContainer,
|
|
||||||
DocumentFlowFormContainerActions,
|
DocumentFlowFormContainerActions,
|
||||||
DocumentFlowFormContainerContent,
|
DocumentFlowFormContainerContent,
|
||||||
DocumentFlowFormContainerFooter,
|
DocumentFlowFormContainerFooter,
|
||||||
DocumentFlowFormContainerStep,
|
DocumentFlowFormContainerStep,
|
||||||
} from './document-flow-root';
|
} from './document-flow-root';
|
||||||
|
import { DocumentFlowStep } from './types';
|
||||||
|
|
||||||
export type AddSubjectFormProps = {
|
export type AddSubjectFormProps = {
|
||||||
|
documentFlow: DocumentFlowStep;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
document: Document;
|
document: Document;
|
||||||
onContinue?: () => void;
|
numberOfSteps: number;
|
||||||
onGoBack?: () => void;
|
|
||||||
onSubmit: (_data: TAddSubjectFormSchema) => void;
|
onSubmit: (_data: TAddSubjectFormSchema) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddSubjectFormPartial = ({
|
export const AddSubjectFormPartial = ({
|
||||||
|
documentFlow,
|
||||||
recipients: _recipients,
|
recipients: _recipients,
|
||||||
fields: _fields,
|
fields: _fields,
|
||||||
document,
|
document,
|
||||||
onGoBack,
|
numberOfSteps,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: AddSubjectFormProps) => {
|
}: AddSubjectFormProps) => {
|
||||||
const {
|
const {
|
||||||
@ -48,11 +48,8 @@ export const AddSubjectFormPartial = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentFlowFormContainer>
|
<>
|
||||||
<DocumentFlowFormContainerContent
|
<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">
|
||||||
<div className="flex flex-col gap-y-4">
|
<div className="flex flex-col gap-y-4">
|
||||||
<div>
|
<div>
|
||||||
@ -122,16 +119,20 @@ export const AddSubjectFormPartial = ({
|
|||||||
</DocumentFlowFormContainerContent>
|
</DocumentFlowFormContainerContent>
|
||||||
|
|
||||||
<DocumentFlowFormContainerFooter>
|
<DocumentFlowFormContainerFooter>
|
||||||
<DocumentFlowFormContainerStep title="Add Subject" step={3} maxStep={3} />
|
<DocumentFlowFormContainerStep
|
||||||
|
title={documentFlow.title}
|
||||||
|
step={documentFlow.stepIndex}
|
||||||
|
maxStep={numberOfSteps}
|
||||||
|
/>
|
||||||
|
|
||||||
<DocumentFlowFormContainerActions
|
<DocumentFlowFormContainerActions
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
goNextLabel={document.status === DocumentStatus.DRAFT ? 'Send' : 'Update'}
|
goNextLabel={document.status === DocumentStatus.DRAFT ? 'Send' : 'Update'}
|
||||||
|
onGoBackClick={documentFlow.onBackStep}
|
||||||
onGoNextClick={() => handleSubmit(onSubmit)()}
|
onGoNextClick={() => handleSubmit(onSubmit)()}
|
||||||
onGoBackClick={onGoBack}
|
|
||||||
/>
|
/>
|
||||||
</DocumentFlowFormContainerFooter>
|
</DocumentFlowFormContainerFooter>
|
||||||
</DocumentFlowFormContainer>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { HTMLAttributes } from 'react';
|
import React, { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -21,7 +21,7 @@ export const DocumentFlowFormContainer = ({
|
|||||||
<form
|
<form
|
||||||
id={id}
|
id={id}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -31,27 +31,37 @@ export const DocumentFlowFormContainer = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DocumentFlowFormContainerContentProps = HTMLAttributes<HTMLDivElement> & {
|
export type DocumentFlowFormContainerHeaderProps = {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
children?: React.ReactNode;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentFlowFormContainerContent = ({
|
export const DocumentFlowFormContainerHeader = ({
|
||||||
children,
|
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
className,
|
}: DocumentFlowFormContainerHeaderProps) => {
|
||||||
...props
|
|
||||||
}: DocumentFlowFormContainerContentProps) => {
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-1 flex-col', className)} {...props}>
|
<>
|
||||||
<h3 className="text-foreground text-2xl font-semibold">{title}</h3>
|
<h3 className="text-foreground text-2xl font-semibold">{title}</h3>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">{description}</p>
|
<p className="text-muted-foreground mt-2 text-sm">{description}</p>
|
||||||
|
|
||||||
<hr className="border-border mb-8 mt-4" />
|
<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 className="-mx-2 flex flex-1 flex-col overflow-y-auto px-2">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -94,7 +104,9 @@ export const DocumentFlowFormContainerStep = ({
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-muted relative mt-4 h-[2px] rounded-md">
|
<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"
|
className="bg-documenso absolute inset-y-0 left-0"
|
||||||
style={{
|
style={{
|
||||||
width: `${(100 / maxStep) * step}%`,
|
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"
|
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
disabled={disabled || loading || !canGoBack}
|
disabled={disabled || loading || !canGoBack || !onGoBackClick}
|
||||||
onClick={onGoBackClick}
|
onClick={onGoBackClick}
|
||||||
>
|
>
|
||||||
{goBackLabel}
|
{goBackLabel}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="submit"
|
||||||
className="bg-documenso flex-1"
|
className="bg-documenso flex-1"
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={disabled || loading || !canGoNext}
|
disabled={disabled || loading || !canGoNext}
|
||||||
onClick={onGoNextClick}
|
onClick={onGoNextClick}
|
||||||
>
|
>
|
||||||
{loading && <Loader className="mr-2 h-5 w-5 animate-spin" />}
|
|
||||||
{goNextLabel}
|
{goNextLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -91,9 +91,10 @@ export const FieldItem = ({
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<Rnd
|
<Rnd
|
||||||
key={coords.pageX + coords.pageY + coords.pageHeight + coords.pageWidth}
|
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': passive,
|
||||||
'pointer-events-none z-10 opacity-75': disabled,
|
'pointer-events-none opacity-75': disabled,
|
||||||
|
'z-10': !active || disabled,
|
||||||
})}
|
})}
|
||||||
// minHeight={minHeight}
|
// minHeight={minHeight}
|
||||||
// minWidth={minWidth}
|
// minWidth={minWidth}
|
||||||
@ -117,7 +118,7 @@ export const FieldItem = ({
|
|||||||
>
|
>
|
||||||
{!disabled && (
|
{!disabled && (
|
||||||
<button
|
<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?.()}
|
onClick={() => onRemove?.()}
|
||||||
>
|
>
|
||||||
<Trash className="h-4 w-4" />
|
<Trash className="h-4 w-4" />
|
||||||
|
|||||||
@ -47,3 +47,12 @@ export const FRIENDLY_FIELD_TYPE: Record<FieldType, string> = {
|
|||||||
[FieldType.EMAIL]: 'Email',
|
[FieldType.EMAIL]: 'Email',
|
||||||
[FieldType.NAME]: 'Name',
|
[FieldType.NAME]: 'Name',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface DocumentFlowStep {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
stepIndex: number;
|
||||||
|
onSubmit?: () => void;
|
||||||
|
onBackStep?: () => void;
|
||||||
|
onNextStep?: () => void;
|
||||||
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { Loader } from 'lucide-react';
|
|||||||
export const LazyPDFViewer = dynamic(async () => import('./pdf-viewer'), {
|
export const LazyPDFViewer = dynamic(async () => import('./pdf-viewer'), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
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" />
|
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4">Loading document...</p>
|
<p className="text-muted-foreground mt-4">Loading document...</p>
|
||||||
|
|||||||
@ -104,11 +104,13 @@ export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFVie
|
|||||||
<div ref={$el} className={cn('overflow-hidden', className)} {...props}>
|
<div ref={$el} className={cn('overflow-hidden', className)} {...props}>
|
||||||
<PDFDocument
|
<PDFDocument
|
||||||
file={document}
|
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)}
|
onLoadSuccess={(d) => onDocumentLoaded(d)}
|
||||||
externalLinkTarget="_blank"
|
externalLinkTarget="_blank"
|
||||||
loading={
|
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" />
|
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4">Loading document...</p>
|
<p className="text-muted-foreground mt-4">Loading document...</p>
|
||||||
|
|||||||
12
packages/ui/tailwind.config.cjs
Normal file
12
packages/ui/tailwind.config.cjs
Normal 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}',
|
||||||
|
],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user