feat: implement auto-save functionality for signers in document edit form (#1792)

This commit is contained in:
Catalin Pit
2025-09-02 14:01:16 +03:00
committed by GitHub
parent 19565c1821
commit bb5c2edefd
22 changed files with 2482 additions and 220 deletions

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,7 @@ export const AddSettingsFormPartial = ({
className="bg-background"
{...field}
disabled={document.status !== DocumentStatus.DRAFT || field.disabled}
onBlur={handleAutoSave}
/>
</FormControl>
<FormMessage />
@ -227,9 +253,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 +291,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 +320,10 @@ export const AddSettingsFormPartial = ({
canUpdateVisibility={canUpdateVisibility}
currentTeamMemberRole={currentTeamMemberRole}
{...field}
onValueChange={field.onChange}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
/>
</FormControl>
</FormItem>
@ -307,9 +344,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 +388,7 @@ export const AddSettingsFormPartial = ({
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
<Input className="bg-background" {...field} onBlur={handleAutoSave} />
</FormControl>
<FormMessage />
@ -372,7 +413,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 +438,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 +478,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 +514,7 @@ export const AddSettingsFormPartial = ({
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
<Input className="bg-background" {...field} onBlur={handleAutoSave} />
</FormControl>
<FormMessage />

View File

@ -14,6 +14,7 @@ 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 { 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';
@ -55,6 +56,7 @@ export type AddSignersFormProps = {
signingOrder?: DocumentSigningOrder | null;
allowDictateNextSigner?: boolean;
onSubmit: (_data: TAddSignersFormSchema) => void;
onAutoSave: (_data: TAddSignersFormSchema) => Promise<void>;
isDocumentPdfLoaded: boolean;
};
@ -65,6 +67,7 @@ export const AddSignersFormPartial = ({
signingOrder,
allowDictateNextSigner,
onSubmit,
onAutoSave,
isDocumentPdfLoaded,
}: AddSignersFormProps) => {
const { _ } = useLingui();
@ -166,6 +169,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,24 +242,47 @@ 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');
}
};
@ -263,7 +312,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 +328,10 @@ export const AddSignersFormPartial = ({
}
await form.trigger('signers');
void handleAutoSave();
},
[form, canRecipientBeModified, watchedSigners, toast],
[form, canRecipientBeModified, watchedSigners, handleAutoSave, toast],
);
const handleRoleChange = useCallback(
@ -287,7 +341,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 +359,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 +401,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 +427,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 +482,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 +529,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 +630,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 +639,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 ||
@ -588,7 +688,9 @@ export const AddSignersFormPartial = ({
isSubmitting ||
!canRecipientBeModified(signer.nativeId)
}
data-testid="signer-email-input"
onKeyDown={onKeyDown}
onBlur={handleAutoSave}
/>
</FormControl>
@ -626,6 +728,7 @@ export const AddSignersFormPartial = ({
!canRecipientBeModified(signer.nativeId)
}
onKeyDown={onKeyDown}
onBlur={handleAutoSave}
/>
</FormControl>
@ -668,6 +771,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 +785,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 +811,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}

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`}