feat: add document naming options when using templates

This commit is contained in:
Catalin Pit
2026-07-10 15:35:34 +03:00
parent 400b6a24f1
commit 7fee24042d
2 changed files with 258 additions and 21 deletions
@@ -0,0 +1,201 @@
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) => {
switch (data.documentNameSource) {
case DOCUMENT_NAME_SOURCE.TEMPLATE:
return undefined;
case DOCUMENT_NAME_SOURCE.UPLOAD:
return getUploadedDocumentTitle(data.customDocumentData);
case DOCUMENT_NAME_SOURCE.CUSTOM:
return data.customDocumentName.trim();
default: {
const exhaustiveCheck: never = data.documentNameSource;
return exhaustiveCheck;
}
}
};
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">