From 0bca950c633bae0dae89253bcaa3b4db99479791 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Tue, 14 Jul 2026 10:55:02 +0300 Subject: [PATCH] feat: add document name options when creating from a template --- .../template-use-dialog-document-name.tsx | 197 --------------- .../dialogs/template-use-dialog.tsx | 232 ++++++++++++++++-- 2 files changed, 212 insertions(+), 217 deletions(-) delete mode 100644 apps/remix/app/components/dialogs/template-use-dialog-document-name.tsx diff --git a/apps/remix/app/components/dialogs/template-use-dialog-document-name.tsx b/apps/remix/app/components/dialogs/template-use-dialog-document-name.tsx deleted file mode 100644 index bd35481e0..000000000 --- a/apps/remix/app/components/dialogs/template-use-dialog-document-name.tsx +++ /dev/null @@ -1,197 +0,0 @@ -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(); - 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 ( - <> - ( - - - Document name - - - { - field.onChange(value); - - if (value !== DOCUMENT_NAME_SOURCE.CUSTOM) { - clearErrors('customDocumentName'); - } - - if (value !== DOCUMENT_NAME_SOURCE.UPLOAD) { - clearErrors('documentNameSource'); - } - }} - className="space-y-2" - > -
- - -
- -
- -
- - - {!canUseUploadedDocumentName && ( -

- Select exactly one uploaded document to use its file name. -

- )} -
-
- -
- - -
-
-
- -
- )} - /> - - {documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM && ( - ( - - - Custom document name - - - - - - - )} - /> - )} - - ); -}; diff --git a/apps/remix/app/components/dialogs/template-use-dialog.tsx b/apps/remix/app/components/dialogs/template-use-dialog.tsx index 84d438819..da801bef4 100644 --- a/apps/remix/app/components/dialogs/template-use-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-use-dialog.tsx @@ -8,6 +8,7 @@ import { AppError } from '@documenso/lib/errors/app-error'; import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { trpc } from '@documenso/trpc/react'; +import { DOCUMENT_TITLE_MAX_LENGTH } from '@documenso/trpc/server/document-router/schema'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { Checkbox } from '@documenso/ui/primitives/checkbox'; @@ -23,6 +24,7 @@ import { } from '@documenso/ui/primitives/dialog'; import { Form, 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 { SpinnerBox } from '@documenso/ui/primitives/spinner'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -32,18 +34,62 @@ import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client'; import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useFieldArray, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; +import { match } from 'ts-pattern'; import * as z from 'zod'; import { getTemplateUseErrorMessage } from '~/utils/toast-error-messages'; -import { - DOCUMENT_NAME_SOURCE, - getTemplateUseDocumentTitle, - refineTemplateUseDocumentName, - TemplateUseDialogDocumentName, -} from './template-use-dialog-document-name'; +const DOCUMENT_NAME_SOURCE = { + TEMPLATE: 'template', + UPLOAD: 'upload', + CUSTOM: 'custom', +} as const; + +type TDocumentNameSource = (typeof DOCUMENT_NAME_SOURCE)[keyof typeof DOCUMENT_NAME_SOURCE]; + +type TCustomDocumentData = { + data?: File; + uploadSequence?: number; +}; + +const getUploadedDocumentTitle = (file: File) => { + return file.name.replace(/\.[^/.]+$/, '').trim(); +}; + +const getLastUploadedFile = (customDocumentData?: TCustomDocumentData[]) => { + const uploadedFiles = customDocumentData?.filter( + (item): item is Required => item.data !== undefined && item.uploadSequence !== undefined, + ); + + if (!uploadedFiles || uploadedFiles.length === 0) { + return undefined; + } + + return uploadedFiles.reduce((lastUploadedFile, uploadedFile) => + uploadedFile.uploadSequence > lastUploadedFile.uploadSequence ? uploadedFile : lastUploadedFile, + ).data; +}; + +const getTemplateUseDocumentTitle = ({ + documentNameSource, + customDocumentName, + customDocumentData, +}: { + documentNameSource: TDocumentNameSource; + customDocumentName: string; + customDocumentData?: TCustomDocumentData[]; +}) => + match(documentNameSource) + .with(DOCUMENT_NAME_SOURCE.UPLOAD, () => { + const uploadedFile = getLastUploadedFile(customDocumentData); + + return uploadedFile ? getUploadedDocumentTitle(uploadedFile) : undefined; + }) + .with(DOCUMENT_NAME_SOURCE.CUSTOM, () => customDocumentName.trim()) + .with(DOCUMENT_NAME_SOURCE.TEMPLATE, () => undefined) + .exhaustive(); const ZAddRecipientsForNewDocumentSchema = z .object({ @@ -60,6 +106,7 @@ const ZAddRecipientsForNewDocumentSchema = z z.object({ title: z.string(), data: z.instanceof(File).optional(), + uploadSequence: z.number().optional(), envelopeItemId: z.string(), }), ) @@ -74,7 +121,30 @@ const ZAddRecipientsForNewDocumentSchema = z ), }) .superRefine((data, ctx) => { - refineTemplateUseDocumentName(data, ctx); + 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: msg`Document name is required`.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], + }); + } }); type TAddRecipientsForNewDocumentSchema = z.infer; @@ -104,6 +174,7 @@ export function TemplateUseDialog({ const navigate = useNavigate(); const [open, setOpen] = useState(false); + const uploadSequenceRef = useRef(0); const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery( { @@ -163,9 +234,8 @@ export function TemplateUseDialog({ 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, + const customFilesToUpload = (data.customDocumentData ?? []).filter( + (item): item is typeof item & { data: File } => item.data !== undefined, ); const customDocumentData = await Promise.all( @@ -218,6 +288,10 @@ export function TemplateUseDialog({ }); const useCustomDocument = form.watch('useCustomDocument'); + const documentNameSource = form.watch('documentNameSource'); + const customDocumentData = form.watch('customDocumentData'); + const lastUploadedFile = useCustomDocument ? getLastUploadedFile(customDocumentData) : undefined; + const canUseUploadedDocumentName = Boolean(lastUploadedFile); useEffect(() => { if (open) { @@ -237,6 +311,15 @@ export function TemplateUseDialog({ } }, [envelopeItems, form, open]); + useEffect(() => { + if (documentNameSource !== DOCUMENT_NAME_SOURCE.UPLOAD || canUseUploadedDocumentName) { + return; + } + + form.setValue('documentNameSource', DOCUMENT_NAME_SOURCE.TEMPLATE); + form.clearErrors('documentNameSource'); + }, [canUseUploadedDocumentName, documentNameSource, form]); + return ( !form.formState.isSubmitting && setOpen(value)}> @@ -262,9 +345,9 @@ export function TemplateUseDialog({
- -
-
+ +
+
{formRecipients.map((recipient, index) => (
{templateSigningOrder === DocumentSigningOrder.SEQUENTIAL && ( @@ -476,7 +559,7 @@ export function TemplateUseDialog({
@@ -484,13 +567,15 @@ export function TemplateUseDialog({
-
-

{item.title}

+
+

+ {field.value ? getUploadedDocumentTitle(field.value) : item.title} +

{field.value ? ( -

+ Custom {(field.value.size / (1024 * 1024)).toFixed(2)} MB file -
+ ) : ( Default file )} @@ -570,6 +655,10 @@ export function TemplateUseDialog({ } field.onChange(file); + form.setValue( + `customDocumentData.${i}.uploadSequence`, + ++uploadSequenceRef.current, + ); form.clearErrors('customDocumentData'); }} /> @@ -585,7 +674,110 @@ export function TemplateUseDialog({
)} - + ( + + + Document name + + + +
+ + +
+ +
+ +
+
+ + + + + + + + + The document name will use the most recently uploaded file name without its + extension. + + + +
+ + {lastUploadedFile && ( +

+ {lastUploadedFile.name} +

+ )} + + {!canUseUploadedDocumentName && ( +

+ Upload a custom document to use its file name. +

+ )} +
+
+ +
+ + +
+
+
+ +
+ )} + /> + + {documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM && ( + ( + + + Custom document name + + + + + + + )} + /> + )}