mirror of
https://github.com/documenso/documenso.git
synced 2026-07-24 00:43:40 +10:00
ba0dead96f
Currently direct templates can be created without the required signatures fields for signers. This means that the document can be fully signed by everyone but will ultimately fail the sealing step which leaves the document in an unrecoverable state.
521 lines
21 KiB
TypeScript
521 lines
21 KiB
TypeScript
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
|
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
|
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
|
import {
|
|
FIELD_META_DEFAULT_VALUES,
|
|
type TCheckboxFieldMeta,
|
|
type TDateFieldMeta,
|
|
type TDropdownFieldMeta,
|
|
type TEmailFieldMeta,
|
|
type TFieldMetaSchema,
|
|
type TInitialsFieldMeta,
|
|
type TNameFieldMeta,
|
|
type TNumberFieldMeta,
|
|
type TRadioFieldMeta,
|
|
type TSignatureFieldMeta,
|
|
type TTextFieldMeta,
|
|
} from '@documenso/lib/types/field-meta';
|
|
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
|
|
import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap';
|
|
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
|
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
|
import { cn } from '@documenso/ui/lib/utils';
|
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
|
import { Button } from '@documenso/ui/primitives/button';
|
|
import { Separator } from '@documenso/ui/primitives/separator';
|
|
import type { MessageDescriptor } from '@lingui/core';
|
|
import { msg } from '@lingui/core/macro';
|
|
import { useLingui } from '@lingui/react';
|
|
import { Trans } from '@lingui/react/macro';
|
|
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
|
import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useRevalidator, useSearchParams } from 'react-router';
|
|
import { isDeepEqual } from 'remeda';
|
|
import { match } from 'ts-pattern';
|
|
|
|
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
|
|
import { AiFieldDetectionDialog } from '~/components/dialogs/ai-field-detection-dialog';
|
|
import { EnvelopeItemEditDialog } from '~/components/dialogs/envelope-item-edit-dialog';
|
|
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
|
|
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
|
|
import { EditorFieldDropdownForm } from '~/components/forms/editor/editor-field-dropdown-form';
|
|
import { EditorFieldEmailForm } from '~/components/forms/editor/editor-field-email-form';
|
|
import { EditorFieldInitialsForm } from '~/components/forms/editor/editor-field-initials-form';
|
|
import { EditorFieldNameForm } from '~/components/forms/editor/editor-field-name-form';
|
|
import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-number-form';
|
|
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
|
|
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
|
|
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
|
|
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
|
|
import { useCurrentTeam } from '~/providers/team';
|
|
|
|
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
|
|
import { EnvelopeEditorFieldsPageRenderer } from './envelope-editor-fields-page-renderer';
|
|
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
|
|
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
|
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
|
|
|
|
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
|
[FieldType.SIGNATURE]: msg`Signature Settings`,
|
|
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
|
|
[FieldType.TEXT]: msg`Text Settings`,
|
|
[FieldType.DATE]: msg`Date Settings`,
|
|
[FieldType.EMAIL]: msg`Email Settings`,
|
|
[FieldType.NAME]: msg`Name Settings`,
|
|
[FieldType.INITIALS]: msg`Initials Settings`,
|
|
[FieldType.NUMBER]: msg`Number Settings`,
|
|
[FieldType.RADIO]: msg`Radio Settings`,
|
|
[FieldType.CHECKBOX]: msg`Checkbox Settings`,
|
|
[FieldType.DROPDOWN]: msg`Dropdown Settings`,
|
|
};
|
|
|
|
export const EnvelopeEditorFieldsPage = () => {
|
|
const [searchParams] = useSearchParams();
|
|
|
|
const team = useCurrentTeam();
|
|
|
|
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
|
|
|
|
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
|
|
|
const { _ } = useLingui();
|
|
|
|
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
|
|
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
|
|
const { revalidate } = useRevalidator();
|
|
|
|
const envelopeItemPermissions = useMemo(
|
|
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
|
|
[envelope, envelope.recipients],
|
|
);
|
|
|
|
const selectedField = useMemo(() => structuredClone(editorFields.selectedField), [editorFields.selectedField]);
|
|
|
|
/**
|
|
* Debounce the fields used for overlap detection so we don't recompute on every
|
|
* small drag/resize movement, which is expensive on large field counts and can
|
|
* bog down lower-end devices.
|
|
*/
|
|
const debouncedLocalFields = useDebouncedValue(editorFields.localFields, 300);
|
|
|
|
/**
|
|
* Fields that significantly overlap each other. Overlapping fields render poorly in
|
|
* the editor and can behave unexpectedly during signing, so we warn the author here.
|
|
*/
|
|
const overlappingFieldPairs = useMemo(
|
|
() =>
|
|
getOverlappingFieldPairs(
|
|
debouncedLocalFields.map((field) => ({
|
|
id: field.formId,
|
|
envelopeItemId: field.envelopeItemId,
|
|
page: field.page,
|
|
positionX: field.positionX,
|
|
positionY: field.positionY,
|
|
width: field.width,
|
|
height: field.height,
|
|
})),
|
|
),
|
|
[debouncedLocalFields],
|
|
);
|
|
|
|
const handleReviewOverlappingField = () => {
|
|
const firstPair = overlappingFieldPairs[0];
|
|
|
|
if (!firstPair) {
|
|
return;
|
|
}
|
|
|
|
const targetField = editorFields.localFields.find((field) => field.formId === firstPair.fieldA.id);
|
|
|
|
if (!targetField) {
|
|
return;
|
|
}
|
|
|
|
if (targetField.envelopeItemId !== currentEnvelopeItem?.id) {
|
|
setCurrentEnvelopeItem(targetField.envelopeItemId);
|
|
}
|
|
|
|
editorFields.setSelectedField(targetField.formId);
|
|
};
|
|
|
|
const updateSelectedFieldMeta = (fieldMeta: TFieldMetaSchema) => {
|
|
if (!selectedField) {
|
|
return;
|
|
}
|
|
|
|
const isMetaSame = isDeepEqual(selectedField.fieldMeta, fieldMeta);
|
|
|
|
if (!isMetaSame) {
|
|
editorFields.updateFieldByFormId(selectedField.formId, {
|
|
fieldMeta,
|
|
});
|
|
}
|
|
};
|
|
|
|
const onFieldDetectionComplete = (fields: NormalizedFieldWithContext[]) => {
|
|
for (const field of fields) {
|
|
editorFields.addField({
|
|
height: field.height,
|
|
width: field.width,
|
|
positionX: field.positionX,
|
|
positionY: field.positionY,
|
|
type: field.type,
|
|
envelopeItemId: field.envelopeItemId,
|
|
recipientId: field.recipientId,
|
|
page: field.pageNumber,
|
|
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[field.type]),
|
|
});
|
|
}
|
|
|
|
setIsAiFieldDialogOpen(false);
|
|
};
|
|
|
|
/**
|
|
* Set the selected recipient to the first recipient in the envelope.
|
|
*/
|
|
useEffect(() => {
|
|
const firstSelectableRecipient = envelope.recipients.find(
|
|
(recipient) => recipient.role === RecipientRole.SIGNER || recipient.role === RecipientRole.APPROVER,
|
|
);
|
|
|
|
editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null);
|
|
}, []);
|
|
|
|
const onDetectClick = () => {
|
|
if (!team.preferences.aiFeaturesEnabled) {
|
|
setIsAiEnableDialogOpen(true);
|
|
return;
|
|
}
|
|
|
|
setIsAiFieldDialogOpen(true);
|
|
};
|
|
|
|
const onAiFeaturesEnabled = () => {
|
|
void revalidate().then(() => {
|
|
setIsAiEnableDialogOpen(false);
|
|
setIsAiFieldDialogOpen(true);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="relative flex h-full">
|
|
<div className="flex h-full w-full flex-col overflow-y-auto px-2" ref={scrollableContainerRef}>
|
|
{/* Horizontal envelope item selector */}
|
|
<EnvelopeRendererFileSelector
|
|
className="px-0"
|
|
fields={editorFields.localFields}
|
|
renderItemAction={
|
|
editorConfig.envelopeItems !== null &&
|
|
editorConfig.envelopeItems.allowReplace &&
|
|
envelopeItemPermissions.canFileBeChanged
|
|
? (item) => (
|
|
<div className="relative flex h-5 w-5 flex-shrink-0 items-center justify-center">
|
|
<div
|
|
className={cn('h-2 w-2 rounded-full transition-opacity duration-150 group-hover:opacity-0', {
|
|
'bg-green-500': currentEnvelopeItem?.id === item.id,
|
|
})}
|
|
/>
|
|
<EnvelopeItemEditDialog
|
|
envelopeItem={item}
|
|
allowConfigureTitle={editorConfig.envelopeItems?.allowConfigureTitle ?? false}
|
|
trigger={
|
|
<span
|
|
className="absolute inset-0 flex cursor-pointer items-center justify-center opacity-0 transition-opacity duration-150 group-hover:opacity-100"
|
|
onClick={(e) => e.stopPropagation()}
|
|
data-testid={`envelope-item-edit-button-${item.id}`}
|
|
>
|
|
<PencilIcon className="h-3.5 w-3.5" />
|
|
</span>
|
|
}
|
|
/>
|
|
</div>
|
|
)
|
|
: undefined
|
|
}
|
|
/>
|
|
|
|
<EnvelopeEditorInvalidDirectTemplateAlert />
|
|
|
|
{/* Document View */}
|
|
<div className="mt-4 flex h-full flex-col items-center justify-center">
|
|
{envelope.recipients.length === 0 && (
|
|
<Alert
|
|
variant="neutral"
|
|
className="mb-4 flex max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm border border-border bg-background"
|
|
>
|
|
<div className="flex flex-col gap-1">
|
|
<AlertTitle>
|
|
<Trans>Missing Recipients</Trans>
|
|
</AlertTitle>
|
|
<AlertDescription>
|
|
<Trans>You need at least one recipient to add fields</Trans>
|
|
</AlertDescription>
|
|
</div>
|
|
|
|
<Button variant="outline" onClick={() => void navigateToStep('upload')}>
|
|
<Trans>Add Recipients</Trans>
|
|
</Button>
|
|
</Alert>
|
|
)}
|
|
|
|
{overlappingFieldPairs.length > 0 && (
|
|
<Alert
|
|
variant="warning"
|
|
className="mt-20 mb-4 flex w-full max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm"
|
|
>
|
|
<div className="flex flex-row items-start gap-3">
|
|
<AlertTriangleIcon className="mt-0.5 h-5 w-5 flex-shrink-0" />
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<AlertTitle>
|
|
<Trans>Overlapping fields detected</Trans>
|
|
</AlertTitle>
|
|
<AlertDescription>
|
|
<Trans>
|
|
Some fields are placed on top of each other. This may complicate the signing process or cause
|
|
fields to not work as expected.
|
|
</Trans>
|
|
</AlertDescription>
|
|
</div>
|
|
</div>
|
|
</Alert>
|
|
)}
|
|
|
|
{currentEnvelopeItem !== null ? (
|
|
<EnvelopePdfViewer
|
|
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
|
|
scrollParentRef={scrollableContainerRef}
|
|
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
|
|
/>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center py-32">
|
|
<FileTextIcon className="h-10 w-10 text-muted-foreground" />
|
|
<p className="mt-1 text-foreground text-sm">
|
|
<Trans>No documents found</Trans>
|
|
</p>
|
|
<p className="mt-1 text-muted-foreground text-sm">
|
|
<Trans>Please upload a document to continue</Trans>
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Section - Form Fields Panel */}
|
|
{currentEnvelopeItem && envelope.recipients.length > 0 && (
|
|
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-border border-l bg-background py-4">
|
|
{/* Recipient selector section. */}
|
|
<section className="px-4">
|
|
<h3 className="mb-2 font-semibold text-foreground text-sm">
|
|
<Trans>Selected Recipient</Trans>
|
|
</h3>
|
|
|
|
<EnvelopeRecipientSelector
|
|
selectedRecipient={editorFields.selectedRecipient}
|
|
onSelectedRecipientChange={(recipient) => editorFields.setSelectedRecipient(recipient.id)}
|
|
recipients={envelope.recipients}
|
|
fields={envelope.fields}
|
|
className="w-full"
|
|
align="end"
|
|
/>
|
|
|
|
{editorFields.selectedRecipient &&
|
|
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
|
|
<Alert className="mt-4" variant="warning">
|
|
<AlertDescription>
|
|
<Trans>
|
|
This recipient can no longer be modified as they have signed a field, or completed the document.
|
|
</Trans>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</section>
|
|
|
|
<Separator className="my-4" />
|
|
|
|
{/* Add fields section. */}
|
|
<section className="px-4">
|
|
<h3 className="mb-2 font-semibold text-foreground text-sm">
|
|
<Trans>Add Fields</Trans>
|
|
</h3>
|
|
|
|
<EnvelopeEditorFieldDragDrop
|
|
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
|
|
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
|
|
/>
|
|
|
|
{editorConfig.fields?.allowAIDetection && (
|
|
<>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="mt-4 w-full"
|
|
onClick={onDetectClick}
|
|
disabled={envelope.status !== DocumentStatus.DRAFT}
|
|
title={
|
|
envelope.status !== DocumentStatus.DRAFT
|
|
? _(msg`You can only detect fields in draft envelopes`)
|
|
: undefined
|
|
}
|
|
>
|
|
<SparklesIcon className="mr-2 -ml-1 h-4 w-4" />
|
|
<Trans>Detect with AI</Trans>
|
|
</Button>
|
|
|
|
<AiFieldDetectionDialog
|
|
open={isAiFieldDialogOpen}
|
|
onOpenChange={setIsAiFieldDialogOpen}
|
|
onComplete={onFieldDetectionComplete}
|
|
envelopeId={envelope.id}
|
|
teamId={envelope.teamId}
|
|
/>
|
|
|
|
<AiFeaturesEnableDialog
|
|
open={isAiEnableDialogOpen}
|
|
onOpenChange={setIsAiEnableDialogOpen}
|
|
onEnabled={onAiFeaturesEnabled}
|
|
/>
|
|
</>
|
|
)}
|
|
</section>
|
|
|
|
{/* Field details section. */}
|
|
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
|
|
{selectedField && (
|
|
<section>
|
|
<Separator className="my-4" />
|
|
|
|
{searchParams.get('devmode') && (
|
|
<>
|
|
<div className="px-4">
|
|
<h3 className="mb-3 font-semibold text-foreground text-sm">
|
|
<Trans>Developer Mode</Trans>
|
|
</h3>
|
|
|
|
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-foreground text-sm">
|
|
{selectedField.id && (
|
|
<p>
|
|
<span className="min-w-12 text-muted-foreground">
|
|
<Trans>Field ID:</Trans>
|
|
</span>{' '}
|
|
{selectedField.id}
|
|
</p>
|
|
)}
|
|
<p>
|
|
<span className="min-w-12 text-muted-foreground">
|
|
<Trans>Recipient ID:</Trans>
|
|
</span>{' '}
|
|
{selectedField.recipientId}
|
|
</p>
|
|
<p>
|
|
<span className="min-w-12 text-muted-foreground">
|
|
<Trans>Pos X:</Trans>
|
|
</span>{' '}
|
|
{selectedField.positionX.toFixed(2)}
|
|
</p>
|
|
<p>
|
|
<span className="min-w-12 text-muted-foreground">
|
|
<Trans>Pos Y:</Trans>
|
|
</span>{' '}
|
|
{selectedField.positionY.toFixed(2)}
|
|
</p>
|
|
<p>
|
|
<span className="min-w-12 text-muted-foreground">
|
|
<Trans>Width:</Trans>
|
|
</span>{' '}
|
|
{selectedField.width.toFixed(2)}
|
|
</p>
|
|
<p>
|
|
<span className="min-w-12 text-muted-foreground">
|
|
<Trans>Height:</Trans>
|
|
</span>{' '}
|
|
{selectedField.height.toFixed(2)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator className="my-4" />
|
|
</>
|
|
)}
|
|
|
|
<div className="px-4 [&_label]:text-foreground/70 [&_label]:text-xs">
|
|
<h3 className="font-semibold text-sm">{_(FieldSettingsTypeTranslations[selectedField.type])}</h3>
|
|
|
|
{match(selectedField.type)
|
|
.with(FieldType.SIGNATURE, () => (
|
|
<EditorFieldSignatureForm
|
|
value={selectedField?.fieldMeta as TSignatureFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.CHECKBOX, () => (
|
|
<EditorFieldCheckboxForm
|
|
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.DATE, () => (
|
|
<EditorFieldDateForm
|
|
value={selectedField?.fieldMeta as TDateFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.DROPDOWN, () => (
|
|
<EditorFieldDropdownForm
|
|
value={selectedField?.fieldMeta as TDropdownFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.EMAIL, () => (
|
|
<EditorFieldEmailForm
|
|
value={selectedField?.fieldMeta as TEmailFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.INITIALS, () => (
|
|
<EditorFieldInitialsForm
|
|
value={selectedField?.fieldMeta as TInitialsFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.NAME, () => (
|
|
<EditorFieldNameForm
|
|
value={selectedField?.fieldMeta as TNameFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.NUMBER, () => (
|
|
<EditorFieldNumberForm
|
|
value={selectedField?.fieldMeta as TNumberFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.RADIO, () => (
|
|
<EditorFieldRadioForm
|
|
value={selectedField?.fieldMeta as TRadioFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.TEXT, () => (
|
|
<EditorFieldTextForm
|
|
value={selectedField?.fieldMeta as TTextFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.otherwise(() => null)}
|
|
</div>
|
|
</section>
|
|
)}
|
|
</AnimateGenericFadeInOut>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|