mirror of
https://github.com/documenso/documenso.git
synced 2025-11-12 15:53:02 +10:00
On larger documents we could accidentally start trying to render fields while not all pages of the PDF have loaded due to us checking for a single page existing. This would cause an error to be thrown, hard locking those documents. This change resolves this by grabbing the highest page number from the given fields and using it for the visibility check instead.
401 lines
15 KiB
TypeScript
401 lines
15 KiB
TypeScript
import { useState } from 'react';
|
|
|
|
import { msg } from '@lingui/core/macro';
|
|
import { useLingui } from '@lingui/react';
|
|
import { Trans } from '@lingui/react/macro';
|
|
import { DocumentStatus, FieldType, SigningStatus } from '@prisma/client';
|
|
import { Loader, LucideChevronDown, LucideChevronUp, X } from 'lucide-react';
|
|
import { P, match } from 'ts-pattern';
|
|
|
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
import { trpc } from '@documenso/trpc/react';
|
|
import type {
|
|
TRemovedSignedFieldWithTokenMutationSchema,
|
|
TSignFieldWithTokenMutationSchema,
|
|
} from '@documenso/trpc/server/field-router/schema';
|
|
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
|
import { cn } from '@documenso/ui/lib/utils';
|
|
import { Button } from '@documenso/ui/primitives/button';
|
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
|
import { Input } from '@documenso/ui/primitives/input';
|
|
import { Label } from '@documenso/ui/primitives/label';
|
|
import PDFViewer from '@documenso/ui/primitives/pdf-viewer';
|
|
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
|
|
import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider';
|
|
import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog';
|
|
import { EmbedDocumentFields } from '../embed-document-fields';
|
|
|
|
interface MultiSignDocumentSigningViewProps {
|
|
token: string;
|
|
recipientId: number;
|
|
onBack: () => void;
|
|
onDocumentCompleted?: (data: { token: string; documentId: number; recipientId: number }) => void;
|
|
onDocumentRejected?: (data: {
|
|
token: string;
|
|
documentId: number;
|
|
recipientId: number;
|
|
reason: string;
|
|
}) => void;
|
|
onDocumentError?: () => void;
|
|
onDocumentReady?: () => void;
|
|
isNameLocked?: boolean;
|
|
allowDocumentRejection?: boolean;
|
|
}
|
|
|
|
export const MultiSignDocumentSigningView = ({
|
|
token,
|
|
recipientId,
|
|
onBack,
|
|
onDocumentCompleted,
|
|
onDocumentRejected,
|
|
onDocumentError,
|
|
onDocumentReady,
|
|
isNameLocked = false,
|
|
allowDocumentRejection = false,
|
|
}: MultiSignDocumentSigningViewProps) => {
|
|
const { _ } = useLingui();
|
|
const { toast } = useToast();
|
|
|
|
const { fullName, email, signature, setFullName, setSignature } =
|
|
useRequiredDocumentSigningContext();
|
|
|
|
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
|
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
|
|
|
const { data: document, isLoading } = trpc.embeddingPresign.getMultiSignDocument.useQuery(
|
|
{ token },
|
|
{
|
|
staleTime: 0,
|
|
},
|
|
);
|
|
|
|
const { mutateAsync: signFieldWithToken } = trpc.field.signFieldWithToken.useMutation();
|
|
const { mutateAsync: removeSignedFieldWithToken } =
|
|
trpc.field.removeSignedFieldWithToken.useMutation();
|
|
|
|
const { mutateAsync: completeDocumentWithToken } =
|
|
trpc.recipient.completeDocumentWithToken.useMutation();
|
|
|
|
const hasSignatureField = document?.fields.some((field) => field.type === FieldType.SIGNATURE);
|
|
|
|
const [pendingFields, completedFields] = [
|
|
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ??
|
|
[],
|
|
document?.fields.filter((field) => field.recipient.signingStatus === SigningStatus.SIGNED) ??
|
|
[],
|
|
];
|
|
|
|
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
|
|
|
const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? [];
|
|
|
|
const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => {
|
|
try {
|
|
await signFieldWithToken(payload);
|
|
} catch (err) {
|
|
const error = AppError.parseError(err);
|
|
|
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
|
throw error;
|
|
}
|
|
|
|
console.error(err);
|
|
|
|
toast({
|
|
title: _(msg`Error`),
|
|
description: _(msg`An error occurred while signing the document.`),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const onUnsignField = async (payload: TRemovedSignedFieldWithTokenMutationSchema) => {
|
|
try {
|
|
await removeSignedFieldWithToken(payload);
|
|
} catch (err) {
|
|
const error = AppError.parseError(err);
|
|
|
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
|
throw error;
|
|
}
|
|
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const onDocumentComplete = async () => {
|
|
try {
|
|
setIsSubmitting(true);
|
|
|
|
await completeDocumentWithToken({
|
|
documentId: document!.id,
|
|
token,
|
|
});
|
|
|
|
onBack();
|
|
|
|
onDocumentCompleted?.({
|
|
token,
|
|
documentId: document!.id,
|
|
recipientId,
|
|
});
|
|
} catch (err) {
|
|
onDocumentError?.();
|
|
|
|
toast({
|
|
title: 'Error',
|
|
description: 'Failed to complete the document. Please try again.',
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const onNextFieldClick = () => {
|
|
setShowPendingFieldTooltip(true);
|
|
|
|
setIsExpanded(false);
|
|
};
|
|
|
|
const onRejected = (reason: string) => {
|
|
if (onDocumentRejected && document) {
|
|
onDocumentRejected({
|
|
token,
|
|
documentId: document.id,
|
|
recipientId,
|
|
reason,
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="bg-background min-h-screen overflow-hidden">
|
|
<div id="document-field-portal-root" className="relative h-full w-full overflow-y-auto p-8">
|
|
{match({ isLoading, document })
|
|
.with({ isLoading: true }, () => (
|
|
<div className="flex min-h-[400px] w-full items-center justify-center">
|
|
<div className="flex flex-col items-center gap-4">
|
|
<Loader className="text-primary h-8 w-8 animate-spin" />
|
|
<p className="text-muted-foreground text-sm">
|
|
<Trans>Loading document...</Trans>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))
|
|
.with({ isLoading: false, document: undefined }, () => (
|
|
<div className="flex min-h-[400px] w-full items-center justify-center">
|
|
<p className="text-muted-foreground text-sm">
|
|
<Trans>Failed to load document</Trans>
|
|
</p>
|
|
</div>
|
|
))
|
|
.with({ document: P.nonNullable }, ({ document }) => (
|
|
<>
|
|
<div className="mx-auto flex w-full max-w-screen-xl items-baseline justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<h1 className="text-2xl font-semibold">{document.title}</h1>
|
|
</div>
|
|
|
|
<Button variant="ghost" size="sm" onClick={onBack} className="p-2">
|
|
<X className="h-5 w-5" />
|
|
</Button>
|
|
</div>
|
|
|
|
{allowDocumentRejection && (
|
|
<div className="embed--Actions mb-4 mt-8 flex w-full flex-row-reverse items-baseline justify-between">
|
|
<DocumentSigningRejectDialog
|
|
document={document}
|
|
token={token}
|
|
onRejected={onRejected}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="embed--DocumentContainer relative mx-auto mt-8 flex w-full max-w-screen-xl flex-col gap-x-6 gap-y-12 md:flex-row">
|
|
<div
|
|
className={cn('embed--DocumentViewer flex-1', {
|
|
'md:mx-auto md:max-w-2xl': document.status === DocumentStatus.COMPLETED,
|
|
})}
|
|
>
|
|
<PDFViewer
|
|
documentData={document.documentData}
|
|
onDocumentLoad={() => {
|
|
setHasDocumentLoaded(true);
|
|
onDocumentReady?.();
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Widget */}
|
|
{document.status !== DocumentStatus.COMPLETED && (
|
|
<div
|
|
key={isExpanded ? 'expanded' : 'collapsed'}
|
|
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:bottom-[unset] md:top-0 md:z-auto md:w-[350px] md:px-0"
|
|
data-expanded={isExpanded || undefined}
|
|
>
|
|
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
|
{/* Header */}
|
|
<div className="embed--DocumentWidgetHeader">
|
|
<div className="flex items-center justify-between gap-x-2">
|
|
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
|
|
<Trans>Sign document</Trans>
|
|
</h3>
|
|
|
|
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
|
{isExpanded ? (
|
|
<LucideChevronDown
|
|
className="text-muted-foreground h-5 w-5"
|
|
onClick={() => setIsExpanded(false)}
|
|
/>
|
|
) : (
|
|
<LucideChevronUp
|
|
className="text-muted-foreground h-5 w-5"
|
|
onClick={() => setIsExpanded(true)}
|
|
/>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="embed--DocumentWidgetContent hidden group-data-[expanded]/document-widget:block md:block">
|
|
<p className="text-muted-foreground mt-2 text-sm">
|
|
<Trans>Sign the document to complete the process.</Trans>
|
|
</p>
|
|
|
|
<hr className="border-border mb-8 mt-4" />
|
|
</div>
|
|
|
|
{/* Form */}
|
|
<div className="embed--DocumentWidgetForm -mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
|
|
<div className="flex flex-1 flex-col gap-y-4">
|
|
{
|
|
<>
|
|
<div>
|
|
<Label htmlFor="full-name">
|
|
<Trans>Full Name</Trans>
|
|
</Label>
|
|
|
|
<Input
|
|
type="text"
|
|
id="full-name"
|
|
className="bg-background mt-2"
|
|
disabled={isNameLocked}
|
|
value={fullName}
|
|
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="email">
|
|
<Trans>Email</Trans>
|
|
</Label>
|
|
|
|
<Input
|
|
type="email"
|
|
id="email"
|
|
className="bg-background mt-2"
|
|
value={email}
|
|
disabled
|
|
/>
|
|
</div>
|
|
|
|
{hasSignatureField && (
|
|
<div>
|
|
<Label htmlFor="Signature">
|
|
<Trans>Signature</Trans>
|
|
</Label>
|
|
|
|
<SignaturePadDialog
|
|
className="mt-2"
|
|
disabled={isSubmitting}
|
|
disableAnimation
|
|
value={signature ?? ''}
|
|
onChange={(v) => setSignature(v ?? '')}
|
|
typedSignatureEnabled={
|
|
document.documentMeta?.typedSignatureEnabled
|
|
}
|
|
uploadSignatureEnabled={
|
|
document.documentMeta?.uploadSignatureEnabled
|
|
}
|
|
drawSignatureEnabled={
|
|
document.documentMeta?.drawSignatureEnabled
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
|
|
|
|
<div className="embed--DocumentWidgetFooter mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
|
|
{uninsertedFields.length > 0 ? (
|
|
<Button className="col-start-2" onClick={onNextFieldClick}>
|
|
<Trans>Next</Trans>
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
className="col-span-2"
|
|
loading={isSubmitting}
|
|
onClick={onDocumentComplete}
|
|
>
|
|
<Trans>Complete</Trans>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{hasDocumentLoaded && (
|
|
<ElementVisible
|
|
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
|
>
|
|
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
|
<FieldToolTip
|
|
key={pendingFields[0].id}
|
|
field={pendingFields[0]}
|
|
color="warning"
|
|
>
|
|
<Trans>Click to insert field</Trans>
|
|
</FieldToolTip>
|
|
)}
|
|
</ElementVisible>
|
|
)}
|
|
|
|
{/* Fields */}
|
|
{hasDocumentLoaded && (
|
|
<EmbedDocumentFields
|
|
fields={pendingFields}
|
|
metadata={document.documentMeta}
|
|
onSignField={onSignField}
|
|
onUnsignField={onUnsignField}
|
|
/>
|
|
)}
|
|
|
|
{/* Completed fields */}
|
|
{document.status !== DocumentStatus.COMPLETED && (
|
|
<DocumentReadOnlyFields
|
|
documentMeta={document.documentMeta ?? undefined}
|
|
fields={completedFields}
|
|
/>
|
|
)}
|
|
</>
|
|
))
|
|
.otherwise(() => null)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|