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 new file mode 100644 index 000000000..4e12cc392 --- /dev/null +++ b/apps/remix/app/components/dialogs/template-use-dialog-document-name.tsx @@ -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(); + 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 48507fb7d..84d438819 100644 --- a/apps/remix/app/components/dialogs/template-use-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-use-dialog.tsx @@ -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; @@ -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 && (
{isLoadingEnvelopeItems ? ( @@ -537,6 +570,7 @@ export function TemplateUseDialog({ } field.onChange(file); + form.clearErrors('customDocumentData'); }} />
@@ -550,6 +584,8 @@ export function TemplateUseDialog({ )} )} + +