Compare commits

...

3 Commits

Author SHA1 Message Date
Catalin Pit e4dd6799c7 chore: simplify code 2026-07-10 15:39:51 +03:00
Catalin Pit 7fee24042d feat: add document naming options when using templates 2026-07-10 15:35:34 +03:00
Tanushree Ahir 400b6a24f1 feat: enable real-time validation for email and password fields (#2204)
## Description

This MR improves the real-time validation experience on the signup page.
Currently, both the email and password fields validate only on blur,
causing users to continue seeing error messages even after correcting
their input until they click outside the field.

This update switches the React Hook Form configuration from mode:
'onBlur' to mode: 'onChange', ensuring validations update immediately as
the user types.
This results in a smoother and less confusing signup experience.

## Related Issue

Fixes #2200

## Changes Made

- Updated React Hook Form configuration in the signup form from:
- mode: 'onBlur' ➜ mode: 'onChange'
- Ensures real-time validation for email and password fields
- No schema or logic changes — only validation trigger updated


## Testing Performed

- Manually tested the signup page for:
- Real-time password rule updates
- Real-time email validation feedback
- Error disappearance immediately after correcting input

## Checklist

- [x] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [x] I have updated the documentation to reflect these changes, if
applicable.
- [x] I have followed the project's coding style guidelines.
- [x] I have addressed the code review feedback from the previous
submission, if applicable.

## Additional Notes

This is a small UX-focused improvement; the validation schema remains
unchanged.

Co-authored-by: Catalin Pit <catalinpit@gmail.com>
2026-07-10 13:41:31 +03:00
3 changed files with 255 additions and 22 deletions
@@ -0,0 +1,197 @@
import { DOCUMENT_TITLE_MAX_LENGTH } from '@documenso/trpc/server/document-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
import { z } from 'zod';
export const DOCUMENT_NAME_SOURCE = {
TEMPLATE: 'template',
UPLOAD: 'upload',
CUSTOM: 'custom',
} as const;
type TDocumentNameSource = (typeof DOCUMENT_NAME_SOURCE)[keyof typeof DOCUMENT_NAME_SOURCE];
type TTemplateUseCustomDocumentData = {
data?: File;
};
type TTemplateUseDocumentNameData = {
documentNameSource: TDocumentNameSource;
customDocumentName: string;
customDocumentData?: TTemplateUseCustomDocumentData[];
};
type TTemplateUseDocumentNameFormValues = TTemplateUseDocumentNameData & {
useCustomDocument: boolean;
};
const getUploadedDocumentTitle = (customDocumentData?: TTemplateUseCustomDocumentData[]) => {
const uploadedFiles = customDocumentData?.map((item) => item.data).filter((file): file is File => file !== undefined);
if (uploadedFiles?.length !== 1) {
return undefined;
}
return uploadedFiles[0].name.replace(/\.[^/.]+$/, '').trim();
};
export const getTemplateUseDocumentTitle = (data: TTemplateUseDocumentNameData) => {
if (data.documentNameSource === DOCUMENT_NAME_SOURCE.UPLOAD) {
return getUploadedDocumentTitle(data.customDocumentData);
}
if (data.documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM) {
return data.customDocumentName.trim();
}
return undefined;
};
export const refineTemplateUseDocumentName = (data: TTemplateUseDocumentNameData, ctx: z.RefinementCtx) => {
if (data.documentNameSource === DOCUMENT_NAME_SOURCE.TEMPLATE) {
return;
}
const title = getTemplateUseDocumentTitle(data);
const path = data.documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM ? 'customDocumentName' : 'documentNameSource';
if (!title) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
data.documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM
? msg`Document name is required`.id
: msg`Select exactly one uploaded document to use its file name.`.id,
path: [path],
});
return;
}
if (title.length > DOCUMENT_TITLE_MAX_LENGTH) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: msg`Document name is too long`.id,
path: [path],
});
}
};
export const TemplateUseDialogDocumentName = () => {
const { t } = useLingui();
const { clearErrors, control, setValue } = useFormContext<TTemplateUseDocumentNameFormValues>();
const [useCustomDocument, documentNameSource, customDocumentData] = useWatch({
control,
name: ['useCustomDocument', 'documentNameSource', 'customDocumentData'],
});
const uploadedDocumentCount = customDocumentData?.filter((item) => item.data !== undefined).length ?? 0;
const canUseUploadedDocumentName = useCustomDocument && uploadedDocumentCount === 1;
useEffect(() => {
if (documentNameSource !== DOCUMENT_NAME_SOURCE.UPLOAD || canUseUploadedDocumentName) {
return;
}
setValue('documentNameSource', DOCUMENT_NAME_SOURCE.TEMPLATE);
clearErrors('documentNameSource');
}, [canUseUploadedDocumentName, clearErrors, documentNameSource, setValue]);
return (
<>
<FormField
control={control}
name="documentNameSource"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Document name</Trans>
</FormLabel>
<FormControl>
<RadioGroup
aria-label={t`Document name`}
value={field.value}
onValueChange={(value) => {
field.onChange(value);
if (value !== DOCUMENT_NAME_SOURCE.CUSTOM) {
clearErrors('customDocumentName');
}
if (value !== DOCUMENT_NAME_SOURCE.UPLOAD) {
clearErrors('documentNameSource');
}
}}
className="space-y-2"
>
<div className="flex items-center gap-2">
<RadioGroupItem id="document-name-source-template" value={DOCUMENT_NAME_SOURCE.TEMPLATE} />
<label className="text-sm" htmlFor="document-name-source-template">
<Trans>Use template name</Trans>
</label>
</div>
<div className="flex items-start gap-2">
<RadioGroupItem
id="document-name-source-upload"
value={DOCUMENT_NAME_SOURCE.UPLOAD}
disabled={!canUseUploadedDocumentName}
className="mt-0.5"
/>
<div>
<label
className={cn('text-sm', {
'cursor-not-allowed text-muted-foreground': !canUseUploadedDocumentName,
})}
htmlFor="document-name-source-upload"
>
<Trans>Use uploaded file name</Trans>
</label>
{!canUseUploadedDocumentName && (
<p className="text-muted-foreground text-xs">
<Trans>Select exactly one uploaded document to use its file name.</Trans>
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem id="document-name-source-custom" value={DOCUMENT_NAME_SOURCE.CUSTOM} />
<label className="text-sm" htmlFor="document-name-source-custom">
<Trans>Enter custom document name</Trans>
</label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM && (
<FormField
control={control}
name="customDocumentName"
render={({ field }) => (
<FormItem className="ml-6">
<FormLabel>
<Trans>Custom document name</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder={t`Enter a document name`} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</>
);
};
@@ -38,27 +38,44 @@ import { useNavigate } from 'react-router';
import * as z from 'zod';
import { getTemplateUseErrorMessage } from '~/utils/toast-error-messages';
const ZAddRecipientsForNewDocumentSchema = z.object({
distributeDocument: z.boolean(),
useCustomDocument: z.boolean().default(false),
customDocumentData: z
.array(
import {
DOCUMENT_NAME_SOURCE,
getTemplateUseDocumentTitle,
refineTemplateUseDocumentName,
TemplateUseDialogDocumentName,
} from './template-use-dialog-document-name';
const ZAddRecipientsForNewDocumentSchema = z
.object({
distributeDocument: z.boolean(),
useCustomDocument: z.boolean().default(false),
documentNameSource: z.enum([
DOCUMENT_NAME_SOURCE.TEMPLATE,
DOCUMENT_NAME_SOURCE.UPLOAD,
DOCUMENT_NAME_SOURCE.CUSTOM,
]),
customDocumentName: z.string(),
customDocumentData: z
.array(
z.object({
title: z.string(),
data: z.instanceof(File).optional(),
envelopeItemId: z.string(),
}),
)
.optional(),
recipients: z.array(
z.object({
title: z.string(),
data: z.instanceof(File).optional(),
envelopeItemId: z.string(),
id: z.number(),
email: ZRecipientEmailSchema,
name: z.string(),
signingOrder: z.number().optional(),
}),
)
.optional(),
recipients: z.array(
z.object({
id: z.number(),
email: ZRecipientEmailSchema,
name: z.string(),
signingOrder: z.number().optional(),
}),
),
});
),
})
.superRefine((data, ctx) => {
refineTemplateUseDocumentName(data, ctx);
});
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
@@ -106,6 +123,8 @@ export function TemplateUseDialog({
return {
distributeDocument: false,
useCustomDocument: false,
documentNameSource: DOCUMENT_NAME_SOURCE.TEMPLATE,
customDocumentName: '',
customDocumentData: envelopeItems.map((item) => ({
title: item.title,
data: undefined,
@@ -142,6 +161,8 @@ export function TemplateUseDialog({
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
try {
const documentTitle = getTemplateUseDocumentTitle(data);
const customFilesToUpload = (data.customDocumentData || []).filter(
(item): item is { data: File; envelopeItemId: string; title: string } =>
item.data !== undefined && item.envelopeItemId !== undefined && item.title !== undefined,
@@ -163,6 +184,7 @@ export function TemplateUseDialog({
recipients: data.recipients,
distributeDocument: data.distributeDocument,
customDocumentData,
...(documentTitle ? { override: { title: documentTitle } } : {}),
});
toast({
@@ -195,6 +217,8 @@ export function TemplateUseDialog({
name: 'recipients',
});
const useCustomDocument = form.watch('useCustomDocument');
useEffect(() => {
if (open) {
form.reset(generateDefaultFormValues());
@@ -401,7 +425,16 @@ export function TemplateUseDialog({
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {
form.setValue('customDocumentData', undefined);
const customDocumentData = form.getValues('customDocumentData');
form.setValue(
'customDocumentData',
customDocumentData?.map((item) => ({
...item,
data: undefined,
})),
);
form.clearErrors('customDocumentData');
}
}}
/>
@@ -428,7 +461,7 @@ export function TemplateUseDialog({
)}
/>
{form.watch('useCustomDocument') && (
{useCustomDocument && (
<div className="my-4 space-y-2">
{isLoadingEnvelopeItems ? (
<SpinnerBox className="py-16" />
@@ -537,6 +570,7 @@ export function TemplateUseDialog({
}
field.onChange(file);
form.clearErrors('customDocumentData');
}}
/>
</div>
@@ -550,6 +584,8 @@ export function TemplateUseDialog({
)}
</div>
)}
<TemplateUseDialogDocumentName />
</div>
<DialogFooter className="mt-4">
+1 -1
View File
@@ -96,7 +96,7 @@ export const SignUpForm = ({
password: '',
signature: '',
},
mode: 'onBlur',
mode: 'onChange',
resolver: zodResolver(ZSignUpFormSchema),
});