chore: merge main

This commit is contained in:
Catalin Pit
2025-09-11 14:58:42 +03:00
343 changed files with 14952 additions and 3564 deletions

View File

@ -7,6 +7,7 @@ import { SigningStatus } from '@prisma/client';
import { Clock, EyeOffIcon } from 'lucide-react';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
@ -21,6 +22,18 @@ import { PopoverHover } from '@documenso/ui/primitives/popover';
import { getRecipientColorStyles } from '../../lib/recipient-colors';
import { FieldContent } from '../../primitives/document-flow/field-content';
const getRecipientDisplayText = (recipient: { name: string; email: string }) => {
if (recipient.name && !isTemplateRecipientEmailPlaceholder(recipient.email)) {
return `${recipient.name} (${recipient.email})`;
}
if (recipient.name && isTemplateRecipientEmailPlaceholder(recipient.email)) {
return recipient.name;
}
return recipient.email;
};
export type DocumentReadOnlyFieldsProps = {
fields: DocumentField[];
documentMeta?: Pick<DocumentMeta | TemplateMeta, 'dateFormat'>;
@ -82,8 +95,10 @@ export const DocumentReadOnlyFields = ({
setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true }));
};
const highestPageNumber = Math.max(...fields.map((field) => field.page));
return (
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}>
{fields.map(
(field) =>
!hiddenFieldIds[field.secondaryId] && (
@ -145,9 +160,7 @@ export const DocumentReadOnlyFields = ({
</p>
<p className="text-muted-foreground mt-1 text-center text-xs">
{field.recipient.name
? `${field.recipient.name} (${field.recipient.email})`
: field.recipient.email}{' '}
{getRecipientDisplayText(field.recipient)}
</p>
<button

View File

@ -0,0 +1,106 @@
import React, { useRef, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { PopoverAnchor } from '@radix-ui/react-popover';
import { Popover, PopoverContent } from '@documenso/ui/primitives/popover';
import { Command, CommandGroup, CommandItem } from '../../primitives/command';
import { Input } from '../../primitives/input';
export type RecipientAutoCompleteOption = {
email: string;
name: string | null;
};
type RecipientAutoCompleteInputProps = {
type: 'email' | 'text';
value: string;
placeholder?: string;
disabled?: boolean;
loading?: boolean;
options: RecipientAutoCompleteOption[];
onSelect: (option: RecipientAutoCompleteOption) => void;
onSearchQueryChange: (query: string) => void;
};
type CombinedProps = RecipientAutoCompleteInputProps &
Omit<React.InputHTMLAttributes<HTMLInputElement>, keyof RecipientAutoCompleteInputProps>;
export const RecipientAutoCompleteInput = ({
value,
placeholder,
disabled,
loading,
onSearchQueryChange,
onSelect,
options = [],
onChange: _onChange,
...props
}: CombinedProps) => {
const [isOpen, setIsOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const onValueChange = (value: string) => {
setIsOpen(!!value.length);
onSearchQueryChange(value);
};
const handleSelectItem = (option: RecipientAutoCompleteOption) => {
setIsOpen(false);
onSelect(option);
};
return (
<Command>
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverAnchor asChild>
<Input
ref={inputRef}
className="w-full"
placeholder={placeholder}
value={value}
disabled={disabled}
onChange={(e) => onValueChange(e.target.value)}
{...props}
/>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-full p-0"
onOpenAutoFocus={(e) => {
e.preventDefault();
}}
>
{/* Not using <CommandEmpty /> here due to some weird behaviour */}
{options.length === 0 && (
<div className="px-2 py-1.5 text-sm">
{loading ? (
<Trans>Loading suggestions...</Trans>
) : (
<Trans>No suggestions found</Trans>
)}
</div>
)}
{options.length > 0 && (
<CommandGroup className="max-h-[250px] overflow-y-auto">
{options.map((option, index) => (
<CommandItem
key={`${index}-${option.email}`}
value={`${option.email}`}
className="cursor-pointer"
onSelect={() => handleSelectItem(option)}
>
{option.name} ({option.email})
</CommandItem>
))}
</CommandGroup>
)}
</PopoverContent>
</Popover>
</Command>
);
};

View File

@ -65,6 +65,27 @@ const CommandInput = React.forwardRef<
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandTextInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div cmdk-input-wrapper="">
<CommandPrimitive.Input
ref={ref}
className={cn(
'bg-background border-input ring-offset-background placeholder:text-muted-foreground/40 focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
{
'ring-2 !ring-red-500 transition-all': props['aria-invalid'],
},
)}
{...props}
/>
</div>
));
CommandTextInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
@ -147,6 +168,7 @@ export {
Command,
CommandDialog,
CommandInput,
CommandTextInput,
CommandList,
CommandEmpty,
CommandGroup,

View File

@ -65,7 +65,11 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
'bg-background animate-in data-[state=open]:fade-in-90 sm:zoom-in-90 data-[state=open]:slide-in-from-bottom-10 data-[state=open]:sm:slide-in-from-bottom-0 fixed z-50 grid w-full gap-4 rounded-b-lg border p-6 shadow-lg sm:max-w-lg sm:rounded-lg',
'bg-background animate-in data-[state=open]:fade-in-90 sm:zoom-in-90 data-[state=open]:slide-in-from-bottom-10 data-[state=open]:sm:slide-in-from-bottom-0 fixed z-50 grid w-full gap-4 border p-6 shadow-lg sm:max-w-lg sm:rounded-lg',
{
'rounded-b-xl': position === 'start',
'rounded-t-xl': position === 'end',
},
className,
)}
{...props}

View File

@ -21,6 +21,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { prop, sortBy } from 'remeda';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import {
@ -83,6 +84,7 @@ export type AddFieldsFormProps = {
recipients: Recipient[];
fields: Field[];
onSubmit: (_data: TAddFieldsFormSchema) => void;
onAutoSave: (_data: TAddFieldsFormSchema) => Promise<void>;
canGoBack?: boolean;
isDocumentPdfLoaded: boolean;
teamId: number;
@ -94,6 +96,7 @@ export const AddFieldsFormPartial = ({
recipients,
fields,
onSubmit,
onAutoSave,
canGoBack = false,
isDocumentPdfLoaded,
teamId,
@ -590,6 +593,20 @@ export const AddFieldsFormPartial = ({
}
};
const { scheduleSave } = useAutoSave(onAutoSave);
const handleAutoSave = async () => {
const isFormValid = await form.trigger();
if (!isFormValid) {
return;
}
const formData = form.getValues();
scheduleSave(formData);
};
return (
<>
{showAdvancedSettings && currentField ? (
@ -603,7 +620,14 @@ export const AddFieldsFormPartial = ({
fields={localFields}
onAdvancedSettings={handleAdvancedSettings}
isDocumentPdfLoaded={isDocumentPdfLoaded}
onSave={handleSavedFieldSettings}
onSave={(fieldState) => {
handleSavedFieldSettings(fieldState);
void handleAutoSave();
}}
onAutoSave={async (fieldState) => {
handleSavedFieldSettings(fieldState);
await handleAutoSave();
}}
/>
) : (
<>
@ -660,14 +684,26 @@ export const AddFieldsFormPartial = ({
defaultWidth={DEFAULT_WIDTH_PX}
passive={isFieldWithinBounds && !!selectedField}
onFocus={() => setLastActiveField(field)}
onBlur={() => setLastActiveField(null)}
onBlur={() => {
setLastActiveField(null);
void handleAutoSave();
}}
onMouseEnter={() => setLastActiveField(field)}
onMouseLeave={() => setLastActiveField(null)}
onResize={(options) => onFieldResize(options, index)}
onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)}
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
onRemove={() => {
remove(index);
void handleAutoSave();
}}
onDuplicate={() => {
onFieldCopy(null, { duplicate: true });
void handleAutoSave();
}}
onDuplicateAllPages={() => {
onFieldCopy(null, { duplicateAll: true });
void handleAutoSave();
}}
onAdvancedSettings={() => {
setCurrentField(field);
handleAdvancedSettings();

View File

@ -14,6 +14,7 @@ import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document';
@ -79,6 +80,7 @@ export type AddSettingsFormProps = {
document: TDocument;
currentTeamMemberRole?: TeamMemberRole;
onSubmit: (_data: TAddSettingsFormSchema) => void;
onAutoSave: (_data: TAddSettingsFormSchema) => Promise<void>;
};
export const AddSettingsFormPartial = ({
@ -89,6 +91,7 @@ export const AddSettingsFormPartial = ({
document,
currentTeamMemberRole,
onSubmit,
onAutoSave,
}: AddSettingsFormProps) => {
const { t } = useLingui();
@ -161,6 +164,28 @@ export const AddSettingsFormPartial = ({
document.documentMeta?.timezone,
]);
const { scheduleSave } = useAutoSave(onAutoSave);
const handleAutoSave = async () => {
const isFormValid = await form.trigger();
if (!isFormValid) {
return;
}
const formData = form.getValues();
/*
* Parse the form data through the Zod schema to handle transformations
* (like -1 -> undefined for the Document Global Auth Access)
*/
const parseResult = ZAddSettingsFormSchema.safeParse(formData);
if (parseResult.success) {
scheduleSave(parseResult.data);
}
};
return (
<>
<DocumentFlowFormContainerHeader
@ -196,6 +221,8 @@ export const AddSettingsFormPartial = ({
className="bg-background"
{...field}
disabled={document.status !== DocumentStatus.DRAFT || field.disabled}
maxLength={255}
onBlur={handleAutoSave}
/>
</FormControl>
<FormMessage />
@ -227,9 +254,13 @@ export const AddSettingsFormPartial = ({
<FormControl>
<Select
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
>
<SelectTrigger className="bg-background">
<SelectValue />
@ -261,9 +292,13 @@ export const AddSettingsFormPartial = ({
<FormControl>
<DocumentGlobalAuthAccessSelect
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
@ -286,7 +321,10 @@ export const AddSettingsFormPartial = ({
canUpdateVisibility={canUpdateVisibility}
currentTeamMemberRole={currentTeamMemberRole}
{...field}
onValueChange={field.onChange}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
/>
</FormControl>
</FormItem>
@ -307,9 +345,13 @@ export const AddSettingsFormPartial = ({
<FormControl>
<DocumentGlobalAuthActionSelect
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
@ -347,7 +389,7 @@ export const AddSettingsFormPartial = ({
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
<Input className="bg-background" {...field} onBlur={handleAutoSave} />
</FormControl>
<FormMessage />
@ -372,7 +414,10 @@ export const AddSettingsFormPartial = ({
value: option.value,
}))}
selectedValues={field.value}
onChange={field.onChange}
onChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
className="bg-background w-full"
emptySelectionPlaceholder="Select signature types"
/>
@ -394,8 +439,12 @@ export const AddSettingsFormPartial = ({
<FormControl>
<Select
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
value={field.value}
onValueChange={field.onChange}
disabled={documentHasBeenSent}
>
<SelectTrigger className="bg-background">
@ -430,8 +479,12 @@ export const AddSettingsFormPartial = ({
<Combobox
className="bg-background"
options={TIME_ZONES}
{...field}
onChange={(value) => {
value && field.onChange(value);
void handleAutoSave();
}}
value={field.value}
onChange={(value) => value && field.onChange(value)}
disabled={documentHasBeenSent}
/>
</FormControl>
@ -462,7 +515,7 @@ export const AddSettingsFormPartial = ({
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
<Input className="bg-background" {...field} onBlur={handleAutoSave} />
</FormControl>
<FormMessage />

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';
@ -14,11 +14,14 @@ import { useFieldArray, useForm } from 'react-hook-form';
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';
@ -28,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';
@ -55,6 +60,7 @@ export type AddSignersFormProps = {
signingOrder?: DocumentSigningOrder | null;
allowDictateNextSigner?: boolean;
onSubmit: (_data: TAddSignersFormSchema) => void;
onAutoSave: (_data: TAddSignersFormSchema) => Promise<void>;
isDocumentPdfLoaded: boolean;
};
@ -65,6 +71,7 @@ export const AddSignersFormPartial = ({
signingOrder,
allowDictateNextSigner,
onSubmit,
onAutoSave,
isDocumentPdfLoaded,
}: AddSignersFormProps) => {
const { _ } = useLingui();
@ -72,6 +79,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);
@ -79,6 +90,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,
@ -166,6 +188,29 @@ export const AddSignersFormPartial = ({
name: 'signers',
});
const emptySigners = useCallback(
() => form.getValues('signers').filter((signer) => signer.email === ''),
[form],
);
const { scheduleSave } = useAutoSave(onAutoSave);
const handleAutoSave = async () => {
if (emptySigners().length > 0) {
return;
}
const isFormValid = await form.trigger();
if (!isFormValid) {
return;
}
const formData = form.getValues();
scheduleSave(formData);
};
const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email);
const isUserAlreadyARecipient = watchedSigners.some(
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
@ -216,31 +261,56 @@ export const AddSignersFormPartial = ({
const formStateIndex = form.getValues('signers').findIndex((s) => s.formId === signer.formId);
if (formStateIndex !== -1) {
removeSigner(formStateIndex);
const updatedSigners = form.getValues('signers').filter((s) => s.formId !== signer.formId);
form.setValue('signers', normalizeSigningOrders(updatedSigners));
form.setValue('signers', normalizeSigningOrders(updatedSigners), {
shouldValidate: true,
shouldDirty: true,
});
void handleAutoSave();
}
};
const onAddSelfSigner = () => {
if (emptySignerIndex !== -1) {
setValue(`signers.${emptySignerIndex}.name`, user?.name ?? '');
setValue(`signers.${emptySignerIndex}.email`, user?.email ?? '');
} else {
appendSigner({
formId: nanoid(12),
name: user?.name ?? '',
email: user?.email ?? '',
role: RecipientRole.SIGNER,
actionAuth: [],
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
setValue(`signers.${emptySignerIndex}.name`, user?.name ?? '', {
shouldValidate: true,
shouldDirty: true,
});
setValue(`signers.${emptySignerIndex}.email`, user?.email ?? '', {
shouldValidate: true,
shouldDirty: true,
});
form.setFocus(`signers.${emptySignerIndex}.email`);
} else {
appendSigner(
{
formId: nanoid(12),
name: user?.name ?? '',
email: user?.email ?? '',
role: RecipientRole.SIGNER,
actionAuth: [],
signingOrder:
signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
},
{
shouldFocus: true,
},
);
void form.trigger('signers');
}
};
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(
@ -263,7 +333,10 @@ export const AddSignersFormPartial = ({
signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : index + 1,
}));
form.setValue('signers', updatedSigners);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
const lastSigner = updatedSigners[updatedSigners.length - 1];
if (lastSigner.role === RecipientRole.ASSISTANT) {
@ -276,8 +349,10 @@ export const AddSignersFormPartial = ({
}
await form.trigger('signers');
void handleAutoSave();
},
[form, canRecipientBeModified, watchedSigners, toast],
[form, canRecipientBeModified, watchedSigners, handleAutoSave, toast],
);
const handleRoleChange = useCallback(
@ -287,7 +362,10 @@ export const AddSignersFormPartial = ({
// Handle parallel to sequential conversion for assistants
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL);
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, {
shouldValidate: true,
shouldDirty: true,
});
toast({
title: _(msg`Signing order is enabled.`),
description: _(msg`You cannot add assistants when signing order is disabled.`),
@ -302,7 +380,10 @@ export const AddSignersFormPartial = ({
signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : idx + 1,
}));
form.setValue('signers', updatedSigners);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
toast({
@ -341,7 +422,10 @@ export const AddSignersFormPartial = ({
signingOrder: !canRecipientBeModified(s.nativeId) ? s.signingOrder : idx + 1,
}));
form.setValue('signers', updatedSigners);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
toast({
@ -364,9 +448,20 @@ export const AddSignersFormPartial = ({
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
}));
form.setValue('signers', updatedSigners);
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL);
form.setValue('allowDictateNextSigner', false);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, {
shouldValidate: true,
shouldDirty: true,
});
form.setValue('allowDictateNextSigner', false, {
shouldValidate: true,
shouldDirty: true,
});
void handleAutoSave();
}, [form]);
return (
@ -408,19 +503,39 @@ export const AddSignersFormPartial = ({
// If sequential signing is turned off, disable dictate next signer
if (!checked) {
form.setValue('allowDictateNextSigner', false);
form.setValue('allowDictateNextSigner', false, {
shouldValidate: true,
shouldDirty: true,
});
}
void handleAutoSave();
}}
disabled={isSubmitting || hasDocumentBeenSent}
disabled={isSubmitting || hasDocumentBeenSent || emptySigners().length !== 0}
/>
</FormControl>
<FormLabel
htmlFor="signingOrder"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Enable signing order</Trans>
</FormLabel>
<div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
<FormLabel
htmlFor="signingOrder"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Enable signing order</Trans>
</FormLabel>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground ml-1 cursor-help">
<HelpCircle className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 p-4">
<p>
<Trans>Add 2 or more signers to enable signing order.</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
</FormItem>
)}
/>
@ -435,12 +550,15 @@ export const AddSignersFormPartial = ({
{...field}
id="allowDictateNextSigner"
checked={value}
onCheckedChange={field.onChange}
onCheckedChange={(checked) => {
field.onChange(checked);
void handleAutoSave();
}}
disabled={isSubmitting || hasDocumentBeenSent || !isSigningOrderSequential}
/>
</FormControl>
<div className="flex items-center">
<div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
<FormLabel
htmlFor="allowDictateNextSigner"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
@ -533,6 +651,7 @@ export const AddSignersFormPartial = ({
<Input
type="number"
max={signers.length}
data-testid="signing-order-input"
className={cn(
'w-full text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
@ -541,10 +660,12 @@ export const AddSignersFormPartial = ({
onChange={(e) => {
field.onChange(e);
handleSigningOrderChange(index, e.target.value);
void handleAutoSave();
}}
onBlur={(e) => {
field.onBlur();
handleSigningOrderChange(index, e.target.value);
void handleAutoSave();
}}
disabled={
snapshot.isDragging ||
@ -579,16 +700,28 @@ export const AddSignersFormPartial = ({
)}
<FormControl>
<Input
<RecipientAutoCompleteInput
type="email"
placeholder={_(msg`Email`)}
{...field}
value={field.value}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.nativeId)
}
onKeyDown={onKeyDown}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
console.log('onSearchQueryChange', query);
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
data-testid="signer-email-input"
maxLength={254}
onBlur={handleAutoSave}
/>
</FormControl>
@ -617,7 +750,8 @@ export const AddSignersFormPartial = ({
)}
<FormControl>
<Input
<RecipientAutoCompleteInput
type="text"
placeholder={_(msg`Name`)}
{...field}
disabled={
@ -625,7 +759,17 @@ 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>
@ -668,6 +812,7 @@ export const AddSignersFormPartial = ({
<div className="col-span-2 flex gap-x-2">
<FormField
control={form.control}
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem
@ -681,10 +826,11 @@ export const AddSignersFormPartial = ({
<RecipientRoleSelect
{...field}
isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) =>
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole)
}
handleRoleChange(index, value as RecipientRole);
void handleAutoSave();
}}
disabled={
snapshot.isDragging ||
isSubmitting ||
@ -706,6 +852,7 @@ export const AddSignersFormPartial = ({
'mb-6': form.formState.errors.signers?.[index],
},
)}
data-testid="remove-signer-button"
disabled={
snapshot.isDragging ||
isSubmitting ||

View File

@ -1,3 +1,5 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
@ -8,6 +10,7 @@ import { AnimatePresence, motion } from 'framer-motion';
import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TDocument } from '@documenso/lib/types/document';
@ -60,6 +63,7 @@ export type AddSubjectFormProps = {
fields: Field[];
document: TDocument;
onSubmit: (_data: TAddSubjectFormSchema) => void;
onAutoSave: (_data: TAddSubjectFormSchema) => Promise<void>;
isDocumentPdfLoaded: boolean;
};
@ -69,6 +73,7 @@ export const AddSubjectFormPartial = ({
fields: fields,
document,
onSubmit,
onAutoSave,
isDocumentPdfLoaded,
}: AddSubjectFormProps) => {
const { _ } = useLingui();
@ -95,6 +100,8 @@ export const AddSubjectFormPartial = ({
handleSubmit,
setValue,
watch,
trigger,
getValues,
formState: { isSubmitting },
} = form;
@ -129,6 +136,35 @@ export const AddSubjectFormPartial = ({
const onFormSubmit = handleSubmit(onSubmit);
const { currentStep, totalSteps, previousStep } = useStep();
const { scheduleSave } = useAutoSave(onAutoSave);
const handleAutoSave = async () => {
const isFormValid = await trigger();
if (!isFormValid) {
return;
}
const formData = getValues();
scheduleSave(formData);
};
useEffect(() => {
const container = window.document.getElementById('document-flow-form-container');
const handleBlur = () => {
void handleAutoSave();
};
if (container) {
container.addEventListener('blur', handleBlur, true);
return () => {
container.removeEventListener('blur', handleBlur, true);
};
}
}, []);
return (
<>
<DocumentFlowFormContainerHeader
@ -185,7 +221,6 @@ export const AddSubjectFormPartial = ({
<FormLabel>
<Trans>Email Sender</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
@ -227,7 +262,7 @@ export const AddSubjectFormPartial = ({
</FormLabel>
<FormControl>
<Input {...field} />
<Input {...field} maxLength={254} />
</FormControl>
<FormMessage />
@ -265,7 +300,7 @@ export const AddSubjectFormPartial = ({
</FormLabel>
<FormControl>
<Input {...field} />
<Input {...field} maxLength={255} />
</FormControl>
<FormMessage />
</FormItem>
@ -291,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

@ -6,7 +6,10 @@ import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-emai
export const ZAddSubjectFormSchema = z.object({
meta: z.object({
emailId: z.string().nullable(),
emailReplyTo: z.string().email().optional(),
emailReplyTo: z.preprocess(
(val) => (val === '' ? undefined : val),
z.string().email().optional(),
),
// emailReplyName: z.string().optional(),
subject: z.string(),
message: z.string(),

View File

@ -160,14 +160,14 @@ export const FieldContent = ({ field, documentMeta }: FieldIconProps) => {
);
}
let textToDisplay = fieldMeta?.label || _(FRIENDLY_FIELD_TYPE[type]) || '';
const labelToDisplay = fieldMeta?.label || _(FRIENDLY_FIELD_TYPE[type]) || '';
let textToDisplay: string | undefined;
const isSignatureField =
field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE;
// Trim default labels.
if (textToDisplay.length > 20) {
textToDisplay = textToDisplay.substring(0, 20) + '...';
if (field.type === FieldType.TEXT && field.fieldMeta?.type === 'text' && field.fieldMeta?.text) {
textToDisplay = field.fieldMeta.text;
}
if (field.inserted) {
@ -190,18 +190,19 @@ export const FieldContent = ({ field, documentMeta }: FieldIconProps) => {
const textAlign = fieldMeta && 'textAlign' in fieldMeta ? fieldMeta.textAlign : 'left';
return (
<div
className={cn(
'text-field-card-foreground flex h-full w-full items-center justify-center gap-x-1.5 overflow-clip whitespace-nowrap text-center text-[clamp(0.07rem,25cqw,0.825rem)]',
{
// Using justify instead of align because we also vertically center the text.
'justify-start': field.inserted && !isSignatureField && textAlign === 'left',
'justify-end': field.inserted && !isSignatureField && textAlign === 'right',
'font-signature text-[clamp(0.07rem,25cqw,1.125rem)]': isSignatureField,
},
)}
>
{textToDisplay}
<div className="flex h-full w-full items-center overflow-hidden">
<p
className={cn(
'text-foreground w-full whitespace-pre-wrap text-left text-[clamp(0.07rem,25cqw,0.825rem)] duration-200',
{
'!text-center': textAlign === 'center' || !textToDisplay,
'!text-right': textAlign === 'right',
'font-signature text-[clamp(0.07rem,25cqw,1.125rem)]': isSignatureField,
},
)}
>
{textToDisplay || labelToDisplay}
</p>
</div>
);
};

View File

@ -6,6 +6,7 @@ import { useLingui } from '@lingui/react';
import { FieldType } from '@prisma/client';
import { match } from 'ts-pattern';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import {
type TBaseFieldMeta as BaseFieldMeta,
type TCheckboxFieldMeta as CheckboxFieldMeta,
@ -48,6 +49,7 @@ export type FieldAdvancedSettingsProps = {
onAdvancedSettings?: () => void;
isDocumentPdfLoaded?: boolean;
onSave?: (fieldState: FieldMeta) => void;
onAutoSave?: (fieldState: FieldMeta) => Promise<void>;
};
export type FieldMetaKeys =
@ -146,7 +148,16 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => {
export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSettingsProps>(
(
{ title, description, field, fields, onAdvancedSettings, isDocumentPdfLoaded = true, onSave },
{
title,
description,
field,
fields,
onAdvancedSettings,
isDocumentPdfLoaded = true,
onSave,
onAutoSave,
},
ref,
) => {
const { _ } = useLingui();
@ -177,6 +188,24 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fieldMeta]);
const { scheduleSave } = useAutoSave(onAutoSave || (async () => {}));
const handleAutoSave = () => {
if (errors.length === 0) {
scheduleSave(fieldState);
}
};
// Auto-save to localStorage and schedule remote save when fieldState changes
useEffect(() => {
try {
localStorage.setItem(localStorageKey, JSON.stringify(fieldState));
handleAutoSave();
} catch (error) {
console.error('Failed to save to localStorage:', error);
}
}, [fieldState, localStorageKey, handleAutoSave]);
const handleFieldChange = (
key: FieldMetaKeys,
value:
@ -325,7 +354,10 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
)}
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter className="mt-auto">
<DocumentFlowFormContainerFooter
className="mt-auto"
data-testid="field-advanced-settings-footer"
>
<DocumentFlowFormContainerActions
goNextLabel={msg`Save`}
goBackLabel={msg`Cancel`}

View File

@ -96,7 +96,7 @@ export const DocumentDropzone = ({
return (
<Button loading={loading} aria-disabled={disabled} {...getRootProps()} {...props}>
<div className="flex items-center gap-2">
<input {...getInputProps()} />
<input data-testid="document-upload-input" {...getInputProps()} />
{!loading && <Upload className="h-4 w-4" />}
{disabled ? _(disabledMessage) : _(heading[type])}
</div>

View File

@ -1,5 +1,3 @@
'use client';
import * as React from 'react';
import { useEffect } from 'react';

View File

@ -8,6 +8,8 @@ const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
@ -91,4 +93,4 @@ const PopoverHover = ({ trigger, children, contentProps, side = 'top' }: Popover
);
};
export { Popover, PopoverTrigger, PopoverContent, PopoverHover };
export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent, PopoverHover };

View File

@ -1,6 +1,7 @@
import type { HTMLAttributes } from 'react';
import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { KeyboardIcon, UploadCloudIcon } from 'lucide-react';
import { match } from 'ts-pattern';
@ -146,21 +147,21 @@ export const SignaturePad = ({
{drawSignatureEnabled && (
<TabsTrigger value="draw">
<SignatureIcon className="mr-2 size-4" />
Draw
<Trans>Draw</Trans>
</TabsTrigger>
)}
{typedSignatureEnabled && (
<TabsTrigger value="text">
<KeyboardIcon className="mr-2 size-4" />
Type
<Trans>Type</Trans>
</TabsTrigger>
)}
{uploadSignatureEnabled && (
<TabsTrigger value="image">
<UploadCloudIcon className="mr-2 size-4" />
Upload
<Trans>Upload</Trans>
</TabsTrigger>
)}
</TabsList>

View File

@ -21,9 +21,11 @@ import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import {
type TFieldMetaSchema as FieldMeta,
ZFieldMetaSchema,
@ -72,6 +74,7 @@ export type AddTemplateFieldsFormProps = {
recipients: Recipient[];
fields: Field[];
onSubmit: (_data: TAddTemplateFieldsFormSchema) => void;
onAutoSave: (_data: TAddTemplateFieldsFormSchema) => Promise<void>;
teamId: number;
};
@ -80,6 +83,7 @@ export const AddTemplateFieldsFormPartial = ({
recipients,
fields,
onSubmit,
onAutoSave,
teamId,
}: AddTemplateFieldsFormProps) => {
const { _ } = useLingui();
@ -120,6 +124,20 @@ export const AddTemplateFieldsFormPartial = ({
const onFormSubmit = form.handleSubmit(onSubmit);
const { scheduleSave } = useAutoSave(onAutoSave);
const handleAutoSave = async () => {
const isFormValid = await form.trigger();
if (!isFormValid) {
return;
}
const formData = form.getValues();
scheduleSave(formData);
};
const {
append,
remove,
@ -159,6 +177,7 @@ export const AddTemplateFieldsFormPartial = ({
};
append(newField);
void handleAutoSave();
return;
}
@ -186,6 +205,7 @@ export const AddTemplateFieldsFormPartial = ({
append(newField);
});
void handleAutoSave();
return;
}
@ -197,7 +217,15 @@ export const AddTemplateFieldsFormPartial = ({
});
}
},
[append, lastActiveField, selectedSigner?.email, selectedSigner?.id, toast],
[
append,
lastActiveField,
selectedSigner?.email,
selectedSigner?.id,
selectedSigner?.token,
toast,
handleAutoSave,
],
);
const onFieldPaste = useCallback(
@ -217,9 +245,18 @@ export const AddTemplateFieldsFormPartial = ({
pageX: copiedField.pageX + 3,
pageY: copiedField.pageY + 3,
});
void handleAutoSave();
}
},
[append, fieldClipboard, selectedSigner?.email, selectedSigner?.id, selectedSigner?.token],
[
append,
fieldClipboard,
selectedSigner?.email,
selectedSigner?.id,
selectedSigner?.token,
handleAutoSave,
],
);
useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt));
@ -377,8 +414,10 @@ export const AddTemplateFieldsFormPartial = ({
pageWidth,
pageHeight,
});
void handleAutoSave();
},
[getFieldPosition, localFields, update],
[getFieldPosition, localFields, update, handleAutoSave],
);
const onFieldMove = useCallback(
@ -400,8 +439,10 @@ export const AddTemplateFieldsFormPartial = ({
pageX,
pageY,
});
void handleAutoSave();
},
[getFieldPosition, localFields, update],
[getFieldPosition, localFields, update, handleAutoSave],
);
useEffect(() => {
@ -503,6 +544,7 @@ export const AddTemplateFieldsFormPartial = ({
});
form.setValue('fields', updatedFields);
void handleAutoSave();
};
return (
@ -518,6 +560,10 @@ export const AddTemplateFieldsFormPartial = ({
fields={localFields}
onAdvancedSettings={handleAdvancedSettings}
onSave={handleSavedFieldSettings}
onAutoSave={async (fieldState) => {
handleSavedFieldSettings(fieldState);
await handleAutoSave();
}}
/>
) : (
<>
@ -565,12 +611,22 @@ export const AddTemplateFieldsFormPartial = ({
defaultWidth={DEFAULT_WIDTH_PX}
passive={isFieldWithinBounds && !!selectedField}
onFocus={() => setLastActiveField(field)}
onBlur={() => setLastActiveField(null)}
onBlur={() => {
setLastActiveField(null);
void handleAutoSave();
}}
onResize={(options) => onFieldResize(options, index)}
onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)}
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
onRemove={() => {
remove(index);
void handleAutoSave();
}}
onDuplicate={() => {
onFieldCopy(null, { duplicate: true });
}}
onDuplicateAllPages={() => {
onFieldCopy(null, { duplicateAll: true });
}}
onAdvancedSettings={() => {
setCurrentField(field);
handleAdvancedSettings();
@ -593,15 +649,21 @@ export const AddTemplateFieldsFormPartial = ({
selectedSignerStyles?.comboxBoxTrigger,
)}
>
{selectedSigner?.email && (
<span className="flex-1 truncate text-left">
{selectedSigner?.name} ({selectedSigner?.email})
</span>
)}
{selectedSigner?.email &&
!isTemplateRecipientEmailPlaceholder(selectedSigner.email) && (
<span className="flex-1 truncate text-left">
{selectedSigner?.name} ({selectedSigner?.email})
</span>
)}
{selectedSigner?.email &&
isTemplateRecipientEmailPlaceholder(selectedSigner.email) && (
<span className="flex-1 truncate text-left">{selectedSigner?.name}</span>
)}
{!selectedSigner?.email && (
<span className="gradie flex-1 truncate text-left">
{selectedSigner?.email}
No recipient selected
</span>
)}
@ -657,15 +719,22 @@ export const AddTemplateFieldsFormPartial = ({
'text-foreground/80': recipient === selectedSigner,
})}
>
{recipient.name && (
<span title={`${recipient.name} (${recipient.email})`}>
{recipient.name} ({recipient.email})
</span>
)}
{recipient.name &&
!isTemplateRecipientEmailPlaceholder(recipient.email) && (
<span title={`${recipient.name} (${recipient.email})`}>
{recipient.name} ({recipient.email})
</span>
)}
{!recipient.name && (
<span title={recipient.email}>{recipient.email}</span>
)}
{recipient.name &&
isTemplateRecipientEmailPlaceholder(recipient.email) && (
<span title={recipient.name}>{recipient.name}</span>
)}
{!recipient.name &&
!isTemplateRecipientEmailPlaceholder(recipient.email) && (
<span title={recipient.email}>{recipient.email}</span>
)}
</span>
</CommandItem>
))}

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, 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';
@ -12,8 +12,10 @@ import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircle, Link2Icon, Plus, Trash } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import { generateRecipientPlaceholder } from '@documenso/lib/utils/templates';
@ -54,6 +56,7 @@ export type AddTemplatePlaceholderRecipientsFormProps = {
allowDictateNextSigner?: boolean;
templateDirectLink?: TemplateDirectLink | null;
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
onAutoSave: (_data: TAddTemplatePlacholderRecipientsFormSchema) => Promise<void>;
isDocumentPdfLoaded: boolean;
};
@ -66,6 +69,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
allowDictateNextSigner,
isDocumentPdfLoaded,
onSubmit,
onAutoSave,
}: AddTemplatePlaceholderRecipientsFormProps) => {
const initialId = useId();
const $sensorApi = useRef<SensorAPI | null>(null);
@ -122,15 +126,38 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
},
});
useEffect(() => {
form.reset({
signers: generateDefaultFormSigners(),
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
allowDictateNextSigner: allowDictateNextSigner ?? false,
});
const emptySigners = useCallback(
() => form.getValues('signers').filter((signer) => signer.email === ''),
[form],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recipients]);
const { scheduleSave } = useAutoSave(onAutoSave);
const handleAutoSave = async () => {
if (emptySigners().length > 0) {
return;
}
const isFormValid = await form.trigger();
if (!isFormValid) {
return;
}
const formData = form.getValues();
scheduleSave(formData);
};
// useEffect(() => {
// form.reset({
// signers: generateDefaultFormSigners(),
// signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
// allowDictateNextSigner: allowDictateNextSigner ?? false,
// });
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, [recipients]);
// Always show advanced settings if any recipient has auth options.
const alwaysShowAdvancedSettings = useMemo(() => {
@ -203,7 +230,12 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
const onRemoveSigner = (index: number) => {
removeSigner(index);
const updatedSigners = signers.filter((_, idx) => idx !== index);
form.setValue('signers', normalizeSigningOrders(updatedSigners));
form.setValue('signers', normalizeSigningOrders(updatedSigners), {
shouldValidate: true,
shouldDirty: true,
});
void handleAutoSave();
};
const isSignerDirectRecipient = (
@ -230,7 +262,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
signingOrder: index + 1,
}));
form.setValue('signers', updatedSigners);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
const lastSigner = updatedSigners[updatedSigners.length - 1];
if (lastSigner.role === RecipientRole.ASSISTANT) {
@ -243,64 +278,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
}
await form.trigger('signers');
void handleAutoSave();
},
[form, watchedSigners, toast],
);
const triggerDragAndDrop = useCallback(
(fromIndex: number, toIndex: number) => {
if (!$sensorApi.current) {
return;
}
const draggableId = signers[fromIndex].id;
const preDrag = $sensorApi.current.tryGetLock(draggableId);
if (!preDrag) {
return;
}
const drag = preDrag.snapLift();
setTimeout(() => {
// Move directly to the target index
if (fromIndex < toIndex) {
for (let i = fromIndex; i < toIndex; i++) {
drag.moveDown();
}
} else {
for (let i = fromIndex; i > toIndex; i--) {
drag.moveUp();
}
}
setTimeout(() => {
drag.drop();
}, 500);
}, 0);
},
[signers],
);
const updateSigningOrders = useCallback(
(newIndex: number, oldIndex: number) => {
const updatedSigners = form.getValues('signers').map((signer, index) => {
if (index === oldIndex) {
return { ...signer, signingOrder: newIndex + 1 };
} else if (index >= newIndex && index < oldIndex) {
return { ...signer, signingOrder: (signer.signingOrder ?? index + 1) + 1 };
} else if (index <= newIndex && index > oldIndex) {
return { ...signer, signingOrder: Math.max(1, (signer.signingOrder ?? index + 1) - 1) };
}
return signer;
});
updatedSigners.forEach((signer, index) => {
form.setValue(`signers.${index}.signingOrder`, signer.signingOrder);
});
},
[form],
[form, watchedSigners, toast, handleAutoSave],
);
const handleSigningOrderChange = useCallback(
@ -328,7 +309,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
signingOrder: idx + 1,
}));
form.setValue('signers', updatedSigners);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
toast({
@ -338,8 +322,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
),
});
}
void handleAutoSave();
},
[form, toast],
[form, toast, handleAutoSave],
);
const handleRoleChange = useCallback(
@ -349,7 +335,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
// Handle parallel to sequential conversion for assistants
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL);
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, {
shouldValidate: true,
shouldDirty: true,
});
toast({
title: _(msg`Signing order is enabled.`),
description: _(msg`You cannot add assistants when signing order is disabled.`),
@ -364,7 +353,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
signingOrder: idx + 1,
}));
form.setValue('signers', updatedSigners);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
toast({
@ -374,8 +366,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
),
});
}
void handleAutoSave();
},
[form, toast],
[form, toast, handleAutoSave],
);
const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false);
@ -389,10 +383,21 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
}));
form.setValue('signers', updatedSigners);
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL);
form.setValue('allowDictateNextSigner', false);
}, [form]);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, {
shouldValidate: true,
shouldDirty: true,
});
form.setValue('allowDictateNextSigner', false, {
shouldValidate: true,
shouldDirty: true,
});
void handleAutoSave();
}, [form, handleAutoSave]);
return (
<>
@ -437,8 +442,13 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
// If sequential signing is turned off, disable dictate next signer
if (!checked) {
form.setValue('allowDictateNextSigner', false);
form.setValue('allowDictateNextSigner', false, {
shouldValidate: true,
shouldDirty: true,
});
}
void handleAutoSave();
}}
disabled={isSubmitting}
/>
@ -464,7 +474,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
{...field}
id="allowDictateNextSigner"
checked={value}
onCheckedChange={field.onChange}
onCheckedChange={(checked) => {
field.onChange(checked);
void handleAutoSave();
}}
disabled={isSubmitting || !isSigningOrderSequential}
/>
</FormControl>
@ -555,6 +568,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
<Input
type="number"
max={signers.length}
data-testid="placeholder-recipient-signing-order-input"
className={cn(
'w-full text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
@ -592,7 +606,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel required>
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
)}
@ -602,12 +616,20 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
type="email"
placeholder={_(msg`Email`)}
{...field}
value={
isTemplateRecipientEmailPlaceholder(field.value)
? ''
: field.value
}
disabled={
field.disabled ||
isSubmitting ||
signers[index].email === user?.email ||
isSignerDirectRecipient(signer)
}
maxLength={254}
onBlur={handleAutoSave}
data-testid="placeholder-recipient-email-input"
/>
</FormControl>
@ -642,6 +664,9 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
signers[index].email === user?.email ||
isSignerDirectRecipient(signer)
}
maxLength={255}
onBlur={handleAutoSave}
data-testid="placeholder-recipient-name-input"
/>
</FormControl>
@ -683,10 +708,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
<FormControl>
<RecipientRoleSelect
{...field}
onValueChange={(value) =>
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole)
}
handleRoleChange(index, value as RecipientRole);
}}
disabled={isSubmitting}
hideCCRecipients={isSignerDirectRecipient(signer)}
/>
@ -722,6 +747,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSubmitting || signers.length === 1}
onClick={() => onRemoveSigner(index)}
data-testid="remove-placeholder-recipient-button"
>
<Trash className="h-5 w-5" />
</button>

View File

@ -1,6 +1,7 @@
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { z } from 'zod';
import { TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX } from '@documenso/lib/constants/template';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
export const ZAddTemplatePlacholderRecipientsFormSchema = z
@ -10,7 +11,7 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z
formId: z.string().min(1),
nativeId: z.number().optional(),
email: z.string().min(1).email(),
name: z.string(),
name: z.string().min(1, { message: 'Name is required' }),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
@ -21,12 +22,25 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z
})
.refine(
(schema) => {
const emails = schema.signers.map((signer) => signer.email.toLowerCase());
const nonPlaceholderEmails = schema.signers
.map((signer) => signer.email.toLowerCase())
.filter((email) => !TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX.test(email));
return new Set(emails).size === emails.length;
return new Set(nonPlaceholderEmails).size === nonPlaceholderEmails.length;
},
// Dirty hack to handle errors when .root is populated for an array type
{ message: 'Signers must have unique emails', path: ['signers__root'] },
)
.refine(
/*
Since placeholder emails are empty, we need to check that the names are unique.
If we don't do this, the app will add duplicate signers and merge them in the next step, where you add fields.
*/
(schema) => {
const names = schema.signers.map((signer) => signer.name.trim());
return new Set(names).size === names.length;
},
{ message: 'Signers must have unique names', path: ['signers__root'] },
);
export type TAddTemplatePlacholderRecipientsFormSchema = z.infer<

View File

@ -9,6 +9,7 @@ import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import {
@ -83,6 +84,7 @@ export type AddTemplateSettingsFormProps = {
template: TTemplate;
currentTeamMemberRole?: TeamMemberRole;
onSubmit: (_data: TAddTemplateSettingsFormSchema) => void;
onAutoSave: (_data: TAddTemplateSettingsFormSchema) => Promise<void>;
};
export const AddTemplateSettingsFormPartial = ({
@ -93,6 +95,7 @@ export const AddTemplateSettingsFormPartial = ({
template,
currentTeamMemberRole,
onSubmit,
onAutoSave,
}: AddTemplateSettingsFormProps) => {
const { t, i18n } = useLingui();
@ -160,6 +163,28 @@ export const AddTemplateSettingsFormPartial = ({
}
}, [form, form.setValue, form.formState.touchedFields.meta?.timezone]);
const { scheduleSave } = useAutoSave(onAutoSave);
const handleAutoSave = async () => {
const isFormValid = await form.trigger();
if (!isFormValid) {
return;
}
const formData = form.getValues();
/*
* Parse the form data through the Zod schema to handle transformations
* (like -1 -> undefined for the Document Global Auth Access)
*/
const parseResult = ZAddTemplateSettingsFormSchema.safeParse(formData);
if (parseResult.success) {
scheduleSave(parseResult.data);
}
};
return (
<>
<DocumentFlowFormContainerHeader
@ -191,7 +216,12 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
<Input
className="bg-background"
{...field}
maxLength={255}
onBlur={handleAutoSave}
/>
</FormControl>
<FormMessage />
</FormItem>
@ -219,7 +249,13 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<Select
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
@ -250,9 +286,13 @@ export const AddTemplateSettingsFormPartial = ({
<FormControl>
<DocumentGlobalAuthAccessSelect
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
@ -275,7 +315,10 @@ export const AddTemplateSettingsFormPartial = ({
canUpdateVisibility={canUpdateVisibility}
currentTeamMemberRole={currentTeamMemberRole}
{...field}
onValueChange={field.onChange}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
/>
</FormControl>
</FormItem>
@ -334,7 +377,13 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<Select
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue data-testid="documentDistributionMethodSelectValue" />
</SelectTrigger>
@ -371,7 +420,10 @@ export const AddTemplateSettingsFormPartial = ({
value: option.value,
}))}
selectedValues={field.value}
onChange={field.onChange}
onChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
className="bg-background w-full"
emptySelectionPlaceholder="Select signature types"
/>
@ -395,9 +447,13 @@ export const AddTemplateSettingsFormPartial = ({
<FormControl>
<DocumentGlobalAuthActionSelect
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
@ -468,7 +524,7 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel>
<FormControl>
<Input {...field} />
<Input {...field} maxLength={254} />
</FormControl>
<FormMessage />
@ -488,7 +544,7 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel>
<FormControl>
<Input {...field} />
<Input {...field} maxLength={254} onBlur={handleAutoSave} />
</FormControl>
<FormMessage />
@ -515,7 +571,12 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel>
<FormControl>
<Textarea className="bg-background h-16 resize-none" {...field} />
<Textarea
className="bg-background h-16 resize-none"
{...field}
maxLength={5000}
onBlur={handleAutoSave}
/>
</FormControl>
<FormMessage />
@ -525,7 +586,12 @@ export const AddTemplateSettingsFormPartial = ({
<DocumentEmailCheckboxes
value={emailSettings}
onChange={(value) => form.setValue('meta.emailSettings', value)}
onChange={(value) => {
form.setValue('meta.emailSettings', value, {
shouldDirty: true,
});
void handleAutoSave();
}}
/>
</div>
</AccordionContent>
@ -563,7 +629,12 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
<Input
className="bg-background"
{...field}
maxLength={255}
onBlur={handleAutoSave}
/>
</FormControl>
<FormMessage />
@ -581,7 +652,13 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<Select
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
@ -615,7 +692,10 @@ export const AddTemplateSettingsFormPartial = ({
className="bg-background time-zone-field"
options={TIME_ZONES}
{...field}
onChange={(value) => value && field.onChange(value)}
onChange={(value) => {
value && field.onChange(value);
void handleAutoSave();
}}
/>
</FormControl>
@ -645,7 +725,12 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
<Input
className="bg-background"
{...field}
maxLength={255}
onBlur={handleAutoSave}
/>
</FormControl>
<FormMessage />

View File

@ -49,7 +49,10 @@ export const ZAddTemplateSettingsFormSchema = z.object({
.optional()
.default('en'),
emailId: z.string().nullable(),
emailReplyTo: z.string().optional(),
emailReplyTo: z.preprocess(
(val) => (val === '' ? undefined : val),
z.string().email().optional(),
),
emailSettings: ZDocumentEmailSettingsSchema,
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
message: msg`At least one signature type must be enabled`.id,