mirror of
https://github.com/documenso/documenso.git
synced 2025-11-20 03:32:14 +10:00
Merge branch 'main' into feat/expiry-links
This commit is contained in:
@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
@ -46,7 +47,7 @@ import { Form } from '../form/form';
|
||||
import { RecipientSelector } from '../recipient-selector';
|
||||
import { useStep } from '../stepper';
|
||||
import { useToast } from '../use-toast';
|
||||
import type { TAddFieldsFormSchema } from './add-fields.types';
|
||||
import { type TAddFieldsFormSchema, ZAddFieldsFormSchema } from './add-fields.types';
|
||||
import {
|
||||
DocumentFlowFormContainerActions,
|
||||
DocumentFlowFormContainerContent,
|
||||
@ -75,6 +76,7 @@ export type FieldFormType = {
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
signerEmail: string;
|
||||
recipientId: number;
|
||||
fieldMeta?: FieldMeta;
|
||||
};
|
||||
|
||||
@ -127,9 +129,11 @@ export const AddFieldsFormPartial = ({
|
||||
pageHeight: Number(field.height),
|
||||
signerEmail:
|
||||
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
||||
recipientId: field.recipientId,
|
||||
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
|
||||
})),
|
||||
},
|
||||
resolver: zodResolver(ZAddFieldsFormSchema),
|
||||
});
|
||||
|
||||
useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt));
|
||||
@ -323,6 +327,7 @@ export const AddFieldsFormPartial = ({
|
||||
|
||||
const field = {
|
||||
formId: nanoid(12),
|
||||
nativeId: undefined,
|
||||
type: selectedField,
|
||||
pageNumber,
|
||||
pageX,
|
||||
@ -330,6 +335,7 @@ export const AddFieldsFormPartial = ({
|
||||
pageWidth: fieldPageWidth,
|
||||
pageHeight: fieldPageHeight,
|
||||
signerEmail: selectedSigner.email,
|
||||
recipientId: selectedSigner.id,
|
||||
fieldMeta: undefined,
|
||||
};
|
||||
|
||||
@ -414,6 +420,7 @@ export const AddFieldsFormPartial = ({
|
||||
nativeId: undefined,
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||
recipientId: selectedSigner?.id ?? lastActiveField.recipientId,
|
||||
pageX: lastActiveField.pageX + 3,
|
||||
pageY: lastActiveField.pageY + 3,
|
||||
};
|
||||
@ -438,6 +445,7 @@ export const AddFieldsFormPartial = ({
|
||||
nativeId: undefined,
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||
recipientId: selectedSigner?.id ?? lastActiveField.recipientId,
|
||||
pageNumber,
|
||||
};
|
||||
|
||||
@ -470,6 +478,7 @@ export const AddFieldsFormPartial = ({
|
||||
nativeId: undefined,
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
|
||||
recipientId: selectedSigner?.id ?? copiedField.recipientId,
|
||||
pageX: copiedField.pageX + 3,
|
||||
pageY: copiedField.pageY + 3,
|
||||
});
|
||||
@ -663,7 +672,7 @@ export const AddFieldsFormPartial = ({
|
||||
|
||||
{isDocumentPdfLoaded &&
|
||||
localFields.map((field, index) => {
|
||||
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail);
|
||||
const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
|
||||
const hasFieldError =
|
||||
emptyCheckboxFields.find((f) => f.formId === field.formId) ||
|
||||
emptyRadioFields.find((f) => f.formId === field.formId) ||
|
||||
|
||||
@ -10,6 +10,7 @@ export const ZAddFieldsFormSchema = z.object({
|
||||
nativeId: z.number().optional(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
signerEmail: z.string().min(1),
|
||||
recipientId: z.number().min(1),
|
||||
pageNumber: z.number().min(1),
|
||||
pageX: z.number().min(0),
|
||||
pageY: z.number().min(0),
|
||||
|
||||
@ -242,6 +242,7 @@ export const AddSettingsFormPartial = ({
|
||||
className="bg-background"
|
||||
{...field}
|
||||
disabled={document.status !== DocumentStatus.DRAFT || field.disabled}
|
||||
maxLength={255}
|
||||
onBlur={handleAutoSave}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useId, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useId, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { DropResult, SensorAPI } from '@hello-pangea/dnd';
|
||||
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
|
||||
@ -15,11 +15,13 @@ import { prop, sortBy } from 'remeda';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
|
||||
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
|
||||
@ -29,6 +31,8 @@ import {
|
||||
DocumentReadOnlyFields,
|
||||
mapFieldsWithRecipients,
|
||||
} from '../../components/document/document-read-only-fields';
|
||||
import type { RecipientAutoCompleteOption } from '../../components/recipient/recipient-autocomplete-input';
|
||||
import { RecipientAutoCompleteInput } from '../../components/recipient/recipient-autocomplete-input';
|
||||
import { Button } from '../button';
|
||||
import { Checkbox } from '../checkbox';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
|
||||
@ -49,6 +53,10 @@ import {
|
||||
import { SigningOrderConfirmation } from './signing-order-confirmation';
|
||||
import type { DocumentFlowStep } from './types';
|
||||
|
||||
type AutoSaveResponse = {
|
||||
recipients: Recipient[];
|
||||
};
|
||||
|
||||
export type AddSignersFormProps = {
|
||||
documentFlow: DocumentFlowStep;
|
||||
recipients: Recipient[];
|
||||
@ -56,7 +64,7 @@ export type AddSignersFormProps = {
|
||||
signingOrder?: DocumentSigningOrder | null;
|
||||
allowDictateNextSigner?: boolean;
|
||||
onSubmit: (_data: TAddSignersFormSchema) => void;
|
||||
onAutoSave: (_data: TAddSignersFormSchema) => Promise<void>;
|
||||
onAutoSave: (_data: TAddSignersFormSchema) => Promise<AutoSaveResponse>;
|
||||
isDocumentPdfLoaded: boolean;
|
||||
};
|
||||
|
||||
@ -75,6 +83,10 @@ export const AddSignersFormPartial = ({
|
||||
const { remaining } = useLimits();
|
||||
const { user } = useSession();
|
||||
|
||||
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
|
||||
|
||||
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
|
||||
|
||||
const initialId = useId();
|
||||
const $sensorApi = useRef<SensorAPI | null>(null);
|
||||
|
||||
@ -82,6 +94,17 @@ export const AddSignersFormPartial = ({
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery(
|
||||
{
|
||||
query: debouncedRecipientSearchQuery,
|
||||
},
|
||||
{
|
||||
enabled: debouncedRecipientSearchQuery.length > 1,
|
||||
},
|
||||
);
|
||||
|
||||
const recipientSuggestions = recipientSuggestionsData?.results || [];
|
||||
|
||||
const defaultRecipients = [
|
||||
{
|
||||
formId: initialId,
|
||||
@ -189,7 +212,44 @@ export const AddSignersFormPartial = ({
|
||||
|
||||
const formData = form.getValues();
|
||||
|
||||
scheduleSave(formData);
|
||||
scheduleSave(formData, (response) => {
|
||||
// Sync the response recipients back to form state to prevent duplicates
|
||||
if (response?.recipients) {
|
||||
const currentSigners = form.getValues('signers');
|
||||
const updatedSigners = currentSigners.map((signer) => {
|
||||
// Find the matching recipient from the response using nativeId
|
||||
const matchingRecipient = response.recipients.find(
|
||||
(recipient) => recipient.id === signer.nativeId,
|
||||
);
|
||||
|
||||
if (matchingRecipient) {
|
||||
// Update the signer with the server-returned data, especially the ID
|
||||
return {
|
||||
...signer,
|
||||
nativeId: matchingRecipient.id,
|
||||
};
|
||||
}
|
||||
|
||||
// For new signers without nativeId, match by email and update with server ID
|
||||
if (!signer.nativeId) {
|
||||
const newRecipient = response.recipients.find(
|
||||
(recipient) => recipient.email === signer.email,
|
||||
);
|
||||
if (newRecipient) {
|
||||
return {
|
||||
...signer,
|
||||
nativeId: newRecipient.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return signer;
|
||||
});
|
||||
|
||||
// Update the form state with the synced data
|
||||
form.setValue('signers', updatedSigners, { shouldValidate: false });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email);
|
||||
@ -286,10 +346,12 @@ export const AddSignersFormPartial = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter' && event.target instanceof HTMLInputElement) {
|
||||
onAddSigner();
|
||||
}
|
||||
const handleRecipientAutoCompleteSelect = (
|
||||
index: number,
|
||||
suggestion: RecipientAutoCompleteOption,
|
||||
) => {
|
||||
setValue(`signers.${index}.email`, suggestion.email);
|
||||
setValue(`signers.${index}.name`, suggestion.name || '');
|
||||
};
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
@ -679,17 +741,26 @@ export const AddSignersFormPartial = ({
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
<RecipientAutoCompleteInput
|
||||
type="email"
|
||||
placeholder={_(msg`Email`)}
|
||||
{...field}
|
||||
value={field.value}
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.nativeId)
|
||||
}
|
||||
options={recipientSuggestions}
|
||||
onSelect={(suggestion) =>
|
||||
handleRecipientAutoCompleteSelect(index, suggestion)
|
||||
}
|
||||
onSearchQueryChange={(query) => {
|
||||
field.onChange(query);
|
||||
setRecipientSearchQuery(query);
|
||||
}}
|
||||
loading={isLoading}
|
||||
data-testid="signer-email-input"
|
||||
onKeyDown={onKeyDown}
|
||||
maxLength={254}
|
||||
onBlur={handleAutoSave}
|
||||
/>
|
||||
</FormControl>
|
||||
@ -719,7 +790,8 @@ export const AddSignersFormPartial = ({
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
<RecipientAutoCompleteInput
|
||||
type="text"
|
||||
placeholder={_(msg`Name`)}
|
||||
{...field}
|
||||
disabled={
|
||||
@ -727,7 +799,16 @@ export const AddSignersFormPartial = ({
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.nativeId)
|
||||
}
|
||||
onKeyDown={onKeyDown}
|
||||
options={recipientSuggestions}
|
||||
onSelect={(suggestion) =>
|
||||
handleRecipientAutoCompleteSelect(index, suggestion)
|
||||
}
|
||||
onSearchQueryChange={(query) => {
|
||||
field.onChange(query);
|
||||
setRecipientSearchQuery(query);
|
||||
}}
|
||||
loading={isLoading}
|
||||
maxLength={255}
|
||||
onBlur={handleAutoSave}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@ -4,33 +4,23 @@ import { z } from 'zod';
|
||||
|
||||
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
||||
|
||||
export const ZAddSignersFormSchema = z
|
||||
.object({
|
||||
signers: z.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
email: z
|
||||
.string()
|
||||
.email({ message: msg`Invalid email`.id })
|
||||
.min(1),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
||||
}),
|
||||
),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||
allowDictateNextSigner: z.boolean().default(false),
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
const emails = schema.signers.map((signer) => signer.email.toLowerCase());
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
// Dirty hack to handle errors when .root is populated for an array type
|
||||
{ message: msg`Signers must have unique emails`.id, path: ['signers__root'] },
|
||||
);
|
||||
export const ZAddSignersFormSchema = z.object({
|
||||
signers: z.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
email: z
|
||||
.string()
|
||||
.email({ message: msg`Invalid email`.id })
|
||||
.min(1),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
||||
}),
|
||||
),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||
allowDictateNextSigner: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;
|
||||
|
||||
@ -262,7 +262,7 @@ export const AddSubjectFormPartial = ({
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input {...field} maxLength={254} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
@ -300,7 +300,7 @@ export const AddSubjectFormPartial = ({
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input {...field} maxLength={255} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@ -326,7 +326,11 @@ export const AddSubjectFormPartial = ({
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Textarea className="bg-background mt-2 h-16 resize-none" {...field} />
|
||||
<Textarea
|
||||
className="bg-background mt-2 h-16 resize-none"
|
||||
{...field}
|
||||
maxLength={5000}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@ -299,6 +299,8 @@ export const FieldItem = ({
|
||||
}}
|
||||
ref={$el}
|
||||
data-field-id={field.nativeId}
|
||||
data-field-type={field.type}
|
||||
data-recipient-id={field.recipientId}
|
||||
>
|
||||
<FieldContent field={field} />
|
||||
|
||||
|
||||
@ -8,19 +8,14 @@ import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
export const ZDocumentFlowFormSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
|
||||
signers: z
|
||||
.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().min(1).email(),
|
||||
name: z.string(),
|
||||
}),
|
||||
)
|
||||
.refine((signers) => {
|
||||
const emails = signers.map((signer) => signer.email);
|
||||
return new Set(emails).size === emails.length;
|
||||
}, 'Signers must have unique emails'),
|
||||
signers: z.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().min(1).email(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
|
||||
fields: z.array(
|
||||
z.object({
|
||||
@ -28,6 +23,7 @@ export const ZDocumentFlowFormSchema = z.object({
|
||||
nativeId: z.number().optional(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
signerEmail: z.string().min(1).optional(),
|
||||
recipientId: z.number().min(1),
|
||||
pageNumber: z.number().min(1),
|
||||
pageX: z.number().min(0),
|
||||
pageY: z.number().min(0),
|
||||
|
||||
Reference in New Issue
Block a user