From 422770a8c7bdc3d3dd584e01c9757469e8a413e5 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Thu, 6 Mar 2025 00:44:09 +0200 Subject: [PATCH] feat: allow fields prefill when generating a document from a template (#1615) This change allows API users to pre-fill fields with values by passing the data in the request body. Example body for V2 API endpoint `/api/v2-beta/template/use`: ```json { "templateId": 1, "recipients": [ { "id": 1, "email": "signer1@mail.com", "name": "Signer 1" }, { "id": 2, "email": "signer2@mail.com", "name": "Signer 2" } ], "prefillValues": [ { "id": 14, "fieldMeta": { "type": "text", "label": "my label", "placeholder": "text placeholder test", "text": "auto-sign value", "characterLimit": 25, "textAlign": "right", "fontSize": 94, "required": true } }, { "id": 15, "fieldMeta": { "type": "radio", "label": "radio label", "placeholder": "new radio placeholder", "required": false, "readOnly": true, "values": [ { "id": 2, "checked": true, "value": "radio val 1" }, { "id": 3, "checked": false, "value": "radio val 2" } ] } }, { "id": 16, "fieldMeta": { "type": "dropdown", "label": "dropdown label", "placeholder": "DD placeholder", "required": false, "readOnly": false, "values": [ { "value": "option 1" }, { "value": "option 2" }, { "value": "option 3" } ], "defaultValue": "option 2" } } ], "distributeDocument": false, "customDocumentDataId": "" } ``` --- package-lock.json | 2 +- packages/api/v1/implementation.ts | 1 + packages/api/v1/schema.ts | 3 +- .../e2e/api/v1/template-field-prefill.spec.ts | 612 ++++++++++++++++++ .../e2e/api/v2/template-field-prefill.spec.ts | 600 +++++++++++++++++ .../template/create-document-from-template.ts | 250 ++++++- packages/lib/types/field-meta.ts | 36 ++ .../trpc/server/template-router/router.ts | 4 +- .../trpc/server/template-router/schema.ts | 2 + .../dropdown-field.tsx | 6 +- .../text-field.tsx | 8 +- 11 files changed, 1505 insertions(+), 19 deletions(-) create mode 100644 packages/app-tests/e2e/api/v1/template-field-prefill.spec.ts create mode 100644 packages/app-tests/e2e/api/v2/template-field-prefill.spec.ts diff --git a/package-lock.json b/package-lock.json index 58e67c573..b69e388bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42172,4 +42172,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index a57342077..d2c900d37 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -580,6 +580,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, { userId: user.id, teamId: team?.id, recipients: body.recipients, + prefillFields: body.prefillFields, override: { title: body.title, ...body.meta, diff --git a/packages/api/v1/schema.ts b/packages/api/v1/schema.ts index 401cbd173..40f79174a 100644 --- a/packages/api/v1/schema.ts +++ b/packages/api/v1/schema.ts @@ -23,7 +23,7 @@ import { ZRecipientActionAuthTypesSchema, } from '@documenso/lib/types/document-auth'; import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; -import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; +import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; extendZodWithOpenApi(z); @@ -299,6 +299,7 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({ }) .optional(), formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(), + prefillFields: z.array(ZFieldMetaPrefillFieldsSchema).optional(), }); export type TGenerateDocumentFromTemplateMutationSchema = z.infer< diff --git a/packages/app-tests/e2e/api/v1/template-field-prefill.spec.ts b/packages/app-tests/e2e/api/v1/template-field-prefill.spec.ts new file mode 100644 index 000000000..c0de8a7c1 --- /dev/null +++ b/packages/app-tests/e2e/api/v1/template-field-prefill.spec.ts @@ -0,0 +1,612 @@ +import { expect, test } from '@playwright/test'; + +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; +import type { TCheckboxFieldMeta, TRadioFieldMeta } from '@documenso/lib/types/field-meta'; +import { prisma } from '@documenso/prisma'; +import { FieldType, RecipientRole } from '@documenso/prisma/client'; +import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../../fixtures/authentication'; + +test.describe('Template Field Prefill API v1', () => { + test('should create a document from template with prefilled fields', async ({ + page, + request, + }) => { + // 1. Create a user + const user = await seedUser(); + + // 2. Create an API token for the user + const { token } = await createApiToken({ + userId: user.id, + tokenName: 'test-token', + expiresIn: null, + }); + + // 3. Create a template with seedBlankTemplate + const template = await seedBlankTemplate(user, { + createTemplateOptions: { + title: 'Template with Advanced Fields', + }, + }); + + // 4. Create a recipient for the template + const recipient = await prisma.recipient.create({ + data: { + templateId: template.id, + email: 'recipient@example.com', + name: 'Test Recipient', + role: RecipientRole.SIGNER, + token: 'test-token', + readStatus: 'NOT_OPENED', + sendStatus: 'NOT_SENT', + signingStatus: 'NOT_SIGNED', + }, + }); + + // 5. Add fields to the template + // Add TEXT field + const textField = await prisma.field.create({ + data: { + templateId: template.id, + recipientId: recipient.id, + type: FieldType.TEXT, + page: 1, + positionX: 5, + positionY: 5, + width: 10, + height: 5, + customText: '', + inserted: false, + fieldMeta: { + type: 'text', + label: 'Text Field', + }, + }, + }); + + // Add NUMBER field + const numberField = await prisma.field.create({ + data: { + templateId: template.id, + recipientId: recipient.id, + type: FieldType.NUMBER, + page: 1, + positionX: 5, + positionY: 15, + width: 10, + height: 5, + customText: '', + inserted: false, + fieldMeta: { + type: 'number', + label: 'Number Field', + }, + }, + }); + + // Add RADIO field + const radioField = await prisma.field.create({ + data: { + templateId: template.id, + recipientId: recipient.id, + type: FieldType.RADIO, + page: 1, + positionX: 5, + positionY: 25, + width: 10, + height: 5, + customText: '', + inserted: false, + fieldMeta: { + type: 'radio', + label: 'Radio Field', + values: [ + { id: 1, value: 'Option A', checked: false }, + { id: 2, value: 'Option B', checked: false }, + ], + }, + }, + }); + + // Add CHECKBOX field + const checkboxField = await prisma.field.create({ + data: { + templateId: template.id, + recipientId: recipient.id, + type: FieldType.CHECKBOX, + page: 1, + positionX: 5, + positionY: 35, + width: 10, + height: 5, + customText: '', + inserted: false, + fieldMeta: { + type: 'checkbox', + label: 'Checkbox Field', + values: [ + { id: 1, value: 'Check A', checked: false }, + { id: 2, value: 'Check B', checked: false }, + ], + }, + }, + }); + + // Add DROPDOWN field + const dropdownField = await prisma.field.create({ + data: { + templateId: template.id, + recipientId: recipient.id, + type: FieldType.DROPDOWN, + page: 1, + positionX: 5, + positionY: 45, + width: 10, + height: 5, + customText: '', + inserted: false, + fieldMeta: { + type: 'dropdown', + label: 'Dropdown Field', + values: [{ value: 'Select A' }, { value: 'Select B' }], + }, + }, + }); + + // 6. Sign in as the user + await apiSignin({ + page, + email: user.email, + }); + + // 7. Navigate to the template + await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`); + + // 8. Create a document from the template with prefilled fields + const response = await request.post( + `${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: { + title: 'Document with Prefilled Fields', + recipients: [ + { + id: recipient.id, + email: 'recipient@example.com', + name: 'Test Recipient', + role: 'SIGNER', + }, + ], + prefillFields: [ + { + id: textField.id, + type: 'text', + label: 'Prefilled Text', + value: 'This is prefilled text', + }, + { + id: numberField.id, + type: 'number', + label: 'Prefilled Number', + value: '42', + }, + { + id: radioField.id, + type: 'radio', + label: 'Prefilled Radio', + value: 'Option A', + }, + { + id: checkboxField.id, + type: 'checkbox', + label: 'Prefilled Checkbox', + value: ['Check A', 'Check B'], + }, + { + id: dropdownField.id, + type: 'dropdown', + label: 'Prefilled Dropdown', + value: 'Select B', + }, + ], + }, + }, + ); + const responseData = await response.json(); + + expect(response.ok()).toBeTruthy(); + expect(response.status()).toBe(200); + + expect(responseData.documentId).toBeDefined(); + + // 9. Verify the document was created with prefilled fields + const document = await prisma.document.findUnique({ + where: { + id: responseData.documentId, + }, + include: { + fields: true, + }, + }); + + expect(document).not.toBeNull(); + + // 10. Verify each field has the correct prefilled values + const documentTextField = document?.fields.find( + (field) => field.type === FieldType.TEXT && field.fieldMeta?.type === 'text', + ); + expect(documentTextField?.fieldMeta).toMatchObject({ + type: 'text', + label: 'Prefilled Text', + text: 'This is prefilled text', + }); + + const documentNumberField = document?.fields.find( + (field) => field.type === FieldType.NUMBER && field.fieldMeta?.type === 'number', + ); + expect(documentNumberField?.fieldMeta).toMatchObject({ + type: 'number', + label: 'Prefilled Number', + value: '42', + }); + + const documentRadioField = document?.fields.find( + (field) => field.type === FieldType.RADIO && field.fieldMeta?.type === 'radio', + ); + expect(documentRadioField?.fieldMeta).toMatchObject({ + type: 'radio', + label: 'Prefilled Radio', + }); + // Check that the correct radio option is selected + const radioValues = (documentRadioField?.fieldMeta as TRadioFieldMeta)?.values || []; + const selectedRadioOption = radioValues.find((option) => option.checked); + expect(selectedRadioOption?.value).toBe('Option A'); + + const documentCheckboxField = document?.fields.find( + (field) => field.type === FieldType.CHECKBOX && field.fieldMeta?.type === 'checkbox', + ); + expect(documentCheckboxField?.fieldMeta).toMatchObject({ + type: 'checkbox', + label: 'Prefilled Checkbox', + }); + // Check that the correct checkbox options are selected + const checkboxValues = (documentCheckboxField?.fieldMeta as TCheckboxFieldMeta)?.values || []; + const checkedOptions = checkboxValues.filter((option) => option.checked); + expect(checkedOptions.length).toBe(2); + expect(checkedOptions.map((option) => option.value)).toContain('Check A'); + expect(checkedOptions.map((option) => option.value)).toContain('Check B'); + + const documentDropdownField = document?.fields.find( + (field) => field.type === FieldType.DROPDOWN && field.fieldMeta?.type === 'dropdown', + ); + expect(documentDropdownField?.fieldMeta).toMatchObject({ + type: 'dropdown', + label: 'Prefilled Dropdown', + defaultValue: 'Select B', + }); + + // 11. Sign in as the recipient and verify the prefilled fields are visible + const documentRecipient = await prisma.recipient.findFirst({ + where: { + documentId: document?.id, + email: 'recipient@example.com', + }, + }); + + // Send the document to the recipient + const sendResponse = await request.post( + `${WEBAPP_BASE_URL}/api/v1/documents/${document?.id}/send`, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: { + sendEmail: false, + }, + }, + ); + + expect(sendResponse.ok()).toBeTruthy(); + expect(sendResponse.status()).toBe(200); + + expect(documentRecipient).not.toBeNull(); + + // Visit the signing page + await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`); + + // Verify the prefilled fields are visible with correct values + // Text field + await expect(page.getByText('This is prefilled')).toBeVisible(); + + // Number field + await expect(page.getByText('42')).toBeVisible(); + + // Radio field + await expect(page.getByText('Option A')).toBeVisible(); + await expect(page.getByRole('radio', { name: 'Option A' })).toBeChecked(); + + // Checkbox field + await expect(page.getByText('Check A')).toBeVisible(); + await expect(page.getByText('Check B')).toBeVisible(); + await expect(page.getByRole('checkbox', { name: 'Check A' })).toBeChecked(); + await expect(page.getByRole('checkbox', { name: 'Check B' })).toBeChecked(); + + // Dropdown field + await expect(page.getByText('Select B')).toBeVisible(); + }); + + test('should create a document from template without prefilled fields', async ({ + page, + request, + }) => { + // 1. Create a user + const user = await seedUser(); + + // 2. Create an API token for the user + const { token } = await createApiToken({ + userId: user.id, + tokenName: 'test-token', + expiresIn: null, + }); + + // 3. Create a template with seedBlankTemplate + const template = await seedBlankTemplate(user, { + createTemplateOptions: { + title: 'Template with Default Fields', + }, + }); + + // 4. Create a recipient for the template + const recipient = await prisma.recipient.create({ + data: { + templateId: template.id, + email: 'recipient@example.com', + name: 'Test Recipient', + role: RecipientRole.SIGNER, + token: 'test-token', + readStatus: 'NOT_OPENED', + sendStatus: 'NOT_SENT', + signingStatus: 'NOT_SIGNED', + }, + }); + + // 5. Add fields to the template + // Add TEXT field + const textField = await prisma.field.create({ + data: { + templateId: template.id, + recipientId: recipient.id, + type: FieldType.TEXT, + page: 1, + positionX: 5, + positionY: 5, + width: 10, + height: 5, + customText: '', + inserted: false, + fieldMeta: { + type: 'text', + label: 'Default Text Field', + }, + }, + }); + + // Add NUMBER field + const numberField = await prisma.field.create({ + data: { + templateId: template.id, + recipientId: recipient.id, + type: FieldType.NUMBER, + page: 1, + positionX: 5, + positionY: 15, + width: 10, + height: 5, + customText: '', + inserted: false, + fieldMeta: { + type: 'number', + label: 'Default Number Field', + }, + }, + }); + + // 6. Sign in as the user + await apiSignin({ + page, + email: user.email, + }); + + // 7. Navigate to the template + await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`); + + // 8. Create a document from the template without prefilled fields + const response = await request.post( + `${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: { + title: 'Document with Default Fields', + recipients: [ + { + id: recipient.id, + email: 'recipient@example.com', + name: 'Test Recipient', + role: 'SIGNER', + }, + ], + }, + }, + ); + + const responseData = await response.json(); + + expect(response.ok()).toBeTruthy(); + expect(response.status()).toBe(200); + + expect(responseData.documentId).toBeDefined(); + + // 9. Verify the document was created with default fields + const document = await prisma.document.findUnique({ + where: { + id: responseData.documentId, + }, + include: { + fields: true, + }, + }); + + expect(document).not.toBeNull(); + + // 10. Verify fields have their default values + const documentTextField = document?.fields.find((field) => field.type === FieldType.TEXT); + expect(documentTextField?.fieldMeta).toMatchObject({ + type: 'text', + label: 'Default Text Field', + }); + + const documentNumberField = document?.fields.find((field) => field.type === FieldType.NUMBER); + expect(documentNumberField?.fieldMeta).toMatchObject({ + type: 'number', + label: 'Default Number Field', + }); + + // 11. Sign in as the recipient and verify the default fields are visible + const documentRecipient = await prisma.recipient.findFirst({ + where: { + documentId: document?.id, + email: 'recipient@example.com', + }, + }); + + expect(documentRecipient).not.toBeNull(); + + const sendResponse = await request.post( + `${WEBAPP_BASE_URL}/api/v1/documents/${document?.id}/send`, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: { + sendEmail: false, + }, + }, + ); + + expect(sendResponse.ok()).toBeTruthy(); + expect(sendResponse.status()).toBe(200); + + // Visit the signing page + await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`); + + // Verify the default fields are visible with correct labels + await expect(page.getByText('Default Text Field')).toBeVisible(); + await expect(page.getByText('Default Number Field')).toBeVisible(); + }); + + test('should handle invalid field prefill values', async ({ request }) => { + // 1. Create a user + const user = await seedUser(); + + // 2. Create an API token for the user + const { token } = await createApiToken({ + userId: user.id, + tokenName: 'test-token', + expiresIn: null, + }); + + // 3. Create a template using seedBlankTemplate + const template = await seedBlankTemplate(user, { + createTemplateOptions: { + title: 'Template for Invalid Test', + visibility: 'EVERYONE', + }, + }); + + // 4. Create a recipient for the template + const recipient = await prisma.recipient.create({ + data: { + templateId: template.id, + email: 'recipient@example.com', + name: 'Test Recipient', + role: RecipientRole.SIGNER, + token: 'test-token', + readStatus: 'NOT_OPENED', + sendStatus: 'NOT_SENT', + signingStatus: 'NOT_SIGNED', + }, + }); + + // 5. Add a field to the template + const field = await prisma.field.create({ + data: { + templateId: template.id, + recipientId: recipient.id, + type: FieldType.RADIO, + page: 1, + positionX: 100, + positionY: 100, + width: 100, + height: 50, + customText: '', + inserted: false, + fieldMeta: { + type: 'radio', + label: 'Radio Field', + values: [ + { id: 1, value: 'Option A', checked: false }, + { id: 2, value: 'Option B', checked: false }, + ], + }, + }, + }); + + // 6. Try to create a document with invalid prefill value + const response = await request.post( + `${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: { + title: 'Document with Invalid Prefill', + recipients: [ + { + id: recipient.id, + email: 'recipient@example.com', + name: 'Test Recipient', + role: 'SIGNER', + }, + ], + prefillFields: [ + { + id: field.id, + type: 'radio', + label: 'Invalid Radio', + value: 'Non-existent Option', // This option doesn't exist + }, + ], + }, + }, + ); + + // 7. Verify the request fails with appropriate error + expect(response.ok()).toBeFalsy(); + expect(response.status()).toBe(400); + + const errorData = await response.json(); + expect(errorData.message).toContain('not found in options for RADIO field'); + }); +}); diff --git a/packages/app-tests/e2e/api/v2/template-field-prefill.spec.ts b/packages/app-tests/e2e/api/v2/template-field-prefill.spec.ts new file mode 100644 index 000000000..812c86f97 --- /dev/null +++ b/packages/app-tests/e2e/api/v2/template-field-prefill.spec.ts @@ -0,0 +1,600 @@ +import { expect, test } from '@playwright/test'; + +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; +import type { TCheckboxFieldMeta, TRadioFieldMeta } from '@documenso/lib/types/field-meta'; +import { prisma } from '@documenso/prisma'; +import { FieldType, RecipientRole } from '@documenso/prisma/client'; +import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../../fixtures/authentication'; + +test.describe('Template Field Prefill API v2', () => { + test('should create a document from template with prefilled fields', async ({ + page, + request, + }) => { + // 1. Create a user + const user = await seedUser(); + + // 2. Create an API token for the user + const { token } = await createApiToken({ + userId: user.id, + tokenName: 'test-token', + expiresIn: null, + }); + + // 3. Create a template with seedBlankTemplate + const template = await seedBlankTemplate(user, { + createTemplateOptions: { + title: 'Template with Advanced Fields V2', + }, + }); + + // 4. Create a recipient for the template + const recipient = await prisma.recipient.create({ + data: { + templateId: template.id, + email: 'recipient@example.com', + name: 'Test Recipient', + role: RecipientRole.SIGNER, + token: 'test-token', + readStatus: 'NOT_OPENED', + sendStatus: 'NOT_SENT', + signingStatus: 'NOT_SIGNED', + }, + }); + + // 5. Add fields to the template + // Add TEXT field + const textField = await prisma.field.create({ + data: { + templateId: template.id, + recipientId: recipient.id, + type: FieldType.TEXT, + page: 1, + positionX: 5, + positionY: 5, + width: 20, + height: 5, + customText: '', + inserted: false, + fieldMeta: { + type: 'text', + label: 'Text Field', + }, + }, + }); + + // Add NUMBER field + const numberField = await prisma.field.create({ + data: { + templateId: template.id, + recipientId: recipient.id, + type: FieldType.NUMBER, + page: 1, + positionX: 5, + positionY: 15, + width: 20, + height: 5, + customText: '', + inserted: false, + fieldMeta: { + type: 'number', + label: 'Number Field', + }, + }, + }); + + // Add RADIO field + const radioField = await prisma.field.create({ + data: { + templateId: template.id, + recipientId: recipient.id, + type: FieldType.RADIO, + page: 1, + positionX: 5, + positionY: 25, + width: 20, + height: 5, + customText: '', + inserted: false, + fieldMeta: { + type: 'radio', + label: 'Radio Field', + values: [ + { id: 1, value: 'Option A', checked: false }, + { id: 2, value: 'Option B', checked: false }, + ], + }, + }, + }); + + // Add CHECKBOX field + const checkboxField = await prisma.field.create({ + data: { + templateId: template.id, + recipientId: recipient.id, + type: FieldType.CHECKBOX, + page: 1, + positionX: 5, + positionY: 35, + width: 20, + height: 5, + customText: '', + inserted: false, + fieldMeta: { + type: 'checkbox', + label: 'Checkbox Field', + values: [ + { id: 1, value: 'Check A', checked: false }, + { id: 2, value: 'Check B', checked: false }, + ], + }, + }, + }); + + // Add DROPDOWN field + const dropdownField = await prisma.field.create({ + data: { + templateId: template.id, + recipientId: recipient.id, + type: FieldType.DROPDOWN, + page: 1, + positionX: 5, + positionY: 45, + width: 20, + height: 5, + customText: '', + inserted: false, + fieldMeta: { + type: 'dropdown', + label: 'Dropdown Field', + values: [{ value: 'Select A' }, { value: 'Select B' }], + }, + }, + }); + + // 6. Sign in as the user + await apiSignin({ + page, + email: user.email, + }); + + // 7. Navigate to the template + await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`); + + // 8. Create a document from the template with prefilled fields using v2 API + const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: { + templateId: template.id, + recipients: [ + { + id: recipient.id, + email: 'recipient@example.com', + name: 'Test Recipient', + }, + ], + prefillFields: [ + { + id: textField.id, + type: 'text', + label: 'Prefilled Text', + value: 'This is prefilled text', + }, + { + id: numberField.id, + type: 'number', + label: 'Prefilled Number', + value: '42', + }, + { + id: radioField.id, + type: 'radio', + label: 'Prefilled Radio', + value: 'Option A', + }, + { + id: checkboxField.id, + type: 'checkbox', + label: 'Prefilled Checkbox', + value: ['Check A', 'Check B'], + }, + { + id: dropdownField.id, + type: 'dropdown', + label: 'Prefilled Dropdown', + value: 'Select B', + }, + ], + }, + }); + + const responseData = await response.json(); + + expect(response.ok()).toBeTruthy(); + expect(response.status()).toBe(200); + + expect(responseData.id).toBeDefined(); + + // 9. Verify the document was created with prefilled fields + const document = await prisma.document.findUnique({ + where: { + id: responseData.id, + }, + include: { + fields: true, + }, + }); + + expect(document).not.toBeNull(); + + // 10. Verify each field has the correct prefilled values + const documentTextField = document?.fields.find( + (field) => field.type === FieldType.TEXT && field.fieldMeta?.type === 'text', + ); + expect(documentTextField?.fieldMeta).toMatchObject({ + type: 'text', + label: 'Prefilled Text', + text: 'This is prefilled text', + }); + + const documentNumberField = document?.fields.find( + (field) => field.type === FieldType.NUMBER && field.fieldMeta?.type === 'number', + ); + expect(documentNumberField?.fieldMeta).toMatchObject({ + type: 'number', + label: 'Prefilled Number', + value: '42', + }); + + const documentRadioField = document?.fields.find( + (field) => field.type === FieldType.RADIO && field.fieldMeta?.type === 'radio', + ); + expect(documentRadioField?.fieldMeta).toMatchObject({ + type: 'radio', + label: 'Prefilled Radio', + }); + // Check that the correct radio option is selected + const radioValues = (documentRadioField?.fieldMeta as TRadioFieldMeta)?.values || []; + const selectedRadioOption = radioValues.find((option) => option.checked); + expect(selectedRadioOption?.value).toBe('Option A'); + + const documentCheckboxField = document?.fields.find( + (field) => field.type === FieldType.CHECKBOX && field.fieldMeta?.type === 'checkbox', + ); + expect(documentCheckboxField?.fieldMeta).toMatchObject({ + type: 'checkbox', + label: 'Prefilled Checkbox', + }); + // Check that the correct checkbox options are selected + const checkboxValues = (documentCheckboxField?.fieldMeta as TCheckboxFieldMeta)?.values || []; + const checkedOptions = checkboxValues.filter((option) => option.checked); + expect(checkedOptions.length).toBe(2); + expect(checkedOptions.map((option) => option.value)).toContain('Check A'); + expect(checkedOptions.map((option) => option.value)).toContain('Check B'); + + const documentDropdownField = document?.fields.find( + (field) => field.type === FieldType.DROPDOWN && field.fieldMeta?.type === 'dropdown', + ); + expect(documentDropdownField?.fieldMeta).toMatchObject({ + type: 'dropdown', + label: 'Prefilled Dropdown', + defaultValue: 'Select B', + }); + + const sendResponse = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: { + documentId: document?.id, + meta: { + subject: 'Test Subject', + message: 'Test Message', + }, + }, + }); + + await expect(sendResponse.ok()).toBeTruthy(); + await expect(sendResponse.status()).toBe(200); + + // 11. Sign in as the recipient and verify the prefilled fields are visible + const documentRecipient = await prisma.recipient.findFirst({ + where: { + documentId: document?.id, + email: 'recipient@example.com', + }, + }); + + expect(documentRecipient).not.toBeNull(); + + // Visit the signing page + await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`); + + // Verify the prefilled fields are visible with correct values + // Text field + await expect(page.getByText('This is prefilled')).toBeVisible(); + + // Number field + await expect(page.getByText('42')).toBeVisible(); + + // Radio field + await expect(page.getByText('Option A')).toBeVisible(); + await expect(page.getByRole('radio', { name: 'Option A' })).toBeChecked(); + + // Checkbox field + await expect(page.getByText('Check A')).toBeVisible(); + await expect(page.getByText('Check B')).toBeVisible(); + await expect(page.getByRole('checkbox', { name: 'Check A' })).toBeChecked(); + await expect(page.getByRole('checkbox', { name: 'Check B' })).toBeChecked(); + + // Dropdown field + await expect(page.getByText('Select B')).toBeVisible(); + }); + + test('should create a document from template without prefilled fields', async ({ + page, + request, + }) => { + // 1. Create a user + const user = await seedUser(); + + // 2. Create an API token for the user + const { token } = await createApiToken({ + userId: user.id, + tokenName: 'test-token', + expiresIn: null, + }); + + // 3. Create a template with seedBlankTemplate + const template = await seedBlankTemplate(user, { + createTemplateOptions: { + title: 'Template with Default Fields V2', + }, + }); + + // 4. Create a recipient for the template + const recipient = await prisma.recipient.create({ + data: { + templateId: template.id, + email: 'recipient@example.com', + name: 'Test Recipient', + role: RecipientRole.SIGNER, + token: 'test-token', + readStatus: 'NOT_OPENED', + sendStatus: 'NOT_SENT', + signingStatus: 'NOT_SIGNED', + }, + }); + + // 5. Add fields to the template + // Add TEXT field + const textField = await prisma.field.create({ + data: { + templateId: template.id, + recipientId: recipient.id, + type: FieldType.TEXT, + page: 1, + positionX: 5, + positionY: 5, + width: 20, + height: 5, + customText: '', + inserted: false, + fieldMeta: { + type: 'text', + label: 'Default Text Field', + }, + }, + }); + + // Add NUMBER field + const numberField = await prisma.field.create({ + data: { + templateId: template.id, + recipientId: recipient.id, + type: FieldType.NUMBER, + page: 1, + positionX: 5, + positionY: 15, + width: 20, + height: 5, + customText: '', + inserted: false, + fieldMeta: { + type: 'number', + label: 'Default Number Field', + }, + }, + }); + + // 6. Sign in as the user + await apiSignin({ + page, + email: user.email, + }); + + // 7. Navigate to the template + await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`); + + // 8. Create a document from the template without prefilled fields using v2 API + const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: { + templateId: template.id, + recipients: [ + { + id: recipient.id, + email: 'recipient@example.com', + name: 'Test Recipient', + }, + ], + }, + }); + + const responseData = await response.json(); + + expect(response.ok()).toBeTruthy(); + expect(response.status()).toBe(200); + + expect(responseData.id).toBeDefined(); + + // 9. Verify the document was created with default fields + const document = await prisma.document.findUnique({ + where: { + id: responseData.id, + }, + include: { + fields: true, + }, + }); + + expect(document).not.toBeNull(); + + // 10. Verify fields have their default values + const documentTextField = document?.fields.find((field) => field.type === FieldType.TEXT); + expect(documentTextField?.fieldMeta).toMatchObject({ + type: 'text', + label: 'Default Text Field', + }); + + const documentNumberField = document?.fields.find((field) => field.type === FieldType.NUMBER); + expect(documentNumberField?.fieldMeta).toMatchObject({ + type: 'number', + label: 'Default Number Field', + }); + + const sendResponse = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: { + documentId: document?.id, + meta: { + subject: 'Test Subject', + message: 'Test Message', + }, + }, + }); + + await expect(sendResponse.ok()).toBeTruthy(); + await expect(sendResponse.status()).toBe(200); + + // 11. Sign in as the recipient and verify the default fields are visible + const documentRecipient = await prisma.recipient.findFirst({ + where: { + documentId: document?.id, + email: 'recipient@example.com', + }, + }); + + expect(documentRecipient).not.toBeNull(); + + // Visit the signing page + await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`); + + await expect(page.getByText('This is prefilled')).not.toBeVisible(); + }); + + test('should handle invalid field prefill values', async ({ request }) => { + // 1. Create a user + const user = await seedUser(); + + // 2. Create an API token for the user + const { token } = await createApiToken({ + userId: user.id, + tokenName: 'test-token', + expiresIn: null, + }); + + // 3. Create a template using seedBlankTemplate + const template = await seedBlankTemplate(user, { + createTemplateOptions: { + title: 'Template for Invalid Test V2', + visibility: 'EVERYONE', + }, + }); + + // 4. Create a recipient for the template + const recipient = await prisma.recipient.create({ + data: { + templateId: template.id, + email: 'recipient@example.com', + name: 'Test Recipient', + role: RecipientRole.SIGNER, + token: 'test-token', + readStatus: 'NOT_OPENED', + sendStatus: 'NOT_SENT', + signingStatus: 'NOT_SIGNED', + }, + }); + + // 5. Add a field to the template + const field = await prisma.field.create({ + data: { + templateId: template.id, + recipientId: recipient.id, + type: FieldType.RADIO, + page: 1, + positionX: 100, + positionY: 100, + width: 100, + height: 50, + customText: '', + inserted: false, + fieldMeta: { + type: 'radio', + label: 'Radio Field', + values: [ + { id: 1, value: 'Option A', checked: false }, + { id: 2, value: 'Option B', checked: false }, + ], + }, + }, + }); + + // 7. Try to create a document with invalid prefill value + const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: { + templateId: template.id, + recipients: [ + { + id: recipient.id, + email: 'recipient@example.com', + name: 'Test Recipient', + }, + ], + prefillFields: [ + { + id: field.id, + type: 'radio', + label: 'Invalid Radio', + value: 'Non-existent Option', // This option doesn't exist + }, + ], + }, + }); + + // 8. Verify the request fails with appropriate error + expect(response.ok()).toBeFalsy(); + expect(response.status()).toBe(400); + + const errorData = await response.json(); + expect(errorData.message).toContain('not found in options for RADIO field'); + }); +}); diff --git a/packages/lib/server-only/template/create-document-from-template.ts b/packages/lib/server-only/template/create-document-from-template.ts index 10b4a59e5..860f8cb98 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -9,6 +9,7 @@ import { SigningStatus, WebhookTriggerEvents, } from '@prisma/client'; +import { match } from 'ts-pattern'; import { nanoid } from '@documenso/lib/universal/id'; import { prisma } from '@documenso/prisma'; @@ -18,7 +19,20 @@ import { AppError, AppErrorCode } from '../../errors/app-error'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; import { ZRecipientAuthOptionsSchema } from '../../types/document-auth'; import type { TDocumentEmailSettings } from '../../types/document-email'; -import { ZFieldMetaSchema } from '../../types/field-meta'; +import type { + TCheckboxFieldMeta, + TDropdownFieldMeta, + TFieldMetaPrefillFieldsSchema, + TNumberFieldMeta, + TRadioFieldMeta, + TTextFieldMeta, +} from '../../types/field-meta'; +import { + ZCheckboxFieldMeta, + ZDropdownFieldMeta, + ZFieldMetaSchema, + ZRadioFieldMeta, +} from '../../types/field-meta'; import { ZWebhookDocumentSchema, mapDocumentToWebhookDocumentPayload, @@ -51,6 +65,7 @@ export type CreateDocumentFromTemplateOptions = { email: string; signingOrder?: number | null; }[]; + prefillFields?: TFieldMetaPrefillFieldsSchema[]; customDocumentDataId?: string; /** @@ -73,6 +88,165 @@ export type CreateDocumentFromTemplateOptions = { requestMetadata: ApiRequestMetadata; }; +const getUpdatedFieldMeta = (field: Field, prefillField?: TFieldMetaPrefillFieldsSchema) => { + if (!prefillField) { + return field.fieldMeta; + } + + const advancedField = ['NUMBER', 'RADIO', 'CHECKBOX', 'DROPDOWN', 'TEXT'].includes(field.type); + + if (!advancedField) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: `Field ${field.id} is not an advanced field and cannot have field meta information. Allowed types: NUMBER, RADIO, CHECKBOX, DROPDOWN, TEXT.`, + }); + } + + // We've already validated that the field types match at a higher level + // Start with the existing field meta or an empty object + const existingMeta = field.fieldMeta || {}; + + // Apply type-specific updates based on the prefill field type using ts-pattern + return match(prefillField) + .with({ type: 'text' }, (field) => { + if (typeof field.value !== 'string') { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: `Invalid value for TEXT field ${field.id}: expected string, got ${typeof field.value}`, + }); + } + + const meta: TTextFieldMeta = { + ...existingMeta, + type: 'text', + label: field.label, + text: field.value, + }; + + return meta; + }) + .with({ type: 'number' }, (field) => { + if (typeof field.value !== 'string') { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: `Invalid value for NUMBER field ${field.id}: expected string, got ${typeof field.value}`, + }); + } + + const meta: TNumberFieldMeta = { + ...existingMeta, + type: 'number', + label: field.label, + value: field.value, + }; + + return meta; + }) + .with({ type: 'radio' }, (field) => { + if (typeof field.value !== 'string') { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: `Invalid value for RADIO field ${field.id}: expected string, got ${typeof field.value}`, + }); + } + + const result = ZRadioFieldMeta.safeParse(existingMeta); + + if (!result.success) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: `Invalid field meta for RADIO field ${field.id}`, + }); + } + + const radioMeta = result.data; + + // Validate that the value exists in the options + const valueExists = radioMeta.values?.some((option) => option.value === field.value); + + if (!valueExists) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: `Value "${field.value}" not found in options for RADIO field ${field.id}`, + }); + } + + const newValues = radioMeta.values?.map((option) => ({ + ...option, + checked: option.value === field.value, + })); + + const meta: TRadioFieldMeta = { + ...existingMeta, + type: 'radio', + label: field.label, + values: newValues, + }; + + return meta; + }) + .with({ type: 'checkbox' }, (field) => { + const result = ZCheckboxFieldMeta.safeParse(existingMeta); + + if (!result.success) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: `Invalid field meta for CHECKBOX field ${field.id}`, + }); + } + + const checkboxMeta = result.data; + + // Validate that all values exist in the options + for (const value of field.value) { + const valueExists = checkboxMeta.values?.some((option) => option.value === value); + + if (!valueExists) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: `Value "${value}" not found in options for CHECKBOX field ${field.id}`, + }); + } + } + + const newValues = checkboxMeta.values?.map((option) => ({ + ...option, + checked: field.value.includes(option.value), + })); + + const meta: TCheckboxFieldMeta = { + ...existingMeta, + type: 'checkbox', + label: field.label, + values: newValues, + }; + + return meta; + }) + .with({ type: 'dropdown' }, (field) => { + const result = ZDropdownFieldMeta.safeParse(existingMeta); + + if (!result.success) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: `Invalid field meta for DROPDOWN field ${field.id}`, + }); + } + + const dropdownMeta = result.data; + + // Validate that the value exists in the options if values are defined + const valueExists = dropdownMeta.values?.some((option) => option.value === field.value); + + if (!valueExists) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: `Value "${field.value}" not found in options for DROPDOWN field ${field.id}`, + }); + } + + const meta: TDropdownFieldMeta = { + ...existingMeta, + type: 'dropdown', + label: field.label, + defaultValue: field.value, + }; + + return meta; + }) + .otherwise(() => field.fieldMeta); +}; + export const createDocumentFromTemplate = async ({ templateId, externalId, @@ -82,6 +256,7 @@ export const createDocumentFromTemplate = async ({ customDocumentDataId, override, requestMetadata, + prefillFields, }: CreateDocumentFromTemplateOptions) => { const template = await prisma.template.findUnique({ where: { @@ -259,6 +434,47 @@ export const createDocumentFromTemplate = async ({ let fieldsToCreate: Omit[] = []; + // Get all template field IDs first so we can validate later + const allTemplateFieldIds = finalRecipients.flatMap((recipient) => + recipient.fields.map((field) => field.id), + ); + + if (prefillFields?.length) { + // Validate that all prefill field IDs exist in the template + const invalidFieldIds = prefillFields + .map((prefillField) => prefillField.id) + .filter((id) => !allTemplateFieldIds.includes(id)); + + if (invalidFieldIds.length > 0) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: `The following field IDs do not exist in the template: ${invalidFieldIds.join(', ')}`, + }); + } + + // Validate that all prefill fields have the correct type + for (const prefillField of prefillFields) { + const templateField = finalRecipients + .flatMap((recipient) => recipient.fields) + .find((field) => field.id === prefillField.id); + + if (!templateField) { + // This should never happen due to the previous validation, but just in case + throw new AppError(AppErrorCode.INVALID_BODY, { + message: `Field with ID ${prefillField.id} not found in the template`, + }); + } + + const expectedType = templateField.type.toLowerCase(); + const actualType = prefillField.type; + + if (expectedType !== actualType) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: `Field type mismatch for field ${prefillField.id}: expected ${expectedType}, got ${actualType}`, + }); + } + } + } + Object.values(finalRecipients).forEach(({ email, fields }) => { const recipient = document.recipients.find((recipient) => recipient.email === email); @@ -267,19 +483,25 @@ export const createDocumentFromTemplate = async ({ } fieldsToCreate = fieldsToCreate.concat( - fields.map((field) => ({ - documentId: document.id, - recipientId: recipient.id, - type: field.type, - page: field.page, - positionX: field.positionX, - positionY: field.positionY, - width: field.width, - height: field.height, - customText: '', - inserted: false, - fieldMeta: field.fieldMeta, - })), + fields.map((field) => { + const prefillField = prefillFields?.find((value) => value.id === field.id); + // Use type assertion to help TypeScript understand the structure + const updatedFieldMeta = getUpdatedFieldMeta(field, prefillField); + + return { + documentId: document.id, + recipientId: recipient.id, + type: field.type, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, + width: field.width, + height: field.height, + customText: '', + inserted: false, + fieldMeta: updatedFieldMeta, + }; + }), ); }); diff --git a/packages/lib/types/field-meta.ts b/packages/lib/types/field-meta.ts index 83cca0ef8..89b58aaed 100644 --- a/packages/lib/types/field-meta.ts +++ b/packages/lib/types/field-meta.ts @@ -122,6 +122,42 @@ export const ZFieldMetaNotOptionalSchema = z.discriminatedUnion('type', [ export type TFieldMetaNotOptionalSchema = z.infer; +export const ZFieldMetaPrefillFieldsSchema = z + .object({ + id: z.number(), + }) + .and( + z.discriminatedUnion('type', [ + z.object({ + type: z.literal('text'), + label: z.string(), + value: z.string(), + }), + z.object({ + type: z.literal('number'), + label: z.string(), + value: z.string(), + }), + z.object({ + type: z.literal('radio'), + label: z.string(), + value: z.string(), + }), + z.object({ + type: z.literal('checkbox'), + label: z.string(), + value: z.array(z.string()), + }), + z.object({ + type: z.literal('dropdown'), + label: z.string(), + value: z.string(), + }), + ]), + ); + +export type TFieldMetaPrefillFieldsSchema = z.infer; + export const ZFieldMetaSchema = z .union([ // Handles an empty object being provided as fieldMeta. diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index 7fc624040..3b3f13218 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -227,7 +227,8 @@ export const templateRouter = router({ .output(ZCreateDocumentFromTemplateResponseSchema) .mutation(async ({ ctx, input }) => { const { teamId } = ctx; - const { templateId, recipients, distributeDocument, customDocumentDataId } = input; + const { templateId, recipients, distributeDocument, customDocumentDataId, prefillFields } = + input; const limits = await getServerLimits({ email: ctx.user.email, teamId }); @@ -242,6 +243,7 @@ export const templateRouter = router({ recipients, customDocumentDataId, requestMetadata: ctx.metadata, + prefillFields, }); if (distributeDocument) { diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts index f339476c8..080939d90 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -7,6 +7,7 @@ import { ZDocumentActionAuthTypesSchema, } from '@documenso/lib/types/document-auth'; import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; +import { ZFieldMetaPrefillFieldsSchema } from '@documenso/lib/types/field-meta'; import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; import { ZTemplateLiteSchema, @@ -67,6 +68,7 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({ 'The data ID of an alternative PDF to use when creating the document. If not provided, the PDF attached to the template will be used.', ) .optional(), + prefillFields: z.array(ZFieldMetaPrefillFieldsSchema).optional(), }); export const ZCreateDocumentFromTemplateResponseSchema = ZDocumentSchema; diff --git a/packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx b/packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx index 9df52f509..e6b78a729 100644 --- a/packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx +++ b/packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx @@ -104,8 +104,12 @@ export const DropdownFieldAdvancedSettings = ({ Select default option handleInput('textAlign', value)} + onValueChange={(value) => { + if (!value) { + return; + } + + handleInput('textAlign', value); + }} >