From ca0b83579f2191683f937ef9ee738c3da099c8ef Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 10 Nov 2025 18:04:21 +1100 Subject: [PATCH] fix: auto insert prefilled text and number fields (#2157) --- .../envelope-generic-page-renderer.tsx | 2 +- .../app-tests/constants/field-meta-pdf.ts | 8 +- .../e2e/envelopes/envelope-alignment.spec.ts | 187 ++++++++++++++++-- .../lib/server-only/document/send-document.ts | 41 +++- .../render-generic-text-field.ts | 31 ++- 5 files changed, 244 insertions(+), 25 deletions(-) diff --git a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx index 2902eab76..370d35240 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx @@ -104,7 +104,7 @@ export default function EnvelopeGenericPageRenderer() { pageHeight: unscaledViewport.height, color: getRecipientColorKey(field.recipientId), editable: false, - mode: overrideSettings?.mode ?? 'sign', + mode: overrideSettings?.mode ?? 'edit', }); }; diff --git a/packages/app-tests/constants/field-meta-pdf.ts b/packages/app-tests/constants/field-meta-pdf.ts index ba4f63b33..298b03804 100644 --- a/packages/app-tests/constants/field-meta-pdf.ts +++ b/packages/app-tests/constants/field-meta-pdf.ts @@ -220,7 +220,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [ type: FieldType.NUMBER, fieldMeta: { type: 'number', - value: '123', + value: '123456789', }, page: 4, ...calculatePosition(3, 2), @@ -273,8 +273,8 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [ type: 'radio', values: [ { id: 1, checked: false, value: 'Option 1' }, - { id: 2, checked: true, value: 'Option 2' }, - { id: 3, checked: false, value: 'Option 3' }, + { id: 2, checked: false, value: 'Option 2' }, + { id: 3, checked: true, value: 'Option 3' }, ], }, page: 5, @@ -341,7 +341,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [ values: [ { id: 1, checked: false, value: 'Option 1' }, { id: 2, checked: true, value: 'Option 2' }, - { id: 2, checked: true, value: 'Option 3' }, + { id: 3, checked: false, value: 'Option 3' }, ], }, page: 6, diff --git a/packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts b/packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts index 2125719d5..8a536e6e3 100644 --- a/packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts +++ b/packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts @@ -21,7 +21,7 @@ import pixelMatch from 'pixelmatch'; import { PNG } from 'pngjs'; import type { TestInfo } from '@playwright/test'; import { expect, test } from '@playwright/test'; -import { DocumentStatus } from '@prisma/client'; +import { DocumentStatus, EnvelopeType } from '@prisma/client'; import fs from 'node:fs'; import path from 'node:path'; import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.js'; @@ -29,8 +29,21 @@ import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download'; import { prisma } from '@documenso/prisma'; import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed'; import { seedUser } from '@documenso/prisma/seed/users'; - import { apiSignin } from '../fixtures/authentication'; +import type { + TCreateEnvelopePayload, + TCreateEnvelopeResponse, +} from '../../../trpc/server/envelope-router/create-envelope.types'; +import { NEXT_PUBLIC_WEBAPP_URL } from '../../../lib/constants/app'; +import { createApiToken } from '../../../lib/server-only/public-api/create-api-token'; +import { RecipientRole } from '../../../prisma/generated/types'; +import { FIELD_META_TEST_FIELDS } from '../../constants/field-meta-pdf'; +import { ALIGNMENT_TEST_FIELDS } from '../../constants/field-alignment-pdf'; +import type { TDistributeEnvelopeRequest } from '../../../trpc/server/envelope-router/distribute-envelope.types'; +import { isBase64Image } from '../../../lib/constants/signatures'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const baseUrl = `${WEBAPP_BASE_URL}/api/v2`; test.describe.configure({ mode: 'parallel', timeout: 60000 }); @@ -61,21 +74,173 @@ test.skip('seed alignment test document', async ({ page }) => { }); }); -test('field placement visual regression', async ({ page }, testInfo) => { +test('field placement visual regression', async ({ page, request }, testInfo) => { const { user, team } = await seedUser(); - const envelope = await seedAlignmentTestDocument({ + const { token } = await createApiToken({ userId: user.id, teamId: team.id, - recipientName: user.name || '', - recipientEmail: user.email, - insertFields: true, - status: DocumentStatus.PENDING, + tokenName: 'test', + expiresIn: null, }); - const token = envelope.recipients[0].token; + // Step 1: Create initial envelope with Prisma (with first envelope item) + const alignmentPdf = fs.readFileSync( + path.join(__dirname, '../../../../assets/field-font-alignment.pdf'), + ); - const signUrl = `/sign/${token}`; + const fieldMetaPdf = fs.readFileSync(path.join(__dirname, '../../../../assets/field-meta.pdf')); + + const formData = new FormData(); + + const fieldMetaFields = FIELD_META_TEST_FIELDS.map((field) => ({ + identifier: 'field-meta', + type: field.type, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, + width: field.width, + height: field.height, + fieldMeta: field.fieldMeta, + })); + + const alignmentFields = ALIGNMENT_TEST_FIELDS.map((field) => ({ + identifier: 'alignment-pdf', + type: field.type, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, + width: field.width, + height: field.height, + fieldMeta: field.fieldMeta, + })); + + const createEnvelopePayload: TCreateEnvelopePayload = { + type: EnvelopeType.DOCUMENT, + title: 'Envelope Full Field Test', + recipients: [ + { + email: user.email, + name: user.name || '', + role: RecipientRole.SIGNER, + fields: [...fieldMetaFields, ...alignmentFields], + }, + ], + }; + + formData.append('payload', JSON.stringify(createEnvelopePayload)); + + formData.append('files', new File([alignmentPdf], 'alignment-pdf', { type: 'application/pdf' })); + formData.append('files', new File([fieldMetaPdf], 'field-meta', { type: 'application/pdf' })); + + const createEnvelopeRequest = await request.post(`${baseUrl}/envelope/create`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: formData, + }); + + expect(createEnvelopeRequest.ok()).toBeTruthy(); + expect(createEnvelopeRequest.status()).toBe(200); + + const { id: createdEnvelopeId }: TCreateEnvelopeResponse = await createEnvelopeRequest.json(); + + const envelope = await prisma.envelope.findUniqueOrThrow({ + where: { + id: createdEnvelopeId, + }, + include: { + recipients: true, + envelopeItems: true, + }, + }); + + const recipientId = envelope.recipients[0].id; + const alignmentItem = envelope.envelopeItems.find((item: { order: number }) => item.order === 1); + const fieldMetaItem = envelope.envelopeItems.find((item: { order: number }) => item.order === 2); + + expect(recipientId).toBeDefined(); + expect(alignmentItem).toBeDefined(); + expect(fieldMetaItem).toBeDefined(); + + if (!alignmentItem || !fieldMetaItem) { + throw new Error('Envelope items not found'); + } + + const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, { + headers: { Authorization: `Bearer ${token}` }, + data: { + envelopeId: envelope.id, + } satisfies TDistributeEnvelopeRequest, + }); + + expect(distributeEnvelopeRequest.ok()).toBeTruthy(); + + const uninsertedFields = await prisma.field.findMany({ + where: { + envelopeId: envelope.id, + inserted: false, + }, + include: { + envelopeItem: { + select: { + title: true, + }, + }, + }, + }); + + await Promise.all( + uninsertedFields.map(async (field) => { + let foundField = ALIGNMENT_TEST_FIELDS.find( + (f) => + field.page === f.page && + field.envelopeItem.title === 'alignment-pdf' && + Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) && + Number(field.positionY).toFixed(2) === f.positionY.toFixed(2) && + Number(field.width).toFixed(2) === f.width.toFixed(2) && + Number(field.height).toFixed(2) === f.height.toFixed(2), + ); + + if (!foundField) { + foundField = FIELD_META_TEST_FIELDS.find( + (f) => + field.page === f.page && + field.envelopeItem.title === 'field-meta' && + Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) && + Number(field.positionY).toFixed(2) === f.positionY.toFixed(2) && + Number(field.width).toFixed(2) === f.width.toFixed(2) && + Number(field.height).toFixed(2) === f.height.toFixed(2), + ); + } + + if (!foundField) { + throw new Error('Field not found'); + } + + await prisma.field.update({ + where: { + id: field.id, + }, + data: { + inserted: true, + customText: foundField.customText, + signature: foundField.signature + ? { + create: { + recipientId: envelope.recipients[0].id, + signatureImageAsBase64: isBase64Image(foundField.signature) + ? foundField.signature + : null, + typedSignature: isBase64Image(foundField.signature) ? null : foundField.signature, + }, + } + : undefined, + }, + }); + }), + ); + + const recipientToken = envelope.recipients[0].token; + const signUrl = `/sign/${recipientToken}`; await apiSignin({ page, @@ -124,7 +289,7 @@ test('field placement visual regression', async ({ page }, testInfo) => { const documentUrl = getEnvelopeItemPdfUrl({ type: 'download', envelopeItem: item, - token, + token: recipientToken, version: 'signed', }); diff --git a/packages/lib/server-only/document/send-document.ts b/packages/lib/server-only/document/send-document.ts index f5d97fcca..ce303efb7 100644 --- a/packages/lib/server-only/document/send-document.ts +++ b/packages/lib/server-only/document/send-document.ts @@ -24,7 +24,9 @@ import { ZCheckboxFieldMeta, ZDropdownFieldMeta, ZFieldAndMetaSchema, + ZNumberFieldMeta, ZRadioFieldMeta, + ZTextFieldMeta, } from '../../types/field-meta'; import { ZWebhookDocumentSchema, @@ -182,9 +184,18 @@ export const sendDocument = async ({ // Validate and autoinsert fields for V2 envelopes. if (envelope.internalVersion === 2) { for (const unknownField of envelope.fields) { + const recipient = envelope.recipients.find((r) => r.id === unknownField.recipientId); + + if (!recipient) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Recipient not found', + }); + } + const fieldToAutoInsert = extractFieldAutoInsertValues(unknownField); - if (fieldToAutoInsert) { + // Only auto-insert fields if the recipient has not been sent the document yet. + if (fieldToAutoInsert && recipient.sendStatus !== SendStatus.SENT) { fieldsToAutoInsert.push(fieldToAutoInsert); } } @@ -205,6 +216,7 @@ export const sendDocument = async ({ if (envelope.internalVersion === 2) { const autoInsertedFields = await Promise.all( fieldsToAutoInsert.map(async (field) => { + // Warning: Only auto-insert fields if the recipient has not been sent the document yet. return await tx.field.update({ where: { id: field.fieldId, @@ -337,6 +349,31 @@ export const extractFieldAutoInsertValues = ( const field = parsedField.data; const fieldId = unknownField.id; + // Auto insert text fields with prefilled values. + if (field.type === FieldType.TEXT) { + const { text } = ZTextFieldMeta.parse(field.fieldMeta); + + if (text) { + return { + fieldId, + customText: text, + }; + } + } + + // Auto insert number fields with prefilled values. + if (field.type === FieldType.NUMBER) { + const { value } = ZNumberFieldMeta.parse(field.fieldMeta); + + if (value) { + return { + fieldId, + customText: value, + }; + } + } + + // Auto insert radio fields with the pre-checked value. if (field.type === FieldType.RADIO) { const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta); @@ -350,6 +387,7 @@ export const extractFieldAutoInsertValues = ( } } + // Auto insert dropdown fields with the default value. if (field.type === FieldType.DROPDOWN) { const { defaultValue, values = [] } = ZDropdownFieldMeta.parse(field.fieldMeta); @@ -361,6 +399,7 @@ export const extractFieldAutoInsertValues = ( } } + // Auto insert checkbox fields with the pre-checked values. if (field.type === FieldType.CHECKBOX) { const { values = [], diff --git a/packages/lib/universal/field-renderer/render-generic-text-field.ts b/packages/lib/universal/field-renderer/render-generic-text-field.ts index 525d162c9..6b90e48da 100644 --- a/packages/lib/universal/field-renderer/render-generic-text-field.ts +++ b/packages/lib/universal/field-renderer/render-generic-text-field.ts @@ -48,14 +48,8 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption let textLineHeight = FIELD_DEFAULT_LINE_HEIGHT; let textLetterSpacing = FIELD_DEFAULT_LETTER_SPACING; - // Default to blank for export mode since this we want to ensure we don't show - // any placeholder text or labels unless actually it's inserted. - if (mode === 'export') { - textToRender = ''; - } - - // Use default values for text/number if provided. - if (fieldMeta?.type === 'text' || fieldMeta?.type === 'number') { + // Render default values for text/number if provided for editing mode. + if (mode === 'edit' && (fieldMeta?.type === 'text' || fieldMeta?.type === 'number')) { const value = fieldMeta?.type === 'text' ? fieldMeta.text : fieldMeta.value; if (value) { @@ -68,6 +62,27 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption } } + // Default to blank for export mode since we want to ensure we don't show + // any placeholder text or labels unless actually it's inserted. + if (mode === 'export') { + textToRender = ''; + } + + // Fallback render readonly fields if prefilled value exists. + if (field?.fieldMeta?.readOnly && (fieldMeta?.type === 'text' || fieldMeta?.type === 'number')) { + const value = fieldMeta?.type === 'text' ? fieldMeta.text : fieldMeta.value; + + if (value) { + textToRender = value; + + textVerticalAlign = fieldMeta.verticalAlign || FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN; + textAlign = fieldMeta.textAlign || FIELD_DEFAULT_GENERIC_ALIGN; + textLetterSpacing = fieldMeta.letterSpacing || FIELD_DEFAULT_LETTER_SPACING; + textLineHeight = fieldMeta.lineHeight || FIELD_DEFAULT_LINE_HEIGHT; + } + } + + // Override everything with value if it's inserted. if (field.inserted) { textToRender = field.customText;