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": ""
}
```
This commit is contained in:
Catalin Pit
2025-03-06 00:44:09 +02:00
committed by David Nguyen
parent 083a706373
commit 422770a8c7
11 changed files with 1505 additions and 19 deletions

View File

@ -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,

View File

@ -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<

View File

@ -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');
});
});

View File

@ -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');
});
});

View File

@ -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<Field, 'id' | 'secondaryId' | 'templateId'>[] = [];
// 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,7 +483,12 @@ export const createDocumentFromTemplate = async ({
}
fieldsToCreate = fieldsToCreate.concat(
fields.map((field) => ({
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,
@ -278,8 +499,9 @@ export const createDocumentFromTemplate = async ({
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
})),
fieldMeta: updatedFieldMeta,
};
}),
);
});

View File

@ -122,6 +122,42 @@ export const ZFieldMetaNotOptionalSchema = z.discriminatedUnion('type', [
export type TFieldMetaNotOptionalSchema = z.infer<typeof ZFieldMetaNotOptionalSchema>;
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<typeof ZFieldMetaPrefillFieldsSchema>;
export const ZFieldMetaSchema = z
.union([
// Handles an empty object being provided as fieldMeta.

View File

@ -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) {

View File

@ -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;

View File

@ -104,8 +104,12 @@ export const DropdownFieldAdvancedSettings = ({
<Trans>Select default option</Trans>
</Label>
<Select
defaultValue={defaultValue}
value={defaultValue}
onValueChange={(val) => {
if (!val) {
return;
}
setDefaultValue(val);
handleFieldChange('defaultValue', val);
}}

View File

@ -127,7 +127,13 @@ export const TextFieldAdvancedSettings = ({
<Select
value={fieldState.textAlign}
onValueChange={(value) => handleInput('textAlign', value)}
onValueChange={(value) => {
if (!value) {
return;
}
handleInput('textAlign', value);
}}
>
<SelectTrigger className="bg-background mt-2">
<SelectValue placeholder="Select text align" />