fix: auto insert prefilled text and number fields (#2157)

This commit is contained in:
David Nguyen
2025-11-10 18:04:21 +11:00
committed by GitHub
parent 6c0d1da91e
commit ca0b83579f
5 changed files with 244 additions and 25 deletions

View File

@ -104,7 +104,7 @@ export default function EnvelopeGenericPageRenderer() {
pageHeight: unscaledViewport.height, pageHeight: unscaledViewport.height,
color: getRecipientColorKey(field.recipientId), color: getRecipientColorKey(field.recipientId),
editable: false, editable: false,
mode: overrideSettings?.mode ?? 'sign', mode: overrideSettings?.mode ?? 'edit',
}); });
}; };

View File

@ -220,7 +220,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
type: FieldType.NUMBER, type: FieldType.NUMBER,
fieldMeta: { fieldMeta: {
type: 'number', type: 'number',
value: '123', value: '123456789',
}, },
page: 4, page: 4,
...calculatePosition(3, 2), ...calculatePosition(3, 2),
@ -273,8 +273,8 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
type: 'radio', type: 'radio',
values: [ values: [
{ id: 1, checked: false, value: 'Option 1' }, { id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: true, value: 'Option 2' }, { id: 2, checked: false, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' }, { id: 3, checked: true, value: 'Option 3' },
], ],
}, },
page: 5, page: 5,
@ -341,7 +341,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
values: [ values: [
{ id: 1, checked: false, value: 'Option 1' }, { id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: true, value: 'Option 2' }, { id: 2, checked: true, value: 'Option 2' },
{ id: 2, checked: true, value: 'Option 3' }, { id: 3, checked: false, value: 'Option 3' },
], ],
}, },
page: 6, page: 6,

View File

@ -21,7 +21,7 @@ import pixelMatch from 'pixelmatch';
import { PNG } from 'pngjs'; import { PNG } from 'pngjs';
import type { TestInfo } from '@playwright/test'; import type { TestInfo } from '@playwright/test';
import { expect, test } 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 fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.js'; 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 { prisma } from '@documenso/prisma';
import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed'; import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed';
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication'; 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 }); 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 { user, team } = await seedUser();
const envelope = await seedAlignmentTestDocument({ const { token } = await createApiToken({
userId: user.id, userId: user.id,
teamId: team.id, teamId: team.id,
recipientName: user.name || '', tokenName: 'test',
recipientEmail: user.email, expiresIn: null,
insertFields: true,
status: DocumentStatus.PENDING,
}); });
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({ await apiSignin({
page, page,
@ -124,7 +289,7 @@ test('field placement visual regression', async ({ page }, testInfo) => {
const documentUrl = getEnvelopeItemPdfUrl({ const documentUrl = getEnvelopeItemPdfUrl({
type: 'download', type: 'download',
envelopeItem: item, envelopeItem: item,
token, token: recipientToken,
version: 'signed', version: 'signed',
}); });

View File

@ -24,7 +24,9 @@ import {
ZCheckboxFieldMeta, ZCheckboxFieldMeta,
ZDropdownFieldMeta, ZDropdownFieldMeta,
ZFieldAndMetaSchema, ZFieldAndMetaSchema,
ZNumberFieldMeta,
ZRadioFieldMeta, ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta'; } from '../../types/field-meta';
import { import {
ZWebhookDocumentSchema, ZWebhookDocumentSchema,
@ -182,9 +184,18 @@ export const sendDocument = async ({
// Validate and autoinsert fields for V2 envelopes. // Validate and autoinsert fields for V2 envelopes.
if (envelope.internalVersion === 2) { if (envelope.internalVersion === 2) {
for (const unknownField of envelope.fields) { 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); 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); fieldsToAutoInsert.push(fieldToAutoInsert);
} }
} }
@ -205,6 +216,7 @@ export const sendDocument = async ({
if (envelope.internalVersion === 2) { if (envelope.internalVersion === 2) {
const autoInsertedFields = await Promise.all( const autoInsertedFields = await Promise.all(
fieldsToAutoInsert.map(async (field) => { fieldsToAutoInsert.map(async (field) => {
// Warning: Only auto-insert fields if the recipient has not been sent the document yet.
return await tx.field.update({ return await tx.field.update({
where: { where: {
id: field.fieldId, id: field.fieldId,
@ -337,6 +349,31 @@ export const extractFieldAutoInsertValues = (
const field = parsedField.data; const field = parsedField.data;
const fieldId = unknownField.id; 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) { if (field.type === FieldType.RADIO) {
const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta); 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) { if (field.type === FieldType.DROPDOWN) {
const { defaultValue, values = [] } = ZDropdownFieldMeta.parse(field.fieldMeta); 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) { if (field.type === FieldType.CHECKBOX) {
const { const {
values = [], values = [],

View File

@ -48,14 +48,8 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
let textLineHeight = FIELD_DEFAULT_LINE_HEIGHT; let textLineHeight = FIELD_DEFAULT_LINE_HEIGHT;
let textLetterSpacing = FIELD_DEFAULT_LETTER_SPACING; let textLetterSpacing = FIELD_DEFAULT_LETTER_SPACING;
// Default to blank for export mode since this we want to ensure we don't show // Render default values for text/number if provided for editing mode.
// any placeholder text or labels unless actually it's inserted. if (mode === 'edit' && (fieldMeta?.type === 'text' || fieldMeta?.type === 'number')) {
if (mode === 'export') {
textToRender = '';
}
// Use default values for text/number if provided.
if (fieldMeta?.type === 'text' || fieldMeta?.type === 'number') {
const value = fieldMeta?.type === 'text' ? fieldMeta.text : fieldMeta.value; const value = fieldMeta?.type === 'text' ? fieldMeta.text : fieldMeta.value;
if (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) { if (field.inserted) {
textToRender = field.customText; textToRender = field.customText;