Merge branch 'main' into feat/expiry-links

This commit is contained in:
Lucas Smith
2025-10-06 16:39:34 +11:00
committed by GitHub
183 changed files with 10954 additions and 1513 deletions

View File

@ -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) ||

View File

@ -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),

View File

@ -242,6 +242,7 @@ export const AddSettingsFormPartial = ({
className="bg-background"
{...field}
disabled={document.status !== DocumentStatus.DRAFT || field.disabled}
maxLength={255}
onBlur={handleAutoSave}
/>
</FormControl>

View File

@ -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>

View File

@ -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>;

View File

@ -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>

View File

@ -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} />

View File

@ -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),