mirror of
https://github.com/documenso/documenso.git
synced 2025-11-19 19:21:39 +10:00
feat: detect fields
This commit is contained in:
@ -4,24 +4,35 @@ import { Trans } from '@lingui/react/macro';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
Dialog,
|
||||||
AlertDialogAction,
|
DialogContent,
|
||||||
AlertDialogCancel,
|
DialogDescription,
|
||||||
AlertDialogContent,
|
DialogFooter,
|
||||||
AlertDialogDescription,
|
DialogHeader,
|
||||||
AlertDialogFooter,
|
DialogTitle,
|
||||||
AlertDialogHeader,
|
} from '@documenso/ui/primitives/dialog';
|
||||||
AlertDialogTitle,
|
|
||||||
} from '@documenso/ui/primitives/alert-dialog';
|
|
||||||
|
|
||||||
type RecipientDetectionStep = 'PROMPT' | 'PROCESSING';
|
import type { RecipientForCreation } from '~/utils/detect-document-recipients';
|
||||||
|
|
||||||
|
import { SuggestedRecipientsForm } from './suggested-recipients-form';
|
||||||
|
|
||||||
|
type RecipientDetectionStep =
|
||||||
|
| 'PROMPT_DETECT_RECIPIENTS'
|
||||||
|
| 'DETECTING_RECIPIENTS'
|
||||||
|
| 'REVIEW_RECIPIENTS'
|
||||||
|
| 'DETECTING_FIELDS';
|
||||||
|
|
||||||
export type RecipientDetectionPromptDialogProps = {
|
export type RecipientDetectionPromptDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onAccept: () => Promise<void> | void;
|
onAccept: () => Promise<void> | void;
|
||||||
onSkip: () => void;
|
onSkip: () => void;
|
||||||
|
recipients: RecipientForCreation[] | null;
|
||||||
|
onRecipientsSubmit: (recipients: RecipientForCreation[]) => Promise<void> | void;
|
||||||
|
onAutoAddFields?: (recipients: RecipientForCreation[]) => Promise<void> | void;
|
||||||
|
isProcessingRecipients?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RecipientDetectionPromptDialog = ({
|
export const RecipientDetectionPromptDialog = ({
|
||||||
@ -29,21 +40,40 @@ export const RecipientDetectionPromptDialog = ({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onAccept,
|
onAccept,
|
||||||
onSkip,
|
onSkip,
|
||||||
|
recipients,
|
||||||
|
onRecipientsSubmit,
|
||||||
|
onAutoAddFields,
|
||||||
|
isProcessingRecipients = false,
|
||||||
}: RecipientDetectionPromptDialogProps) => {
|
}: RecipientDetectionPromptDialogProps) => {
|
||||||
const [currentStep, setCurrentStep] = useState<RecipientDetectionStep>('PROMPT');
|
const [currentStep, setCurrentStep] = useState<RecipientDetectionStep>(
|
||||||
|
'PROMPT_DETECT_RECIPIENTS',
|
||||||
|
);
|
||||||
|
const [currentRecipients, setCurrentRecipients] = useState<RecipientForCreation[] | null>(
|
||||||
|
recipients,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setCurrentStep('PROMPT');
|
setCurrentStep('PROMPT_DETECT_RECIPIENTS');
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentRecipients(recipients);
|
||||||
|
}, [recipients]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (recipients && currentStep === 'DETECTING_RECIPIENTS') {
|
||||||
|
setCurrentStep('REVIEW_RECIPIENTS');
|
||||||
|
}
|
||||||
|
}, [recipients, currentStep]);
|
||||||
|
|
||||||
const handleStartDetection = (e: React.MouseEvent) => {
|
const handleStartDetection = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setCurrentStep('PROCESSING');
|
setCurrentStep('DETECTING_RECIPIENTS');
|
||||||
|
|
||||||
Promise.resolve(onAccept()).catch(() => {
|
Promise.resolve(onAccept()).catch(() => {
|
||||||
setCurrentStep('PROMPT');
|
setCurrentStep('PROMPT_DETECT_RECIPIENTS');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -51,37 +81,74 @@ export const RecipientDetectionPromptDialog = ({
|
|||||||
onSkip();
|
onSkip();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAutoAddFields = async (recipients: RecipientForCreation[]) => {
|
||||||
|
if (!onAutoAddFields) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the current state of recipients so if we fail and come back,
|
||||||
|
// the form is restored with the user's changes.
|
||||||
|
setCurrentRecipients(recipients);
|
||||||
|
setCurrentStep('DETECTING_FIELDS');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onAutoAddFields(recipients);
|
||||||
|
} catch {
|
||||||
|
setCurrentStep('REVIEW_RECIPIENTS');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
<Dialog
|
||||||
<AlertDialogContent>
|
open={open}
|
||||||
<fieldset disabled={currentStep === 'PROCESSING'}>
|
onOpenChange={(newOpen) => {
|
||||||
|
// Prevent closing during processing
|
||||||
|
if (
|
||||||
|
!newOpen &&
|
||||||
|
(currentStep === 'DETECTING_RECIPIENTS' ||
|
||||||
|
currentStep === 'DETECTING_FIELDS' ||
|
||||||
|
isProcessingRecipients)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onOpenChange(newOpen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
className={
|
||||||
|
currentStep === 'REVIEW_RECIPIENTS' ? 'max-h-[90vh] max-w-4xl overflow-y-auto' : ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<fieldset
|
||||||
|
disabled={currentStep === 'DETECTING_RECIPIENTS' || currentStep === 'DETECTING_FIELDS'}
|
||||||
|
>
|
||||||
<AnimateGenericFadeInOut motionKey={currentStep} className="grid gap-4">
|
<AnimateGenericFadeInOut motionKey={currentStep} className="grid gap-4">
|
||||||
{match(currentStep)
|
{match(currentStep)
|
||||||
.with('PROMPT', () => (
|
.with('PROMPT_DETECT_RECIPIENTS', () => (
|
||||||
<>
|
<>
|
||||||
<AlertDialogHeader>
|
<DialogHeader>
|
||||||
<AlertDialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Auto-detect recipients?</Trans>
|
<Trans>Auto-detect recipients?</Trans>
|
||||||
</AlertDialogTitle>
|
</DialogTitle>
|
||||||
<AlertDialogDescription>
|
<DialogDescription>
|
||||||
<Trans>
|
<Trans>
|
||||||
Would you like to automatically detect recipients in your document? This can
|
Would you like to automatically detect recipients in your document? This can
|
||||||
save you time in setting up your document.
|
save you time in setting up your document.
|
||||||
</Trans>
|
</Trans>
|
||||||
</AlertDialogDescription>
|
</DialogDescription>
|
||||||
</AlertDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<AlertDialogFooter>
|
<DialogFooter>
|
||||||
<AlertDialogCancel onClick={handleSkip}>
|
<Button variant="ghost" onClick={handleSkip}>
|
||||||
<Trans>Skip for now</Trans>
|
<Trans>Skip for now</Trans>
|
||||||
</AlertDialogCancel>
|
</Button>
|
||||||
<AlertDialogAction onClick={handleStartDetection}>
|
<Button onClick={handleStartDetection}>
|
||||||
<Trans>Detect recipients</Trans>
|
<Trans>Detect recipients</Trans>
|
||||||
</AlertDialogAction>
|
</Button>
|
||||||
</AlertDialogFooter>
|
</DialogFooter>
|
||||||
</>
|
</>
|
||||||
))
|
))
|
||||||
.with('PROCESSING', () => (
|
.with('DETECTING_RECIPIENTS', () => (
|
||||||
<div className="flex flex-col items-center justify-center py-4">
|
<div className="flex flex-col items-center justify-center py-4">
|
||||||
<div className="relative mb-4 flex items-center justify-center">
|
<div className="relative mb-4 flex items-center justify-center">
|
||||||
<div
|
<div
|
||||||
@ -105,22 +172,80 @@ export const RecipientDetectionPromptDialog = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AlertDialogHeader>
|
<DialogHeader>
|
||||||
<AlertDialogTitle className="text-center">
|
<DialogTitle className="text-center">
|
||||||
<Trans>Analyzing your document</Trans>
|
<Trans>Analyzing your document</Trans>
|
||||||
</AlertDialogTitle>
|
</DialogTitle>
|
||||||
<AlertDialogDescription className="text-center">
|
<DialogDescription className="text-center">
|
||||||
<Trans>
|
<Trans>
|
||||||
Scanning your document to detect recipient names, emails, and signing order.
|
Scanning your document to detect recipient names, emails, and signing order.
|
||||||
</Trans>
|
</Trans>
|
||||||
</AlertDialogDescription>
|
</DialogDescription>
|
||||||
</AlertDialogHeader>
|
</DialogHeader>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
.with('DETECTING_FIELDS', () => (
|
||||||
|
<div className="flex flex-col items-center justify-center py-4">
|
||||||
|
<div className="relative mb-4 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="border-muted-foreground/20 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 origin-top-left flex-col gap-y-1 overflow-hidden rounded-lg border px-2 py-4 backdrop-blur-sm"
|
||||||
|
style={{ transform: 'translateZ(0px)' }}
|
||||||
|
>
|
||||||
|
<div className="bg-muted-foreground/20 h-2 w-full rounded-[2px]"></div>
|
||||||
|
<div className="bg-muted-foreground/20 h-2 w-5/6 rounded-[2px]"></div>
|
||||||
|
<div className="bg-muted-foreground/20 h-2 w-full rounded-[2px]"></div>
|
||||||
|
|
||||||
|
<div className="bg-muted-foreground/20 h-2 w-4/5 rounded-[2px]"></div>
|
||||||
|
<div className="bg-muted-foreground/20 h-2 w-full rounded-[2px]"></div>
|
||||||
|
<div className="bg-muted-foreground/20 h-2 w-3/4 rounded-[2px]"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="bg-documenso/80 animate-scan pointer-events-none absolute left-1/2 top-0 z-20 h-0.5 w-24 -translate-x-1/2"
|
||||||
|
style={{
|
||||||
|
transform: 'translateX(-50%) translateZ(0px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-center">
|
||||||
|
<Trans>Detecting fields</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-center">
|
||||||
|
<Trans>
|
||||||
|
Scanning your document to intelligently place fields for your recipients.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
.with('REVIEW_RECIPIENTS', () => (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Review detected recipients</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>
|
||||||
|
Confirm, edit, or add recipients before continuing. You can adjust any
|
||||||
|
information below before importing it into your document.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<SuggestedRecipientsForm
|
||||||
|
recipients={currentRecipients}
|
||||||
|
onCancel={handleSkip}
|
||||||
|
onSubmit={onRecipientsSubmit}
|
||||||
|
onAutoAddFields={onAutoAddFields ? handleAutoAddFields : undefined}
|
||||||
|
isProcessing={isProcessingRecipients}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
))
|
||||||
.exhaustive()}
|
.exhaustive()}
|
||||||
</AnimateGenericFadeInOut>
|
</AnimateGenericFadeInOut>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</AlertDialogContent>
|
</DialogContent>
|
||||||
</AlertDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,366 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
|
||||||
import { RecipientRole } from '@prisma/client';
|
|
||||||
import { PlusIcon, TrashIcon } from 'lucide-react';
|
|
||||||
import { type FieldError, useFieldArray, useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { RecipientAutoCompleteInput } from '@documenso/ui/components/recipient/recipient-autocomplete-input';
|
|
||||||
import type { RecipientAutoCompleteOption } from '@documenso/ui/components/recipient/recipient-autocomplete-input';
|
|
||||||
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
|
||||||
|
|
||||||
import type { RecipientForCreation } from '~/utils/detect-document-recipients';
|
|
||||||
|
|
||||||
const ZSuggestedRecipientSchema = z.object({
|
|
||||||
formId: z.string().min(1),
|
|
||||||
name: z
|
|
||||||
.string()
|
|
||||||
.min(1, { message: msg`Name is required`.id })
|
|
||||||
.max(255),
|
|
||||||
email: z
|
|
||||||
.string()
|
|
||||||
.min(1, { message: msg`Email is required`.id })
|
|
||||||
.email({ message: msg`Invalid email`.id })
|
|
||||||
.max(254),
|
|
||||||
role: z.nativeEnum(RecipientRole),
|
|
||||||
});
|
|
||||||
|
|
||||||
const ZSuggestedRecipientsFormSchema = z.object({
|
|
||||||
recipients: z
|
|
||||||
.array(ZSuggestedRecipientSchema)
|
|
||||||
.min(1, { message: msg`Please add at least one recipient`.id }),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TSuggestedRecipientsFormSchema = z.infer<typeof ZSuggestedRecipientsFormSchema>;
|
|
||||||
|
|
||||||
export type SuggestedRecipientsDialogProps = {
|
|
||||||
open: boolean;
|
|
||||||
recipients: RecipientForCreation[] | null;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
onSubmit: (recipients: RecipientForCreation[]) => Promise<void> | void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SuggestedRecipientsDialog = ({
|
|
||||||
open,
|
|
||||||
recipients,
|
|
||||||
onOpenChange,
|
|
||||||
onCancel,
|
|
||||||
onSubmit,
|
|
||||||
}: SuggestedRecipientsDialogProps) => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
|
|
||||||
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
|
|
||||||
|
|
||||||
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
|
|
||||||
|
|
||||||
const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery(
|
|
||||||
{
|
|
||||||
query: debouncedRecipientSearchQuery,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: debouncedRecipientSearchQuery.length > 1,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const recipientSuggestions = recipientSuggestionsData?.results || [];
|
|
||||||
|
|
||||||
const defaultRecipients = useMemo(() => {
|
|
||||||
if (recipients && recipients.length > 0) {
|
|
||||||
const sorted = [...recipients].sort((a, b) => {
|
|
||||||
const orderA = a.signingOrder ?? 0;
|
|
||||||
const orderB = b.signingOrder ?? 0;
|
|
||||||
|
|
||||||
return orderA - orderB;
|
|
||||||
});
|
|
||||||
|
|
||||||
return sorted.map((recipient) => ({
|
|
||||||
formId: nanoid(),
|
|
||||||
name: recipient.name,
|
|
||||||
email: recipient.email,
|
|
||||||
role: recipient.role,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
formId: nanoid(),
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}, [recipients]);
|
|
||||||
|
|
||||||
const form = useForm<TSuggestedRecipientsFormSchema>({
|
|
||||||
resolver: zodResolver(ZSuggestedRecipientsFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
recipients: defaultRecipients,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const {
|
|
||||||
formState: { isSubmitting },
|
|
||||||
} = form;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
form.reset({
|
|
||||||
recipients: defaultRecipients,
|
|
||||||
});
|
|
||||||
}, [defaultRecipients, form]);
|
|
||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({
|
|
||||||
control: form.control,
|
|
||||||
name: 'recipients',
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleRecipientAutoCompleteSelect = (
|
|
||||||
index: number,
|
|
||||||
suggestion: RecipientAutoCompleteOption,
|
|
||||||
) => {
|
|
||||||
form.setValue(`recipients.${index}.email`, suggestion.email);
|
|
||||||
form.setValue(`recipients.${index}.name`, suggestion.name ?? suggestion.email);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddSigner = () => {
|
|
||||||
append({
|
|
||||||
formId: nanoid(),
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveSigner = (index: number) => {
|
|
||||||
remove(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = form.handleSubmit(async (values) => {
|
|
||||||
const normalizedRecipients: RecipientForCreation[] = values.recipients.map(
|
|
||||||
(recipient, index) => ({
|
|
||||||
name: recipient.name.trim(),
|
|
||||||
email: recipient.email.trim(),
|
|
||||||
role: recipient.role,
|
|
||||||
signingOrder: index + 1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await onSubmit(normalizedRecipients);
|
|
||||||
} catch {
|
|
||||||
// Form level errors are surfaced via toasts in the parent. Keep the dialog open.
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const getRecipientsRootError = (
|
|
||||||
error: typeof form.formState.errors.recipients,
|
|
||||||
): FieldError | undefined => {
|
|
||||||
if (typeof error !== 'object' || !error || !('root' in error)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidate = (error as { root?: FieldError }).root;
|
|
||||||
return typeof candidate === 'object' ? candidate : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const recipientsRootError = getRecipientsRootError(form.formState.errors.recipients);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Review detected recipients</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>
|
|
||||||
Confirm, edit, or add recipients before continuing. You can adjust any information
|
|
||||||
below before importing it into your document.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{fields.map((field, index) => (
|
|
||||||
<div
|
|
||||||
key={field.id}
|
|
||||||
className="flex flex-col gap-4 md:flex-row md:items-center md:gap-x-2"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name={`recipients.${index}.email`}
|
|
||||||
render={({ field: emailField }) => (
|
|
||||||
<FormItem
|
|
||||||
className={cn('relative w-full', {
|
|
||||||
'mb-6':
|
|
||||||
form.formState.errors.recipients?.[index] &&
|
|
||||||
!form.formState.errors.recipients[index]?.email,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{index === 0 && (
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Email</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
)}
|
|
||||||
<FormControl>
|
|
||||||
<RecipientAutoCompleteInput
|
|
||||||
type="email"
|
|
||||||
placeholder={t`Email`}
|
|
||||||
value={emailField.value}
|
|
||||||
options={recipientSuggestions}
|
|
||||||
onSelect={(suggestion) =>
|
|
||||||
handleRecipientAutoCompleteSelect(index, suggestion)
|
|
||||||
}
|
|
||||||
onSearchQueryChange={(query) => {
|
|
||||||
emailField.onChange(query);
|
|
||||||
setRecipientSearchQuery(query);
|
|
||||||
}}
|
|
||||||
loading={isLoading}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
maxLength={254}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name={`recipients.${index}.name`}
|
|
||||||
render={({ field: nameField }) => (
|
|
||||||
<FormItem
|
|
||||||
className={cn('w-full', {
|
|
||||||
'mb-6':
|
|
||||||
form.formState.errors.recipients?.[index] &&
|
|
||||||
!form.formState.errors.recipients[index]?.name,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{index === 0 && (
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Name</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
)}
|
|
||||||
<FormControl>
|
|
||||||
<RecipientAutoCompleteInput
|
|
||||||
type="text"
|
|
||||||
placeholder={t`Name`}
|
|
||||||
value={nameField.value}
|
|
||||||
options={recipientSuggestions}
|
|
||||||
onSelect={(suggestion) =>
|
|
||||||
handleRecipientAutoCompleteSelect(index, suggestion)
|
|
||||||
}
|
|
||||||
onSearchQueryChange={(query) => {
|
|
||||||
nameField.onChange(query);
|
|
||||||
setRecipientSearchQuery(query);
|
|
||||||
}}
|
|
||||||
loading={isLoading}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
maxLength={255}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name={`recipients.${index}.role`}
|
|
||||||
render={({ field: roleField }) => (
|
|
||||||
<FormItem
|
|
||||||
className={cn('mt-2 w-full md:mt-auto md:w-fit', {
|
|
||||||
'mb-6':
|
|
||||||
form.formState.errors.recipients?.[index] &&
|
|
||||||
!form.formState.errors.recipients[index]?.role,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{index === 0 && (
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Role</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
)}
|
|
||||||
<FormControl>
|
|
||||||
<RecipientRoleSelect
|
|
||||||
value={roleField.value}
|
|
||||||
onValueChange={roleField.onChange}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
className={cn('mt-2 w-full px-2 md:mt-auto md:w-auto', {
|
|
||||||
'mb-6': form.formState.errors.recipients?.[index],
|
|
||||||
})}
|
|
||||||
onClick={() => handleRemoveSigner(index)}
|
|
||||||
disabled={isSubmitting || fields.length === 1}
|
|
||||||
>
|
|
||||||
<TrashIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<FormErrorMessage className="mt-2" error={recipientsRootError} />
|
|
||||||
|
|
||||||
<div className="flex">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleAddSigner}
|
|
||||||
className="w-full md:w-auto"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
<PlusIcon className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Add signer</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end sm:gap-3">
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:gap-3">
|
|
||||||
<Button type="button" variant="ghost" onClick={onCancel} disabled={isSubmitting}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
|
||||||
<Trans>Use recipients</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
412
apps/remix/app/components/dialogs/suggested-recipients-form.tsx
Normal file
412
apps/remix/app/components/dialogs/suggested-recipients-form.tsx
Normal file
@ -0,0 +1,412 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { RecipientRole } from '@prisma/client';
|
||||||
|
import { PlusIcon, TrashIcon } from 'lucide-react';
|
||||||
|
import { type FieldError, useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||||
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { RecipientAutoCompleteInput } from '@documenso/ui/components/recipient/recipient-autocomplete-input';
|
||||||
|
import type { RecipientAutoCompleteOption } from '@documenso/ui/components/recipient/recipient-autocomplete-input';
|
||||||
|
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
|
import type { RecipientForCreation } from '~/utils/detect-document-recipients';
|
||||||
|
|
||||||
|
const ZSuggestedRecipientSchema = z.object({
|
||||||
|
formId: z.string().min(1),
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: msg`Name is required`.id })
|
||||||
|
.max(255),
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: msg`Email is required`.id })
|
||||||
|
.email({ message: msg`Invalid email`.id })
|
||||||
|
.max(254),
|
||||||
|
role: z.nativeEnum(RecipientRole),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ZSuggestedRecipientsFormSchema = z.object({
|
||||||
|
recipients: z
|
||||||
|
.array(ZSuggestedRecipientSchema)
|
||||||
|
.min(1, { message: msg`Please add at least one recipient`.id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TSuggestedRecipientsFormSchema = z.infer<typeof ZSuggestedRecipientsFormSchema>;
|
||||||
|
|
||||||
|
export type SuggestedRecipientsFormProps = {
|
||||||
|
recipients: RecipientForCreation[] | null;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSubmit: (recipients: RecipientForCreation[]) => Promise<void> | void;
|
||||||
|
onAutoAddFields?: (recipients: RecipientForCreation[]) => Promise<void> | void;
|
||||||
|
isProcessing?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SuggestedRecipientsForm = ({
|
||||||
|
recipients,
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
onAutoAddFields,
|
||||||
|
isProcessing = false,
|
||||||
|
}: SuggestedRecipientsFormProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
|
||||||
|
|
||||||
|
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
|
||||||
|
|
||||||
|
const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery(
|
||||||
|
{
|
||||||
|
query: debouncedRecipientSearchQuery,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: debouncedRecipientSearchQuery.length > 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const recipientSuggestions = recipientSuggestionsData?.results || [];
|
||||||
|
|
||||||
|
const defaultRecipients = useMemo(() => {
|
||||||
|
if (recipients && recipients.length > 0) {
|
||||||
|
const sorted = [...recipients].sort((a, b) => {
|
||||||
|
const orderA = a.signingOrder ?? 0;
|
||||||
|
const orderB = b.signingOrder ?? 0;
|
||||||
|
|
||||||
|
return orderA - orderB;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sorted.map((recipient) => ({
|
||||||
|
formId: nanoid(),
|
||||||
|
name: recipient.name,
|
||||||
|
email: recipient.email,
|
||||||
|
role: recipient.role,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
formId: nanoid(),
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [recipients]);
|
||||||
|
|
||||||
|
const form = useForm<TSuggestedRecipientsFormSchema>({
|
||||||
|
resolver: zodResolver(ZSuggestedRecipientsFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
recipients: defaultRecipients,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
formState: { isSubmitting },
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset({
|
||||||
|
recipients: defaultRecipients,
|
||||||
|
});
|
||||||
|
}, [defaultRecipients, form]);
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: 'recipients',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRecipientAutoCompleteSelect = (
|
||||||
|
index: number,
|
||||||
|
suggestion: RecipientAutoCompleteOption,
|
||||||
|
) => {
|
||||||
|
form.setValue(`recipients.${index}.email`, suggestion.email);
|
||||||
|
form.setValue(`recipients.${index}.name`, suggestion.name ?? suggestion.email);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddSigner = () => {
|
||||||
|
append({
|
||||||
|
formId: nanoid(),
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveSigner = (index: number) => {
|
||||||
|
remove(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = form.handleSubmit(async (values) => {
|
||||||
|
const normalizedRecipients: RecipientForCreation[] = values.recipients.map(
|
||||||
|
(recipient, index) => ({
|
||||||
|
name: recipient.name.trim(),
|
||||||
|
email: recipient.email.trim(),
|
||||||
|
role: recipient.role,
|
||||||
|
signingOrder: index + 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSubmit(normalizedRecipients);
|
||||||
|
} catch (error) {
|
||||||
|
// Log for debugging
|
||||||
|
console.error('Failed to submit recipients:', error);
|
||||||
|
// Form level errors are surfaced via toasts in the parent. Keep the dialog open.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAutoAddFields = form.handleSubmit(async (values) => {
|
||||||
|
if (!onAutoAddFields) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedRecipients: RecipientForCreation[] = values.recipients.map(
|
||||||
|
(recipient, index) => ({
|
||||||
|
name: recipient.name.trim(),
|
||||||
|
email: recipient.email.trim(),
|
||||||
|
role: recipient.role,
|
||||||
|
signingOrder: index + 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onAutoAddFields(normalizedRecipients);
|
||||||
|
} catch (error) {
|
||||||
|
// Log for debugging
|
||||||
|
console.error('Failed to auto-add fields:', error);
|
||||||
|
// Form level errors are surfaced via toasts in the parent. Keep the dialog open.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const getRecipientsRootError = (
|
||||||
|
error: typeof form.formState.errors.recipients,
|
||||||
|
): FieldError | undefined => {
|
||||||
|
if (typeof error !== 'object' || !error || !('root' in error)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = (error as { root?: FieldError }).root;
|
||||||
|
return typeof candidate === 'object' ? candidate : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const recipientsRootError = getRecipientsRootError(form.formState.errors.recipients);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<div
|
||||||
|
key={field.id}
|
||||||
|
className="flex flex-col gap-4 md:flex-row md:items-center md:gap-x-2"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`recipients.${index}.email`}
|
||||||
|
render={({ field: emailField }) => (
|
||||||
|
<FormItem
|
||||||
|
className={cn('relative w-full', {
|
||||||
|
'mb-6':
|
||||||
|
form.formState.errors.recipients?.[index] &&
|
||||||
|
!form.formState.errors.recipients[index]?.email,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{index === 0 && (
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>Email</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
)}
|
||||||
|
<FormControl>
|
||||||
|
<RecipientAutoCompleteInput
|
||||||
|
type="email"
|
||||||
|
placeholder={t`Email`}
|
||||||
|
value={emailField.value}
|
||||||
|
options={recipientSuggestions}
|
||||||
|
onSelect={(suggestion) =>
|
||||||
|
handleRecipientAutoCompleteSelect(index, suggestion)
|
||||||
|
}
|
||||||
|
onSearchQueryChange={(query) => {
|
||||||
|
emailField.onChange(query);
|
||||||
|
setRecipientSearchQuery(query);
|
||||||
|
}}
|
||||||
|
loading={isLoading}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
maxLength={254}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`recipients.${index}.name`}
|
||||||
|
render={({ field: nameField }) => (
|
||||||
|
<FormItem
|
||||||
|
className={cn('w-full', {
|
||||||
|
'mb-6':
|
||||||
|
form.formState.errors.recipients?.[index] &&
|
||||||
|
!form.formState.errors.recipients[index]?.name,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{index === 0 && (
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
)}
|
||||||
|
<FormControl>
|
||||||
|
<RecipientAutoCompleteInput
|
||||||
|
type="text"
|
||||||
|
placeholder={t`Name`}
|
||||||
|
value={nameField.value}
|
||||||
|
options={recipientSuggestions}
|
||||||
|
onSelect={(suggestion) =>
|
||||||
|
handleRecipientAutoCompleteSelect(index, suggestion)
|
||||||
|
}
|
||||||
|
onSearchQueryChange={(query) => {
|
||||||
|
nameField.onChange(query);
|
||||||
|
setRecipientSearchQuery(query);
|
||||||
|
}}
|
||||||
|
loading={isLoading}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
maxLength={255}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`recipients.${index}.role`}
|
||||||
|
render={({ field: roleField }) => (
|
||||||
|
<FormItem
|
||||||
|
className={cn('mt-2 w-full md:mt-auto md:w-fit', {
|
||||||
|
'mb-6':
|
||||||
|
form.formState.errors.recipients?.[index] &&
|
||||||
|
!form.formState.errors.recipients[index]?.role,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{index === 0 && (
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Role</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
)}
|
||||||
|
<FormControl>
|
||||||
|
<RecipientRoleSelect
|
||||||
|
value={roleField.value}
|
||||||
|
onValueChange={roleField.onChange}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className={cn('mt-2 w-full px-2 md:mt-auto md:w-auto', {
|
||||||
|
'mb-6': form.formState.errors.recipients?.[index],
|
||||||
|
})}
|
||||||
|
onClick={() => handleRemoveSigner(index)}
|
||||||
|
disabled={isSubmitting || fields.length === 1}
|
||||||
|
>
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<FormErrorMessage className="mt-2" error={recipientsRootError} />
|
||||||
|
|
||||||
|
<div className="flex">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleAddSigner}
|
||||||
|
className="w-full md:w-auto"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<PlusIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Add signer</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-between sm:gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isSubmitting || isProcessing}
|
||||||
|
>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:gap-3">
|
||||||
|
<Button type="submit" disabled={isSubmitting || isProcessing}>
|
||||||
|
<Trans>Use recipients</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{onAutoAddFields && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAutoAddFields}
|
||||||
|
disabled={
|
||||||
|
isSubmitting ||
|
||||||
|
isProcessing ||
|
||||||
|
fields.length === 0 ||
|
||||||
|
!form.formState.isValid
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<Trans>Processing...</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Auto add fields</Trans>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{(fields.length === 0 || !form.formState.isValid) && (
|
||||||
|
<TooltipContent>
|
||||||
|
<Trans>Please add at least one valid recipient to auto-detect fields</Trans>
|
||||||
|
</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { lazy, useEffect, useMemo, useState } from 'react';
|
import { lazy, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import type { MessageDescriptor } from '@lingui/core';
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg, plural } from '@lingui/core/macro';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { FieldType, RecipientRole } from '@prisma/client';
|
import { FieldType, RecipientRole } from '@prisma/client';
|
||||||
import { FileTextIcon } from 'lucide-react';
|
import { FileTextIcon } from 'lucide-react';
|
||||||
@ -54,7 +54,6 @@ const EnvelopeEditorFieldsPageRenderer = lazy(
|
|||||||
async () => import('./envelope-editor-fields-page-renderer'),
|
async () => import('./envelope-editor-fields-page-renderer'),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Expands fields to minimum usable dimensions (30px height, 36px width) and centers them
|
|
||||||
const enforceMinimumFieldDimensions = (params: {
|
const enforceMinimumFieldDimensions = (params: {
|
||||||
positionX: number;
|
positionX: number;
|
||||||
positionY: number;
|
positionY: number;
|
||||||
@ -184,6 +183,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
current: number;
|
current: number;
|
||||||
total: number;
|
total: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [hasAutoPlacedFields, setHasAutoPlacedFields] = useState(false);
|
||||||
|
|
||||||
const selectedField = useMemo(
|
const selectedField = useMemo(
|
||||||
() => structuredClone(editorFields.selectedField),
|
() => structuredClone(editorFields.selectedField),
|
||||||
@ -204,9 +204,6 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the selected recipient to the first recipient in the envelope.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const firstSelectableRecipient = envelope.recipients.find(
|
const firstSelectableRecipient = envelope.recipients.find(
|
||||||
(recipient) =>
|
(recipient) =>
|
||||||
@ -216,16 +213,143 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null);
|
editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasAutoPlacedFields || !currentEnvelopeItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageKey = `autoPlaceFields_${envelope.id}`;
|
||||||
|
const storedData = sessionStorage.getItem(storageKey);
|
||||||
|
|
||||||
|
if (!storedData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionStorage.removeItem(storageKey);
|
||||||
|
setHasAutoPlacedFields(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { fields: detectedFields, recipientCount } = JSON.parse(storedData) as {
|
||||||
|
fields: TDetectedFormField[];
|
||||||
|
recipientCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let totalAdded = 0;
|
||||||
|
|
||||||
|
const fieldsPerPage = new Map<number, TDetectedFormField[]>();
|
||||||
|
for (const field of detectedFields) {
|
||||||
|
if (!fieldsPerPage.has(field.pageNumber)) {
|
||||||
|
fieldsPerPage.set(field.pageNumber, []);
|
||||||
|
}
|
||||||
|
fieldsPerPage.get(field.pageNumber)!.push(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [pageNumber, fields] of fieldsPerPage.entries()) {
|
||||||
|
const pageCanvasRefs = getPageCanvasRefs(pageNumber);
|
||||||
|
|
||||||
|
for (const detected of fields) {
|
||||||
|
const [ymin, xmin, ymax, xmax] = detected.boundingBox;
|
||||||
|
let positionX = (xmin / 1000) * 100;
|
||||||
|
let positionY = (ymin / 1000) * 100;
|
||||||
|
let width = ((xmax - xmin) / 1000) * 100;
|
||||||
|
let height = ((ymax - ymin) / 1000) * 100;
|
||||||
|
|
||||||
|
if (pageCanvasRefs) {
|
||||||
|
const adjusted = enforceMinimumFieldDimensions({
|
||||||
|
positionX,
|
||||||
|
positionY,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
pageWidth: pageCanvasRefs.pdfCanvas.width,
|
||||||
|
pageHeight: pageCanvasRefs.pdfCanvas.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
positionX = adjusted.positionX;
|
||||||
|
positionY = adjusted.positionY;
|
||||||
|
width = adjusted.width;
|
||||||
|
height = adjusted.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldType = detected.label as FieldType;
|
||||||
|
const resolvedRecipientId =
|
||||||
|
envelope.recipients.find((recipient) => recipient.id === detected.recipientId)?.id ??
|
||||||
|
editorFields.selectedRecipient?.id ??
|
||||||
|
envelope.recipients[0]?.id;
|
||||||
|
|
||||||
|
if (!resolvedRecipientId) {
|
||||||
|
console.warn('Skipping detected field because no recipient could be resolved', {
|
||||||
|
detectedRecipientId: detected.recipientId,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
editorFields.addField({
|
||||||
|
envelopeItemId: currentEnvelopeItem.id,
|
||||||
|
page: pageNumber,
|
||||||
|
type: fieldType,
|
||||||
|
positionX,
|
||||||
|
positionY,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
recipientId: resolvedRecipientId,
|
||||||
|
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[fieldType]),
|
||||||
|
});
|
||||||
|
totalAdded++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to add field on page ${pageNumber}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalAdded > 0) {
|
||||||
|
toast({
|
||||||
|
title: t`Recipients and fields added`,
|
||||||
|
description: t`Added ${recipientCount} ${plural(recipientCount, {
|
||||||
|
one: 'recipient',
|
||||||
|
other: 'recipients',
|
||||||
|
})} and ${totalAdded} ${plural(totalAdded, { one: 'field', other: 'fields' })}`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: t`Recipients added`,
|
||||||
|
description: t`Added ${recipientCount} ${plural(recipientCount, {
|
||||||
|
one: 'recipient',
|
||||||
|
other: 'recipients',
|
||||||
|
})}. No fields were detected in the document.`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to auto-place fields:', error);
|
||||||
|
toast({
|
||||||
|
title: t`Field placement failed`,
|
||||||
|
description: t`Failed to automatically place fields. You can add them manually.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
currentEnvelopeItem,
|
||||||
|
envelope.id,
|
||||||
|
envelope.recipients,
|
||||||
|
editorFields,
|
||||||
|
hasAutoPlacedFields,
|
||||||
|
t,
|
||||||
|
toast,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full">
|
<div className="relative flex h-full">
|
||||||
<div className="relative flex w-full flex-col overflow-y-auto">
|
<div className="relative flex w-full flex-col overflow-y-auto">
|
||||||
{/* Horizontal envelope item selector */}
|
{/* Horizontal envelope item selector */}
|
||||||
{isDetectingFields && (
|
{isDetectingFields && (
|
||||||
<>
|
<>
|
||||||
<div className="edge-glow edge-glow-top pointer-events-none fixed left-0 right-0 top-0 z-20 h-16" />
|
<div className="edge-glow edge-glow-top pointer-events-none fixed left-0 right-0 top-0 z-20 h-32" />
|
||||||
<div className="edge-glow edge-glow-right pointer-events-none fixed bottom-0 right-0 top-0 z-20 w-16" />
|
<div className="edge-glow edge-glow-right pointer-events-none fixed bottom-0 right-0 top-0 z-20 w-32" />
|
||||||
<div className="edge-glow edge-glow-bottom pointer-events-none fixed bottom-0 left-0 right-0 z-20 h-16" />
|
<div className="edge-glow edge-glow-bottom pointer-events-none fixed bottom-0 left-0 right-0 z-20 h-32" />
|
||||||
<div className="edge-glow edge-glow-left pointer-events-none fixed bottom-0 left-0 top-0 z-20 w-16" />
|
<div className="edge-glow edge-glow-left pointer-events-none fixed bottom-0 left-0 top-0 z-20 w-32" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -330,7 +454,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
try {
|
try {
|
||||||
if (!currentEnvelopeItem) {
|
if (!currentEnvelopeItem) {
|
||||||
toast({
|
toast({
|
||||||
title: t`Error`,
|
title: t`No document selected`,
|
||||||
description: t`No document selected. Please reload the page and try again.`,
|
description: t`No document selected. Please reload the page and try again.`,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
@ -339,7 +463,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
|
|
||||||
if (!currentEnvelopeItem.documentDataId) {
|
if (!currentEnvelopeItem.documentDataId) {
|
||||||
toast({
|
toast({
|
||||||
title: t`Error`,
|
title: t`Document data missing`,
|
||||||
description: t`Document data not found. Please try reloading the page.`,
|
description: t`Document data not found. Please try reloading the page.`,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
@ -430,24 +554,24 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t`Success`,
|
title: t`Fields added`,
|
||||||
description,
|
description,
|
||||||
});
|
});
|
||||||
} else if (failedPages > 0) {
|
} else if (failedPages > 0) {
|
||||||
toast({
|
toast({
|
||||||
title: t`Error`,
|
title: t`Field detection failed`,
|
||||||
description: t`Failed to detect fields on ${failedPages} pages. Please try again.`,
|
description: t`Failed to detect fields on ${failedPages} pages. Please try again.`,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: t`Info`,
|
title: t`No fields detected`,
|
||||||
description: t`No fields were detected in the document`,
|
description: t`No fields were detected in the document`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: t`Error`,
|
title: t`Processing error`,
|
||||||
description: t`An unexpected error occurred while processing pages.`,
|
description: t`An unexpected error occurred while processing pages.`,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { type ReactNode, useState } from 'react';
|
import { type ReactNode, useState } from 'react';
|
||||||
|
|
||||||
|
import { plural } from '@lingui/core/macro';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { EnvelopeType } from '@prisma/client';
|
import { EnvelopeType } from '@prisma/client';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
@ -27,7 +28,6 @@ import { cn } from '@documenso/ui/lib/utils';
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { RecipientDetectionPromptDialog } from '~/components/dialogs/recipient-detection-prompt-dialog';
|
import { RecipientDetectionPromptDialog } from '~/components/dialogs/recipient-detection-prompt-dialog';
|
||||||
import { SuggestedRecipientsDialog } from '~/components/dialogs/suggested-recipients-dialog';
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
import {
|
import {
|
||||||
type RecipientForCreation,
|
type RecipientForCreation,
|
||||||
@ -61,7 +61,6 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
const [showExtractionPrompt, setShowExtractionPrompt] = useState(false);
|
const [showExtractionPrompt, setShowExtractionPrompt] = useState(false);
|
||||||
const [uploadedDocumentId, setUploadedDocumentId] = useState<string | null>(null);
|
const [uploadedDocumentId, setUploadedDocumentId] = useState<string | null>(null);
|
||||||
const [pendingRecipients, setPendingRecipients] = useState<RecipientForCreation[] | null>(null);
|
const [pendingRecipients, setPendingRecipients] = useState<RecipientForCreation[] | null>(null);
|
||||||
const [showRecipientsDialog, setShowRecipientsDialog] = useState(false);
|
|
||||||
const [shouldNavigateAfterPromptClose, setShouldNavigateAfterPromptClose] = useState(true);
|
const [shouldNavigateAfterPromptClose, setShouldNavigateAfterPromptClose] = useState(true);
|
||||||
|
|
||||||
const userTimezone =
|
const userTimezone =
|
||||||
@ -124,7 +123,6 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
// Show AI prompt dialog for documents
|
// Show AI prompt dialog for documents
|
||||||
setUploadedDocumentId(id);
|
setUploadedDocumentId(id);
|
||||||
setPendingRecipients(null);
|
setPendingRecipients(null);
|
||||||
setShowRecipientsDialog(false);
|
|
||||||
setShouldNavigateAfterPromptClose(true);
|
setShouldNavigateAfterPromptClose(true);
|
||||||
setShowExtractionPrompt(true);
|
setShowExtractionPrompt(true);
|
||||||
} else {
|
} else {
|
||||||
@ -148,7 +146,7 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
.otherwise(() => t`An error occurred during upload.`);
|
.otherwise(() => t`An error occurred during upload.`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t`Error`,
|
title: t`Upload failed`,
|
||||||
description: errorMessage,
|
description: errorMessage,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 7500,
|
duration: 7500,
|
||||||
@ -252,8 +250,6 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
|
|
||||||
setPendingRecipients(recipientsWithEmails);
|
setPendingRecipients(recipientsWithEmails);
|
||||||
setShouldNavigateAfterPromptClose(false);
|
setShouldNavigateAfterPromptClose(false);
|
||||||
setShowExtractionPrompt(false);
|
|
||||||
setShowRecipientsDialog(true);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!(error instanceof Error && error.message === 'NO_RECIPIENTS_DETECTED')) {
|
if (!(error instanceof Error && error.message === 'NO_RECIPIENTS_DETECTED')) {
|
||||||
const parsedError = AppError.parseError(error);
|
const parsedError = AppError.parseError(error);
|
||||||
@ -276,12 +272,6 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
navigateToEnvelopeEditor();
|
navigateToEnvelopeEditor();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRecipientsCancel = () => {
|
|
||||||
setShowRecipientsDialog(false);
|
|
||||||
setPendingRecipients(null);
|
|
||||||
navigateToEnvelopeEditor();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRecipientsConfirm = async (recipientsToCreate: RecipientForCreation[]) => {
|
const handleRecipientsConfirm = async (recipientsToCreate: RecipientForCreation[]) => {
|
||||||
if (!uploadedDocumentId) {
|
if (!uploadedDocumentId) {
|
||||||
return;
|
return;
|
||||||
@ -295,11 +285,17 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t`Recipients added`,
|
title: t`Recipients added`,
|
||||||
description: t`Successfully detected ${recipientsToCreate.length} recipient(s)`,
|
description: t`Successfully detected ${recipientsToCreate.length} ${plural(
|
||||||
|
recipientsToCreate.length,
|
||||||
|
{
|
||||||
|
one: 'recipient',
|
||||||
|
other: 'recipients',
|
||||||
|
},
|
||||||
|
)}`,
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
setShowRecipientsDialog(false);
|
setShowExtractionPrompt(false);
|
||||||
setPendingRecipients(null);
|
setPendingRecipients(null);
|
||||||
navigateToEnvelopeEditor();
|
navigateToEnvelopeEditor();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -401,20 +397,8 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
onOpenChange={handlePromptDialogOpenChange}
|
onOpenChange={handlePromptDialogOpenChange}
|
||||||
onAccept={handleStartRecipientDetection}
|
onAccept={handleStartRecipientDetection}
|
||||||
onSkip={handleSkipRecipientDetection}
|
onSkip={handleSkipRecipientDetection}
|
||||||
/>
|
|
||||||
|
|
||||||
<SuggestedRecipientsDialog
|
|
||||||
open={showRecipientsDialog}
|
|
||||||
recipients={pendingRecipients}
|
recipients={pendingRecipients}
|
||||||
onOpenChange={(open) => {
|
onRecipientsSubmit={handleRecipientsConfirm}
|
||||||
if (!open) {
|
|
||||||
handleRecipientsCancel();
|
|
||||||
} else {
|
|
||||||
setShowRecipientsDialog(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onCancel={handleRecipientsCancel}
|
|
||||||
onSubmit={handleRecipientsConfirm}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg, plural } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { EnvelopeType } from '@prisma/client';
|
import { EnvelopeType } from '@prisma/client';
|
||||||
@ -28,8 +28,8 @@ import {
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { RecipientDetectionPromptDialog } from '~/components/dialogs/recipient-detection-prompt-dialog';
|
import { RecipientDetectionPromptDialog } from '~/components/dialogs/recipient-detection-prompt-dialog';
|
||||||
import { SuggestedRecipientsDialog } from '~/components/dialogs/suggested-recipients-dialog';
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
import { detectFieldsInDocument } from '~/utils/detect-document-fields';
|
||||||
import {
|
import {
|
||||||
type RecipientForCreation,
|
type RecipientForCreation,
|
||||||
detectRecipientsInDocument,
|
detectRecipientsInDocument,
|
||||||
@ -65,8 +65,8 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
const [showExtractionPrompt, setShowExtractionPrompt] = useState(false);
|
const [showExtractionPrompt, setShowExtractionPrompt] = useState(false);
|
||||||
const [uploadedDocumentId, setUploadedDocumentId] = useState<string | null>(null);
|
const [uploadedDocumentId, setUploadedDocumentId] = useState<string | null>(null);
|
||||||
const [pendingRecipients, setPendingRecipients] = useState<RecipientForCreation[] | null>(null);
|
const [pendingRecipients, setPendingRecipients] = useState<RecipientForCreation[] | null>(null);
|
||||||
const [showRecipientsDialog, setShowRecipientsDialog] = useState(false);
|
|
||||||
const [shouldNavigateAfterPromptClose, setShouldNavigateAfterPromptClose] = useState(true);
|
const [shouldNavigateAfterPromptClose, setShouldNavigateAfterPromptClose] = useState(true);
|
||||||
|
const [isAutoAddingFields, setIsAutoAddingFields] = useState(false);
|
||||||
|
|
||||||
const { mutateAsync: createEnvelope } = trpc.envelope.create.useMutation();
|
const { mutateAsync: createEnvelope } = trpc.envelope.create.useMutation();
|
||||||
const { mutateAsync: createRecipients } = trpc.envelope.recipient.createMany.useMutation();
|
const { mutateAsync: createRecipients } = trpc.envelope.recipient.createMany.useMutation();
|
||||||
@ -121,11 +121,9 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
? formatDocumentsPath(team.url)
|
? formatDocumentsPath(team.url)
|
||||||
: formatTemplatesPath(team.url);
|
: formatTemplatesPath(team.url);
|
||||||
|
|
||||||
// Show AI prompt dialog for documents only
|
|
||||||
if (type === EnvelopeType.DOCUMENT) {
|
if (type === EnvelopeType.DOCUMENT) {
|
||||||
setUploadedDocumentId(id);
|
setUploadedDocumentId(id);
|
||||||
setPendingRecipients(null);
|
setPendingRecipients(null);
|
||||||
setShowRecipientsDialog(false);
|
|
||||||
setShouldNavigateAfterPromptClose(true);
|
setShouldNavigateAfterPromptClose(true);
|
||||||
setShowExtractionPrompt(true);
|
setShowExtractionPrompt(true);
|
||||||
|
|
||||||
@ -135,7 +133,6 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Templates - navigate immediately
|
|
||||||
await navigate(`${pathPrefix}/${id}/edit`);
|
await navigate(`${pathPrefix}/${id}/edit`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -162,7 +159,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
.otherwise(() => t`An error occurred while uploading your document.`);
|
.otherwise(() => t`An error occurred while uploading your document.`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t`Error`,
|
title: t`Upload failed`,
|
||||||
description: errorMessage,
|
description: errorMessage,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 7500,
|
duration: 7500,
|
||||||
@ -229,12 +226,11 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
|
|
||||||
setPendingRecipients(recipientsWithEmails);
|
setPendingRecipients(recipientsWithEmails);
|
||||||
setShouldNavigateAfterPromptClose(false);
|
setShouldNavigateAfterPromptClose(false);
|
||||||
setShowExtractionPrompt(false);
|
|
||||||
setShowRecipientsDialog(true);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!(err instanceof Error && err.message === 'NO_RECIPIENTS_DETECTED')) {
|
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
// Only show toast if this wasn't a "no recipients found" case
|
||||||
|
if (error.code !== 'NO_RECIPIENTS_DETECTED') {
|
||||||
toast({
|
toast({
|
||||||
title: t`Failed to analyze recipients`,
|
title: t`Failed to analyze recipients`,
|
||||||
description: error.userMessage || t`You can add recipients manually in the editor`,
|
description: error.userMessage || t`You can add recipients manually in the editor`,
|
||||||
@ -243,7 +239,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
throw err;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -253,12 +249,6 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
navigateToEnvelopeEditor();
|
navigateToEnvelopeEditor();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRecipientsCancel = () => {
|
|
||||||
setShowRecipientsDialog(false);
|
|
||||||
setPendingRecipients(null);
|
|
||||||
navigateToEnvelopeEditor();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRecipientsConfirm = async (recipientsToCreate: RecipientForCreation[]) => {
|
const handleRecipientsConfirm = async (recipientsToCreate: RecipientForCreation[]) => {
|
||||||
if (!uploadedDocumentId) {
|
if (!uploadedDocumentId) {
|
||||||
return;
|
return;
|
||||||
@ -272,11 +262,17 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t`Recipients added`,
|
title: t`Recipients added`,
|
||||||
description: t`Successfully detected ${recipientsToCreate.length} recipient(s)`,
|
description: t`Successfully detected ${recipientsToCreate.length} ${plural(
|
||||||
|
recipientsToCreate.length,
|
||||||
|
{
|
||||||
|
one: 'recipient',
|
||||||
|
other: 'recipients',
|
||||||
|
},
|
||||||
|
)}`,
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
setShowRecipientsDialog(false);
|
setShowExtractionPrompt(false);
|
||||||
setPendingRecipients(null);
|
setPendingRecipients(null);
|
||||||
navigateToEnvelopeEditor();
|
navigateToEnvelopeEditor();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -289,7 +285,72 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
duration: 7500,
|
duration: 7500,
|
||||||
});
|
});
|
||||||
|
|
||||||
throw err;
|
// Error is handled, dialog stays open for retry
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAutoAddFields = async (recipientsToCreate: RecipientForCreation[]) => {
|
||||||
|
if (!uploadedDocumentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAutoAddingFields(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createRecipients({
|
||||||
|
envelopeId: uploadedDocumentId,
|
||||||
|
data: recipientsToCreate,
|
||||||
|
});
|
||||||
|
|
||||||
|
let detectedFields;
|
||||||
|
try {
|
||||||
|
detectedFields = await detectFieldsInDocument(uploadedDocumentId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Field detection failed:', error);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Field detection failed`,
|
||||||
|
description: t`Recipients added successfully, but field detection encountered an error. You can add fields manually.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 7500,
|
||||||
|
});
|
||||||
|
|
||||||
|
setShowExtractionPrompt(false);
|
||||||
|
setPendingRecipients(null);
|
||||||
|
setIsAutoAddingFields(false);
|
||||||
|
|
||||||
|
const pathPrefix = formatDocumentsPath(team.url);
|
||||||
|
void navigate(`${pathPrefix}/${uploadedDocumentId}/edit?step=addFields`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detectedFields.length > 0) {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
`autoPlaceFields_${uploadedDocumentId}`,
|
||||||
|
JSON.stringify({
|
||||||
|
fields: detectedFields,
|
||||||
|
recipientCount: recipientsToCreate.length,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowExtractionPrompt(false);
|
||||||
|
setPendingRecipients(null);
|
||||||
|
setIsAutoAddingFields(false);
|
||||||
|
|
||||||
|
const pathPrefix = formatDocumentsPath(team.url);
|
||||||
|
void navigate(`${pathPrefix}/${uploadedDocumentId}/edit?step=addFields`);
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Failed to add recipients`,
|
||||||
|
description: error.userMessage || t`Please try again`,
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 7500,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsAutoAddingFields(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -344,20 +405,10 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
onOpenChange={handlePromptDialogOpenChange}
|
onOpenChange={handlePromptDialogOpenChange}
|
||||||
onAccept={handleStartRecipientDetection}
|
onAccept={handleStartRecipientDetection}
|
||||||
onSkip={handleSkipRecipientDetection}
|
onSkip={handleSkipRecipientDetection}
|
||||||
/>
|
|
||||||
|
|
||||||
<SuggestedRecipientsDialog
|
|
||||||
open={showRecipientsDialog}
|
|
||||||
recipients={pendingRecipients}
|
recipients={pendingRecipients}
|
||||||
onOpenChange={(open) => {
|
onRecipientsSubmit={handleRecipientsConfirm}
|
||||||
if (!open) {
|
onAutoAddFields={handleAutoAddFields}
|
||||||
handleRecipientsCancel();
|
isProcessingRecipients={isAutoAddingFields}
|
||||||
} else {
|
|
||||||
setShowRecipientsDialog(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onCancel={handleRecipientsCancel}
|
|
||||||
onSubmit={handleRecipientsConfirm}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
39
apps/remix/app/utils/detect-document-fields.ts
Normal file
39
apps/remix/app/utils/detect-document-fields.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import type { TDetectedFormField } from '@documenso/lib/types/document-analysis';
|
||||||
|
|
||||||
|
export const detectFieldsInDocument = async (envelopeId: string): Promise<TDetectedFormField[]> => {
|
||||||
|
const response = await fetch('/api/ai/detect-fields', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ envelopeId }),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
|
||||||
|
console.error('Field detection failed:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: errorText,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||||
|
message: `Field detection failed: ${response.statusText}`,
|
||||||
|
userMessage: 'Failed to detect fields in the document. Please try adding fields manually.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||||
|
message: 'Invalid response from field detection API - expected array',
|
||||||
|
userMessage: 'Failed to detect fields in the document. Please try again.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
@ -200,22 +200,22 @@
|
|||||||
@keyframes edgeGlow {
|
@keyframes edgeGlow {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
opacity: 0.3;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
opacity: 0.6;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.edge-glow {
|
.edge-glow {
|
||||||
animation: edgeGlow 2s ease-in-out infinite;
|
animation: edgeGlow 1.25s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edge-glow-top {
|
.edge-glow-top {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to bottom,
|
to bottom,
|
||||||
rgba(162, 231, 113, 0.4) 0%,
|
rgba(162, 231, 113, 0.8) 0%,
|
||||||
rgba(162, 231, 113, 0.2) 20%,
|
rgba(162, 231, 113, 0.4) 20%,
|
||||||
transparent 100%
|
transparent 100%
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -223,8 +223,8 @@
|
|||||||
.edge-glow-right {
|
.edge-glow-right {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to left,
|
to left,
|
||||||
rgba(162, 231, 113, 0.4) 0%,
|
rgba(162, 231, 113, 0.8) 0%,
|
||||||
rgba(162, 231, 113, 0.2) 20%,
|
rgba(162, 231, 113, 0.4) 20%,
|
||||||
transparent 100%
|
transparent 100%
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -232,8 +232,8 @@
|
|||||||
.edge-glow-bottom {
|
.edge-glow-bottom {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to top,
|
to top,
|
||||||
rgba(162, 231, 113, 0.4) 0%,
|
rgba(162, 231, 113, 0.8) 0%,
|
||||||
rgba(162, 231, 113, 0.2) 20%,
|
rgba(162, 231, 113, 0.4) 20%,
|
||||||
transparent 100%
|
transparent 100%
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -241,8 +241,8 @@
|
|||||||
.edge-glow-left {
|
.edge-glow-left {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to right,
|
to right,
|
||||||
rgba(162, 231, 113, 0.4) 0%,
|
rgba(162, 231, 113, 0.8) 0%,
|
||||||
rgba(162, 231, 113, 0.2) 20%,
|
rgba(162, 231, 113, 0.4) 20%,
|
||||||
transparent 100%
|
transparent 100%
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user