diff --git a/apps/remix/app/components/dialogs/template-use-dialog.tsx b/apps/remix/app/components/dialogs/template-use-dialog.tsx index 48507fb7d..a78f4179a 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'; @@ -38,27 +40,64 @@ 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( +const DOCUMENT_NAME_SOURCE = { + TEMPLATE: 'template', + UPLOAD: 'upload', + CUSTOM: 'custom', +} as const; + +const getUploadedDocumentTitle = (file: File) => { + return file.name.replace(/\.[^/.]+$/, '').trim(); +}; + +/** + * Whether the file name can be used as a document title. + */ +const isUploadedFileNameUsable = (file?: File): file is File => { + if (!file) { + return false; + } + + const title = getUploadedDocumentTitle(file); + + return title.length > 0 && title.length <= DOCUMENT_TITLE_MAX_LENGTH; +}; + +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() + .trim() + .max(DOCUMENT_TITLE_MAX_LENGTH, { message: msg`Document name is too long`.id }), + 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(), - }), - ), -}); + ), + }) + .refine((data) => data.documentNameSource !== DOCUMENT_NAME_SOURCE.CUSTOM || data.customDocumentName.length > 0, { + message: msg`Document name is required`.id, + path: ['customDocumentName'], + }); type TAddRecipientsForNewDocumentSchema = z.infer; @@ -87,6 +126,7 @@ export function TemplateUseDialog({ const navigate = useNavigate(); const [open, setOpen] = useState(false); + const [lastUploadedFile, setLastUploadedFile] = useState(); const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery( { @@ -106,6 +146,8 @@ export function TemplateUseDialog({ return { distributeDocument: false, useCustomDocument: false, + documentNameSource: DOCUMENT_NAME_SOURCE.TEMPLATE, + customDocumentName: '', customDocumentData: envelopeItems.map((item) => ({ title: item.title, data: undefined, @@ -140,11 +182,39 @@ export function TemplateUseDialog({ const { mutateAsync: createDocumentFromTemplate } = trpc.template.createDocumentFromTemplate.useMutation(); + /** + * Track the most recently uploaded file so its name can be used as the document name. + * Files with an unusable name are ignored, and the document name source is reset if + * no usable file remains. + */ + const updateLastUploadedFile = (file?: File) => { + const usableFile = isUploadedFileNameUsable(file) ? file : undefined; + + setLastUploadedFile(usableFile); + + if (!usableFile && form.getValues('documentNameSource') === DOCUMENT_NAME_SOURCE.UPLOAD) { + form.setValue('documentNameSource', DOCUMENT_NAME_SOURCE.TEMPLATE); + } + }; + + const getDocumentTitle = (data: TAddRecipientsForNewDocumentSchema) => { + if (data.documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM) { + return data.customDocumentName; + } + + if (data.documentNameSource === DOCUMENT_NAME_SOURCE.UPLOAD && lastUploadedFile) { + return getUploadedDocumentTitle(lastUploadedFile); + } + + return undefined; + }; + const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => { try { - const customFilesToUpload = (data.customDocumentData || []).filter( - (item): item is { data: File; envelopeItemId: string; title: string } => - item.data !== undefined && item.envelopeItemId !== undefined && item.title !== undefined, + const documentTitle = getDocumentTitle(data); + + const customFilesToUpload = (data.customDocumentData ?? []).filter( + (item): item is typeof item & { data: File } => item.data !== undefined, ); const customDocumentData = await Promise.all( @@ -163,6 +233,7 @@ export function TemplateUseDialog({ recipients: data.recipients, distributeDocument: data.distributeDocument, customDocumentData, + ...(documentTitle ? { override: { title: documentTitle } } : {}), }); toast({ @@ -195,9 +266,14 @@ export function TemplateUseDialog({ name: 'recipients', }); + const useCustomDocument = form.watch('useCustomDocument'); + const documentNameSource = form.watch('documentNameSource'); + const canUseUploadedDocumentName = Boolean(lastUploadedFile); + useEffect(() => { if (open) { form.reset(generateDefaultFormValues()); + setLastUploadedFile(undefined); } }, [open, form]); @@ -238,9 +314,9 @@ export function TemplateUseDialog({
- -
-
+ +
+
{formRecipients.map((recipient, index) => (
{templateSigningOrder === DocumentSigningOrder.SEQUENTIAL && ( @@ -401,7 +477,17 @@ 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'); + updateLastUploadedFile(undefined); } }} /> @@ -428,7 +514,7 @@ export function TemplateUseDialog({ )} /> - {form.watch('useCustomDocument') && ( + {useCustomDocument && (
{isLoadingEnvelopeItems ? ( @@ -443,7 +529,7 @@ export function TemplateUseDialog({
@@ -451,13 +537,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 )} @@ -475,6 +563,18 @@ export function TemplateUseDialog({ onClick={(e) => { e.preventDefault(); field.onChange(undefined); + + if (field.value === lastUploadedFile) { + // Fall back to any other uploaded file so the option stays available. + const remainingUploadedFile = form + .getValues('customDocumentData') + ?.find( + (item) => + item.data !== field.value && isUploadedFileNameUsable(item.data), + )?.data; + + updateLastUploadedFile(remainingUploadedFile); + } }} > @@ -517,7 +617,7 @@ export function TemplateUseDialog({ } if (file.type !== 'application/pdf') { - form.setError('customDocumentData', { + form.setError(`customDocumentData.${i}.data`, { type: 'manual', message: _(msg`Please select a PDF file`), }); @@ -526,7 +626,7 @@ export function TemplateUseDialog({ } if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) { - form.setError('customDocumentData', { + form.setError(`customDocumentData.${i}.data`, { type: 'manual', message: _( msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`, @@ -537,6 +637,8 @@ export function TemplateUseDialog({ } field.onChange(file); + form.clearErrors(`customDocumentData.${i}.data`); + updateLastUploadedFile(file); }} />
@@ -550,6 +652,112 @@ 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 && ( + ( + + + + + + + )} + /> + )}
diff --git a/packages/app-tests/e2e/templates/manage-templates.spec.ts b/packages/app-tests/e2e/templates/manage-templates.spec.ts index 7611e7b27..abe766eff 100644 --- a/packages/app-tests/e2e/templates/manage-templates.spec.ts +++ b/packages/app-tests/e2e/templates/manage-templates.spec.ts @@ -156,8 +156,8 @@ test('[TEMPLATES]: use template', async ({ page }) => { // Get input with Email label placeholder. await page.getByLabel('Email').click(); await page.getByLabel('Email').fill(teamMemberUser.email); - await page.getByLabel('Name').click(); - await page.getByLabel('Name').fill('name'); + await page.getByRole('textbox', { name: 'Name', exact: true }).click(); + await page.getByRole('textbox', { name: 'Name', exact: true }).fill('name'); await page.getByRole('button', { name: 'Create as draft' }).click(); await page.waitForURL(/\/t\/.+\/documents/);