mirror of
https://github.com/documenso/documenso.git
synced 2025-11-20 03:32:14 +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 { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@documenso/ui/primitives/alert-dialog';
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/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 = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAccept: () => Promise<void> | void;
|
||||
onSkip: () => void;
|
||||
recipients: RecipientForCreation[] | null;
|
||||
onRecipientsSubmit: (recipients: RecipientForCreation[]) => Promise<void> | void;
|
||||
onAutoAddFields?: (recipients: RecipientForCreation[]) => Promise<void> | void;
|
||||
isProcessingRecipients?: boolean;
|
||||
};
|
||||
|
||||
export const RecipientDetectionPromptDialog = ({
|
||||
@ -29,21 +40,40 @@ export const RecipientDetectionPromptDialog = ({
|
||||
onOpenChange,
|
||||
onAccept,
|
||||
onSkip,
|
||||
recipients,
|
||||
onRecipientsSubmit,
|
||||
onAutoAddFields,
|
||||
isProcessingRecipients = false,
|
||||
}: RecipientDetectionPromptDialogProps) => {
|
||||
const [currentStep, setCurrentStep] = useState<RecipientDetectionStep>('PROMPT');
|
||||
const [currentStep, setCurrentStep] = useState<RecipientDetectionStep>(
|
||||
'PROMPT_DETECT_RECIPIENTS',
|
||||
);
|
||||
const [currentRecipients, setCurrentRecipients] = useState<RecipientForCreation[] | null>(
|
||||
recipients,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setCurrentStep('PROMPT');
|
||||
setCurrentStep('PROMPT_DETECT_RECIPIENTS');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentRecipients(recipients);
|
||||
}, [recipients]);
|
||||
|
||||
useEffect(() => {
|
||||
if (recipients && currentStep === 'DETECTING_RECIPIENTS') {
|
||||
setCurrentStep('REVIEW_RECIPIENTS');
|
||||
}
|
||||
}, [recipients, currentStep]);
|
||||
|
||||
const handleStartDetection = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setCurrentStep('PROCESSING');
|
||||
setCurrentStep('DETECTING_RECIPIENTS');
|
||||
|
||||
Promise.resolve(onAccept()).catch(() => {
|
||||
setCurrentStep('PROMPT');
|
||||
setCurrentStep('PROMPT_DETECT_RECIPIENTS');
|
||||
});
|
||||
};
|
||||
|
||||
@ -51,37 +81,74 @@ export const RecipientDetectionPromptDialog = ({
|
||||
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 (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<fieldset disabled={currentStep === 'PROCESSING'}>
|
||||
<Dialog
|
||||
open={open}
|
||||
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">
|
||||
{match(currentStep)
|
||||
.with('PROMPT', () => (
|
||||
.with('PROMPT_DETECT_RECIPIENTS', () => (
|
||||
<>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Auto-detect recipients?</Trans>
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Would you like to automatically detect recipients in your document? This can
|
||||
save you time in setting up your document.
|
||||
</Trans>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleSkip}>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={handleSkip}>
|
||||
<Trans>Skip for now</Trans>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleStartDetection}>
|
||||
</Button>
|
||||
<Button onClick={handleStartDetection}>
|
||||
<Trans>Detect recipients</Trans>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
))
|
||||
.with('PROCESSING', () => (
|
||||
.with('DETECTING_RECIPIENTS', () => (
|
||||
<div className="flex flex-col items-center justify-center py-4">
|
||||
<div className="relative mb-4 flex items-center justify-center">
|
||||
<div
|
||||
@ -105,22 +172,80 @@ export const RecipientDetectionPromptDialog = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-center">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center">
|
||||
<Trans>Analyzing your document</Trans>
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-center">
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-center">
|
||||
<Trans>
|
||||
Scanning your document to detect recipient names, emails, and signing order.
|
||||
</Trans>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</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()}
|
||||
</AnimateGenericFadeInOut>
|
||||
</fieldset>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</DialogContent>
|
||||
</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 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 { FieldType, RecipientRole } from '@prisma/client';
|
||||
import { FileTextIcon } from 'lucide-react';
|
||||
@ -54,7 +54,6 @@ const EnvelopeEditorFieldsPageRenderer = lazy(
|
||||
async () => import('./envelope-editor-fields-page-renderer'),
|
||||
);
|
||||
|
||||
// Expands fields to minimum usable dimensions (30px height, 36px width) and centers them
|
||||
const enforceMinimumFieldDimensions = (params: {
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
@ -184,6 +183,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
current: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
const [hasAutoPlacedFields, setHasAutoPlacedFields] = useState(false);
|
||||
|
||||
const selectedField = useMemo(
|
||||
() => structuredClone(editorFields.selectedField),
|
||||
@ -204,9 +204,6 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the selected recipient to the first recipient in the envelope.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const firstSelectableRecipient = envelope.recipients.find(
|
||||
(recipient) =>
|
||||
@ -216,16 +213,143 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
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 (
|
||||
<div className="relative flex h-full">
|
||||
<div className="relative flex w-full flex-col overflow-y-auto">
|
||||
{/* Horizontal envelope item selector */}
|
||||
{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-right pointer-events-none fixed bottom-0 right-0 top-0 z-20 w-16" />
|
||||
<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-left pointer-events-none fixed bottom-0 left-0 top-0 z-20 w-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-32" />
|
||||
<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-32" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -330,7 +454,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
try {
|
||||
if (!currentEnvelopeItem) {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
title: t`No document selected`,
|
||||
description: t`No document selected. Please reload the page and try again.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@ -339,7 +463,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
if (!currentEnvelopeItem.documentDataId) {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
title: t`Document data missing`,
|
||||
description: t`Document data not found. Please try reloading the page.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@ -430,24 +554,24 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t`Success`,
|
||||
title: t`Fields added`,
|
||||
description,
|
||||
});
|
||||
} else if (failedPages > 0) {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
title: t`Field detection failed`,
|
||||
description: t`Failed to detect fields on ${failedPages} pages. Please try again.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t`Info`,
|
||||
title: t`No fields detected`,
|
||||
description: t`No fields were detected in the document`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
title: t`Processing error`,
|
||||
description: t`An unexpected error occurred while processing pages.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
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 { RecipientDetectionPromptDialog } from '~/components/dialogs/recipient-detection-prompt-dialog';
|
||||
import { SuggestedRecipientsDialog } from '~/components/dialogs/suggested-recipients-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import {
|
||||
type RecipientForCreation,
|
||||
@ -61,7 +61,6 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
const [showExtractionPrompt, setShowExtractionPrompt] = useState(false);
|
||||
const [uploadedDocumentId, setUploadedDocumentId] = useState<string | null>(null);
|
||||
const [pendingRecipients, setPendingRecipients] = useState<RecipientForCreation[] | null>(null);
|
||||
const [showRecipientsDialog, setShowRecipientsDialog] = useState(false);
|
||||
const [shouldNavigateAfterPromptClose, setShouldNavigateAfterPromptClose] = useState(true);
|
||||
|
||||
const userTimezone =
|
||||
@ -124,7 +123,6 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
// Show AI prompt dialog for documents
|
||||
setUploadedDocumentId(id);
|
||||
setPendingRecipients(null);
|
||||
setShowRecipientsDialog(false);
|
||||
setShouldNavigateAfterPromptClose(true);
|
||||
setShowExtractionPrompt(true);
|
||||
} else {
|
||||
@ -148,7 +146,7 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
.otherwise(() => t`An error occurred during upload.`);
|
||||
|
||||
toast({
|
||||
title: t`Error`,
|
||||
title: t`Upload failed`,
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
@ -252,8 +250,6 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
|
||||
setPendingRecipients(recipientsWithEmails);
|
||||
setShouldNavigateAfterPromptClose(false);
|
||||
setShowExtractionPrompt(false);
|
||||
setShowRecipientsDialog(true);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && error.message === 'NO_RECIPIENTS_DETECTED')) {
|
||||
const parsedError = AppError.parseError(error);
|
||||
@ -276,12 +272,6 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
navigateToEnvelopeEditor();
|
||||
};
|
||||
|
||||
const handleRecipientsCancel = () => {
|
||||
setShowRecipientsDialog(false);
|
||||
setPendingRecipients(null);
|
||||
navigateToEnvelopeEditor();
|
||||
};
|
||||
|
||||
const handleRecipientsConfirm = async (recipientsToCreate: RecipientForCreation[]) => {
|
||||
if (!uploadedDocumentId) {
|
||||
return;
|
||||
@ -295,11 +285,17 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
|
||||
toast({
|
||||
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,
|
||||
});
|
||||
|
||||
setShowRecipientsDialog(false);
|
||||
setShowExtractionPrompt(false);
|
||||
setPendingRecipients(null);
|
||||
navigateToEnvelopeEditor();
|
||||
} catch (error) {
|
||||
@ -401,20 +397,8 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
onOpenChange={handlePromptDialogOpenChange}
|
||||
onAccept={handleStartRecipientDetection}
|
||||
onSkip={handleSkipRecipientDetection}
|
||||
/>
|
||||
|
||||
<SuggestedRecipientsDialog
|
||||
open={showRecipientsDialog}
|
||||
recipients={pendingRecipients}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleRecipientsCancel();
|
||||
} else {
|
||||
setShowRecipientsDialog(true);
|
||||
}
|
||||
}}
|
||||
onCancel={handleRecipientsCancel}
|
||||
onSubmit={handleRecipientsConfirm}
|
||||
onRecipientsSubmit={handleRecipientsConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
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 { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
@ -28,8 +28,8 @@ import {
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { RecipientDetectionPromptDialog } from '~/components/dialogs/recipient-detection-prompt-dialog';
|
||||
import { SuggestedRecipientsDialog } from '~/components/dialogs/suggested-recipients-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { detectFieldsInDocument } from '~/utils/detect-document-fields';
|
||||
import {
|
||||
type RecipientForCreation,
|
||||
detectRecipientsInDocument,
|
||||
@ -65,8 +65,8 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
const [showExtractionPrompt, setShowExtractionPrompt] = useState(false);
|
||||
const [uploadedDocumentId, setUploadedDocumentId] = useState<string | null>(null);
|
||||
const [pendingRecipients, setPendingRecipients] = useState<RecipientForCreation[] | null>(null);
|
||||
const [showRecipientsDialog, setShowRecipientsDialog] = useState(false);
|
||||
const [shouldNavigateAfterPromptClose, setShouldNavigateAfterPromptClose] = useState(true);
|
||||
const [isAutoAddingFields, setIsAutoAddingFields] = useState(false);
|
||||
|
||||
const { mutateAsync: createEnvelope } = trpc.envelope.create.useMutation();
|
||||
const { mutateAsync: createRecipients } = trpc.envelope.recipient.createMany.useMutation();
|
||||
@ -121,11 +121,9 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
? formatDocumentsPath(team.url)
|
||||
: formatTemplatesPath(team.url);
|
||||
|
||||
// Show AI prompt dialog for documents only
|
||||
if (type === EnvelopeType.DOCUMENT) {
|
||||
setUploadedDocumentId(id);
|
||||
setPendingRecipients(null);
|
||||
setShowRecipientsDialog(false);
|
||||
setShouldNavigateAfterPromptClose(true);
|
||||
setShowExtractionPrompt(true);
|
||||
|
||||
@ -135,7 +133,6 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
duration: 5000,
|
||||
});
|
||||
} else {
|
||||
// Templates - navigate immediately
|
||||
await navigate(`${pathPrefix}/${id}/edit`);
|
||||
|
||||
toast({
|
||||
@ -162,7 +159,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
.otherwise(() => t`An error occurred while uploading your document.`);
|
||||
|
||||
toast({
|
||||
title: t`Error`,
|
||||
title: t`Upload failed`,
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
@ -229,12 +226,11 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
|
||||
setPendingRecipients(recipientsWithEmails);
|
||||
setShouldNavigateAfterPromptClose(false);
|
||||
setShowExtractionPrompt(false);
|
||||
setShowRecipientsDialog(true);
|
||||
} 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({
|
||||
title: t`Failed to analyze recipients`,
|
||||
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();
|
||||
};
|
||||
|
||||
const handleRecipientsCancel = () => {
|
||||
setShowRecipientsDialog(false);
|
||||
setPendingRecipients(null);
|
||||
navigateToEnvelopeEditor();
|
||||
};
|
||||
|
||||
const handleRecipientsConfirm = async (recipientsToCreate: RecipientForCreation[]) => {
|
||||
if (!uploadedDocumentId) {
|
||||
return;
|
||||
@ -272,11 +262,17 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
|
||||
toast({
|
||||
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,
|
||||
});
|
||||
|
||||
setShowRecipientsDialog(false);
|
||||
setShowExtractionPrompt(false);
|
||||
setPendingRecipients(null);
|
||||
navigateToEnvelopeEditor();
|
||||
} catch (err) {
|
||||
@ -289,7 +285,72 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
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}
|
||||
onAccept={handleStartRecipientDetection}
|
||||
onSkip={handleSkipRecipientDetection}
|
||||
/>
|
||||
|
||||
<SuggestedRecipientsDialog
|
||||
open={showRecipientsDialog}
|
||||
recipients={pendingRecipients}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleRecipientsCancel();
|
||||
} else {
|
||||
setShowRecipientsDialog(true);
|
||||
}
|
||||
}}
|
||||
onCancel={handleRecipientsCancel}
|
||||
onSubmit={handleRecipientsConfirm}
|
||||
onRecipientsSubmit={handleRecipientsConfirm}
|
||||
onAutoAddFields={handleAutoAddFields}
|
||||
isProcessingRecipients={isAutoAddingFields}
|
||||
/>
|
||||
</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;
|
||||
};
|
||||
Reference in New Issue
Block a user