import { nanoid } from '@documenso/lib/universal/id'; import { prisma } from '@documenso/prisma'; import { expect, type Page, test } from '@playwright/test'; import { FieldType } from '@prisma/client'; import { addEnvelopeItemPdf, clickAddMyselfButton, clickAddSignerButton, clickEnvelopeEditorStep, getEnvelopeEditorSettingsTrigger, getRecipientEmailInputs, getRecipientRemoveButtons, openDocumentEnvelopeEditor, openEmbeddedEnvelopeEditor, openTemplateEnvelopeEditor, persistEmbeddedEnvelope, setRecipientEmail, setRecipientName, type TEnvelopeEditorSurface, } from '../fixtures/envelope-editor'; import { expectToastTextToBeVisible } from '../fixtures/generic'; import { getKonvaElementCountForPage } from '../fixtures/konva'; type TFieldFlowResult = { externalId: string; recipientEmail: string; }; const TEST_FIELD_VALUES = { embeddedRecipient: { email: 'embedded-field-recipient@documenso.com', name: 'Embedded Field Recipient', }, }; const openSettingsDialog = async (root: Page) => { await getEnvelopeEditorSettingsTrigger(root).click(); await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible(); }; const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => { await openSettingsDialog(surface.root); await surface.root.locator('input[name="externalId"]').fill(externalId); await surface.root.getByRole('button', { name: 'Update' }).click(); if (!surface.isEmbedded) { await expectToastTextToBeVisible(surface.root, 'Envelope updated'); } }; const setupRecipientsForFieldPlacement = async (surface: TEnvelopeEditorSurface) => { if (surface.isEmbedded) { await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toHaveCount(0); await setRecipientEmail(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.email); await setRecipientName(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.name); return TEST_FIELD_VALUES.embeddedRecipient.email; } await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toBeVisible(); await clickAddMyselfButton(surface.root); await expect(getRecipientEmailInputs(surface.root).first()).toHaveValue(surface.userEmail); return surface.userEmail; }; type FieldButtonName = | 'Signature' | 'Email' | 'Name' | 'Initials' | 'Date' | 'Text' | 'Number' | 'Radio' | 'Checkbox' | 'Dropdown'; const placeFieldOnPdf = async (root: Page, fieldName: FieldButtonName, position: { x: number; y: number }) => { await root.getByRole('button', { name: fieldName, exact: true }).click(); const canvas = root.locator('.konva-container canvas').first(); await expect(canvas).toBeVisible(); await canvas.click({ position }); }; const selectRecipientInFieldsStep = async (root: Page, recipientIdentifier: string) => { await root.locator('button[role="combobox"]').click(); await root.getByText(recipientIdentifier).click(); }; const selectFieldOnCanvas = async (root: Page, position: { x: number; y: number }) => { const canvas = root.locator('.konva-container canvas').first(); await expect(canvas).toBeVisible(); await root.waitForTimeout(300); // Use force:true to bypass any floating action toolbar buttons that may intercept clicks. await canvas.click({ position, force: true }); }; const runAddAndPersistSignatureTextFields = async (surface: TEnvelopeEditorSurface): Promise => { const externalId = `e2e-fields-${nanoid()}`; if (surface.isEmbedded && !surface.envelopeId) { await addEnvelopeItemPdf(surface.root, 'embedded-fields.pdf'); } await updateExternalId(surface, externalId); const recipientEmail = await setupRecipientsForFieldPlacement(surface); await clickEnvelopeEditorStep(surface.root, 'addFields'); await expect(surface.root.getByText('Selected Recipient')).toBeVisible(); await expect(surface.root.locator('.konva-container canvas').first()).toBeVisible(); await placeFieldOnPdf(surface.root, 'Signature', { x: 120, y: 140 }); let fieldCount = await getKonvaElementCountForPage(surface.root, 1, '.field-group'); expect(fieldCount).toBe(1); await placeFieldOnPdf(surface.root, 'Text', { x: 220, y: 240 }); fieldCount = await getKonvaElementCountForPage(surface.root, 1, '.field-group'); expect(fieldCount).toBe(2); await clickEnvelopeEditorStep(surface.root, 'upload'); await expect(surface.root.getByRole('heading', { name: 'Recipients' })).toBeVisible(); await clickEnvelopeEditorStep(surface.root, 'addFields'); await surface.root.locator('.konva-container canvas').first().waitFor({ state: 'visible' }); await expect(surface.root.getByText('Selected Recipient')).toBeVisible(); fieldCount = await getKonvaElementCountForPage(surface.root, 1, '.field-group'); expect(fieldCount).toBe(2); return { externalId, recipientEmail, }; }; const getFieldMetaType = (fieldMeta: unknown) => { if (!isRecord(fieldMeta)) { return null; } return typeof fieldMeta.type === 'string' ? fieldMeta.type : null; }; const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; const assertFieldsPersistedInDatabase = async ({ surface, externalId, recipientEmail, }: { surface: TEnvelopeEditorSurface; externalId: string; recipientEmail: string; }) => { const envelope = await prisma.envelope.findFirstOrThrow({ where: { externalId, userId: surface.userId, teamId: surface.teamId, type: surface.envelopeType, }, orderBy: { createdAt: 'desc', }, include: { fields: true, recipients: true, }, }); const recipient = envelope.recipients.find((currentRecipient) => currentRecipient.email === recipientEmail); expect(recipient).toBeDefined(); const fieldTypes = envelope.fields.map((field) => field.type).sort(); const expectedFieldTypes = [FieldType.SIGNATURE, FieldType.TEXT].sort(); expect(envelope.fields).toHaveLength(2); expect(fieldTypes).toEqual(expectedFieldTypes); expect(new Set(envelope.fields.map((field) => field.envelopeItemId)).size).toBe(1); expect(envelope.fields.every((field) => field.recipientId === recipient?.id)).toBe(true); const signatureField = envelope.fields.find((field) => field.type === FieldType.SIGNATURE); const textField = envelope.fields.find((field) => field.type === FieldType.TEXT); expect(getFieldMetaType(signatureField?.fieldMeta)).toBe('signature'); expect(getFieldMetaType(textField?.fieldMeta)).toBe('text'); }; // --- Multi-recipient field flow --- type TMultiRecipientFlowResult = { externalId: string; firstRecipientEmail: string; secondRecipientEmail: string; }; const MULTI_RECIPIENT_VALUES = { secondSigner: { email: 'second-signer@test.documenso.com', name: 'Second Signer', }, }; const runMultiRecipientFieldFlow = async (surface: TEnvelopeEditorSurface): Promise => { const externalId = `e2e-multi-recip-${nanoid()}`; const root = surface.root; if (surface.isEmbedded && !surface.envelopeId) { await addEnvelopeItemPdf(root, 'embedded-fields.pdf'); } await updateExternalId(surface, externalId); // Add two recipients. let firstRecipientEmail: string; if (surface.isEmbedded) { await setRecipientEmail(root, 0, TEST_FIELD_VALUES.embeddedRecipient.email); await setRecipientName(root, 0, TEST_FIELD_VALUES.embeddedRecipient.name); firstRecipientEmail = TEST_FIELD_VALUES.embeddedRecipient.email; } else { await clickAddMyselfButton(root); firstRecipientEmail = surface.userEmail; } await clickAddSignerButton(root); await setRecipientEmail(root, 1, MULTI_RECIPIENT_VALUES.secondSigner.email); await setRecipientName(root, 1, MULTI_RECIPIENT_VALUES.secondSigner.name); // Navigate to fields step. await clickEnvelopeEditorStep(root, 'addFields'); await expect(root.getByText('Selected Recipient')).toBeVisible(); await expect(root.locator('.konva-container canvas').first()).toBeVisible(); let fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group'); expect(fieldCount).toBe(0); // Place Signature for recipient #1 (auto-selected). await placeFieldOnPdf(root, 'Signature', { x: 120, y: 140 }); fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group'); expect(fieldCount).toBe(1); // Switch recipient and place text field for recipient #2. await selectRecipientInFieldsStep(root, MULTI_RECIPIENT_VALUES.secondSigner.email); await placeFieldOnPdf(root, 'Text', { x: 220, y: 240 }); fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group'); expect(fieldCount).toBe(2); // Navigate away and back to ensure fields are persisted in the UI. await clickEnvelopeEditorStep(root, 'upload'); await clickEnvelopeEditorStep(root, 'addFields'); fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group'); expect(fieldCount).toBe(2); // Phase 2: cascade deletion — go back to recipients and remove the second one. await clickEnvelopeEditorStep(root, 'upload'); await expect(getRecipientEmailInputs(root)).toHaveCount(2); await getRecipientRemoveButtons(root).nth(1).click(); await expect(getRecipientEmailInputs(root)).toHaveCount(1); // Go back to fields and verify cascade removal. await clickEnvelopeEditorStep(root, 'addFields'); fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group'); expect(fieldCount).toBe(1); return { externalId, firstRecipientEmail, secondRecipientEmail: MULTI_RECIPIENT_VALUES.secondSigner.email, }; }; const assertMultiRecipientCascadePersistedInDatabase = async ({ surface, externalId, firstRecipientEmail, }: { surface: TEnvelopeEditorSurface; externalId: string; firstRecipientEmail: string; secondRecipientEmail: string; }) => { const envelope = await prisma.envelope.findFirstOrThrow({ where: { externalId, userId: surface.userId, teamId: surface.teamId, type: surface.envelopeType, }, orderBy: { createdAt: 'desc' }, include: { fields: true, recipients: true }, }); // After cascade deletion, only one recipient and one field should remain. expect(envelope.recipients).toHaveLength(1); expect(envelope.recipients[0].email).toBe(firstRecipientEmail); expect(envelope.fields).toHaveLength(1); expect(envelope.fields[0].type).toBe(FieldType.SIGNATURE); expect(envelope.fields[0].recipientId).toBe(envelope.recipients[0].id); }; // --- All 10 field types flow --- type TAllFieldTypesFlowResult = { externalId: string; }; const runAllFieldTypesFlow = async (surface: TEnvelopeEditorSurface): Promise => { const externalId = `e2e-all-fields-${nanoid()}`; const root = surface.root; if (surface.isEmbedded && !surface.envelopeId) { await addEnvelopeItemPdf(root, 'embedded-fields.pdf'); } await updateExternalId(surface, externalId); await setupRecipientsForFieldPlacement(surface); await clickEnvelopeEditorStep(root, 'addFields'); await expect(root.locator('.konva-container canvas').first()).toBeVisible(); // Place and configure each field type immediately after placement. // After placeFieldOnPdf, the sidebar shows the field's config form (field is selected in React state). // 1. Signature: place and set fontSize to 24. await placeFieldOnPdf(root, 'Signature', { x: 120, y: 50 }); await root.locator('[data-testid="field-form-fontSize"]').fill('24'); // 2. Email: place and set textAlign to center. await placeFieldOnPdf(root, 'Email', { x: 120, y: 100 }); await root.locator('[data-testid="field-form-textAlign"]').click(); await root.getByRole('option', { name: 'Center' }).click(); // 3. Name: place and set textAlign to right. await placeFieldOnPdf(root, 'Name', { x: 120, y: 150 }); await root.locator('[data-testid="field-form-textAlign"]').click(); await root.getByRole('option', { name: 'Right' }).click(); // 4. Initials: place and set fontSize to 16. await placeFieldOnPdf(root, 'Initials', { x: 120, y: 200 }); await root.locator('[data-testid="field-form-fontSize"]').fill('16'); // 5. Date: place and set textAlign to center. await placeFieldOnPdf(root, 'Date', { x: 120, y: 250 }); await root.locator('[data-testid="field-form-textAlign"]').click(); await root.getByRole('option', { name: 'Center' }).click(); // 6. Text: place and configure label, placeholder, text, characterLimit, required. await placeFieldOnPdf(root, 'Text', { x: 120, y: 300 }); await root.locator('[data-testid="field-form-label"]').fill('Test Label'); await root.locator('[data-testid="field-form-placeholder"]').fill('Enter text here'); await root.locator('[data-testid="field-form-text"]').fill('Default text value'); await root.locator('[data-testid="field-form-characterLimit"]').fill('100'); await root.locator('[data-testid="field-form-required"]').click(); // 7. Number: place and configure label, placeholder, numberFormat, minValue, maxValue, required. await placeFieldOnPdf(root, 'Number', { x: 120, y: 350 }); await root.locator('[data-testid="field-form-label"]').fill('Amount'); await root.locator('[data-testid="field-form-placeholder"]').fill('0.00'); await root.locator('[data-testid="field-form-numberFormat"]').click(); await root.getByRole('option', { name: '123,456,789.00' }).click(); await root.locator('[data-testid="field-form-minValue"]').fill('0'); await root.locator('[data-testid="field-form-maxValue"]').fill('1000'); await root.locator('[data-testid="field-form-required"]').click(); // 8. Radio: place and configure two options, pre-select first, set direction to horizontal. await placeFieldOnPdf(root, 'Radio', { x: 120, y: 400 }); // The first option already exists with default value "Default value". Fill it. await root.locator('[data-testid="field-form-values-0-value"]').fill('Option A'); // Add a second option. await root.locator('[data-testid="field-form-values-add"]').click(); await root.locator('[data-testid="field-form-values-1-value"]').fill('Option B'); // Pre-select the first option (click its checkbox). await root.locator('[data-testid="field-form-values-0-checked"]').click(); // Set direction to horizontal. await root.locator('[data-testid="field-form-direction"]').click(); await root.getByRole('option', { name: 'Horizontal' }).click(); // 9. Checkbox: place and configure two options, check both, set validation rule. await placeFieldOnPdf(root, 'Checkbox', { x: 120, y: 450 }); // Fill first option value. await root.locator('[data-testid="field-form-values-0-value"]').fill('Check A'); // Add a second option. await root.locator('[data-testid="field-form-values-add"]').click(); await root.locator('[data-testid="field-form-values-1-value"]').fill('Check B'); // Check both options (click their checkboxes). await root.locator('[data-testid="field-form-values-0-checked"]').click(); await root.locator('[data-testid="field-form-values-1-checked"]').click(); // Set validation: "Select at least" 1. await root.locator('[data-testid="field-form-validationRule"]').click(); await root.getByRole('option', { name: 'Select at least' }).click(); // Set validation length to 1. await root.locator('[data-testid="field-form-validationLength"]').click(); await root.getByRole('option', { name: '1', exact: true }).click(); // 10. Dropdown: place and configure two options, set default value. await placeFieldOnPdf(root, 'Dropdown', { x: 120, y: 500 }); // First option already has "Option 1". Change it to "Red". await root.locator('[data-testid="field-form-values-0-value"]').fill('Red'); // Add a second option. await root.locator('[data-testid="field-form-values-add"]').click(); await root.locator('[data-testid="field-form-values-1-value"]').clear(); await root.locator('[data-testid="field-form-values-1-value"]').fill('Blue'); // Set default value to "Red". await root.locator('[data-testid="field-form-defaultValue"]').click(); await root.getByRole('option', { name: 'Red' }).click(); let fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group'); expect(fieldCount).toBe(10); // Wait briefly for auto-save to fire on the last configured field. await root.waitForTimeout(500); // Navigate away and back to verify persistence. await clickEnvelopeEditorStep(root, 'upload'); await clickEnvelopeEditorStep(root, 'addFields'); fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group'); expect(fieldCount).toBe(10); return { externalId }; }; const assertAllFieldTypesPersistedInDatabase = async ({ surface, externalId, }: { surface: TEnvelopeEditorSurface; externalId: string; }) => { const envelope = await prisma.envelope.findFirstOrThrow({ where: { externalId, userId: surface.userId, teamId: surface.teamId, type: surface.envelopeType, }, orderBy: { createdAt: 'desc' }, include: { fields: true }, }); expect(envelope.fields).toHaveLength(10); const fieldsByType = new Map(envelope.fields.map((f) => [f.type, f])); // Helper to safely access fieldMeta as a record. const meta = (type: FieldType): Record => { const field = fieldsByType.get(type); expect(field).toBeDefined(); const fieldMeta = field!.fieldMeta; expect(typeof fieldMeta).toBe('object'); expect(fieldMeta).not.toBeNull(); return fieldMeta as Record; }; // SIGNATURE expect(meta(FieldType.SIGNATURE).type).toBe('signature'); expect(meta(FieldType.SIGNATURE).fontSize).toBe(24); // EMAIL expect(meta(FieldType.EMAIL).type).toBe('email'); expect(meta(FieldType.EMAIL).textAlign).toBe('center'); // NAME expect(meta(FieldType.NAME).type).toBe('name'); expect(meta(FieldType.NAME).textAlign).toBe('right'); // INITIALS expect(meta(FieldType.INITIALS).type).toBe('initials'); expect(meta(FieldType.INITIALS).fontSize).toBe(16); // DATE expect(meta(FieldType.DATE).type).toBe('date'); expect(meta(FieldType.DATE).textAlign).toBe('center'); // TEXT expect(meta(FieldType.TEXT).type).toBe('text'); expect(meta(FieldType.TEXT).label).toBe('Test Label'); expect(meta(FieldType.TEXT).placeholder).toBe('Enter text here'); expect(meta(FieldType.TEXT).text).toBe('Default text value'); expect(meta(FieldType.TEXT).characterLimit).toBe(100); expect(meta(FieldType.TEXT).required).toBe(true); // NUMBER expect(meta(FieldType.NUMBER).type).toBe('number'); expect(meta(FieldType.NUMBER).label).toBe('Amount'); expect(meta(FieldType.NUMBER).placeholder).toBe('0.00'); expect(meta(FieldType.NUMBER).numberFormat).toBe('123,456,789.00'); expect(meta(FieldType.NUMBER).minValue).toBe(0); expect(meta(FieldType.NUMBER).maxValue).toBe(1000); expect(meta(FieldType.NUMBER).required).toBe(true); // RADIO expect(meta(FieldType.RADIO).type).toBe('radio'); expect(meta(FieldType.RADIO).direction).toBe('horizontal'); const radioValues = meta(FieldType.RADIO).values as Array<{ value: string; checked: boolean; }>; expect(radioValues).toHaveLength(2); expect(radioValues[0].value).toBe('Option A'); expect(radioValues[0].checked).toBe(true); expect(radioValues[1].value).toBe('Option B'); expect(radioValues[1].checked).toBe(false); // CHECKBOX expect(meta(FieldType.CHECKBOX).type).toBe('checkbox'); expect(meta(FieldType.CHECKBOX).validationRule).toBe('Select at least'); expect(meta(FieldType.CHECKBOX).validationLength).toBe(1); const checkboxValues = meta(FieldType.CHECKBOX).values as Array<{ value: string; checked: boolean; }>; expect(checkboxValues).toHaveLength(2); expect(checkboxValues[0].value).toBe('Check A'); expect(checkboxValues[0].checked).toBe(true); expect(checkboxValues[1].value).toBe('Check B'); expect(checkboxValues[1].checked).toBe(true); // DROPDOWN expect(meta(FieldType.DROPDOWN).type).toBe('dropdown'); expect(meta(FieldType.DROPDOWN).defaultValue).toBe('Red'); const dropdownValues = meta(FieldType.DROPDOWN).values as Array<{ value: string }>; expect(dropdownValues).toHaveLength(2); expect(dropdownValues[0].value).toBe('Red'); expect(dropdownValues[1].value).toBe('Blue'); }; // --- Duplicate and delete fields flow --- type TDuplicateDeleteFlowResult = { externalId: string; }; const runDuplicateDeleteFieldFlow = async (surface: TEnvelopeEditorSurface): Promise => { const externalId = `e2e-dup-del-${nanoid()}`; const root = surface.root; if (surface.isEmbedded && !surface.envelopeId) { await addEnvelopeItemPdf(root, 'embedded-fields.pdf'); } await updateExternalId(surface, externalId); await setupRecipientsForFieldPlacement(surface); await clickEnvelopeEditorStep(root, 'addFields'); await expect(root.locator('.konva-container canvas').first()).toBeVisible(); // Place a Signature field. await placeFieldOnPdf(root, 'Signature', { x: 150, y: 150 }); let fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group'); expect(fieldCount).toBe(1); // Select the field on canvas to show the action toolbar. await selectFieldOnCanvas(root, { x: 150, y: 150 }); await expect(root.locator('button[title="Duplicate"]')).toBeVisible(); // Duplicate the field. await root.locator('button[title="Duplicate"]').click(); fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group'); expect(fieldCount).toBe(2); // Navigate away and back to persist changes. await clickEnvelopeEditorStep(root, 'upload'); await clickEnvelopeEditorStep(root, 'addFields'); fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group'); expect(fieldCount).toBe(2); // Select a field and delete it via the Remove button. await selectFieldOnCanvas(root, { x: 150, y: 150 }); await expect(root.locator('button[title="Remove"]')).toBeVisible(); await root.locator('button[title="Remove"]').click(); fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group'); expect(fieldCount).toBe(1); // Navigate away and back to verify persistence. await clickEnvelopeEditorStep(root, 'upload'); await clickEnvelopeEditorStep(root, 'addFields'); fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group'); expect(fieldCount).toBe(1); return { externalId }; }; const assertDuplicateDeleteFieldPersistedInDatabase = async ({ surface, externalId, }: { surface: TEnvelopeEditorSurface; externalId: string; }) => { const envelope = await prisma.envelope.findFirstOrThrow({ where: { externalId, userId: surface.userId, teamId: surface.teamId, type: surface.envelopeType, }, orderBy: { createdAt: 'desc' }, include: { fields: true }, }); // After duplicating (2 fields) then deleting one, exactly 1 SIGNATURE field should remain. expect(envelope.fields).toHaveLength(1); expect(envelope.fields[0].type).toBe(FieldType.SIGNATURE); }; // --- Change field type flow --- type TChangeFieldTypeFlowResult = { externalId: string; }; const FIELD_A_POSITION = { x: 150, y: 150 }; const FIELD_B_POSITION = { x: 150, y: 250 }; const changeFieldTypeViaToolbar = async (root: Page, newTypeLabel: FieldButtonName) => { await expect(root.locator('button[title="Change Field Type"]')).toBeVisible(); await root.locator('button[title="Change Field Type"]').click(); // The CommandDialog uses role="option" for items; sidebar palette buttons use role="button". const option = root.getByRole('option', { name: newTypeLabel, exact: true }); await expect(option).toBeVisible(); await option.click(); // Wait for the CommandDialog to close (selection persists so the toolbar remains). await expect(root.getByRole('dialog')).toHaveCount(0); }; /** * Multi-select fields on the konva canvas by drawing a marquee selection rectangle. * * The editor's stage mousedown/mousemove/mouseup handlers create a Konva selection * rectangle when the user drags on empty stage area. All field groups that intersect * the rectangle are selected at once. This is the canonical multi-select gesture. */ const marqueeSelectFieldsOnCanvas = async ( root: Page, start: { x: number; y: number }, end: { x: number; y: number }, ) => { const canvas = root.locator('.konva-container canvas').first(); await expect(canvas).toBeVisible(); const box = await canvas.boundingBox(); if (!box) { throw new Error('Canvas bounding box not available for marquee selection.'); } // The marquee gesture must start on empty stage (not on a field) and pass through // intermediate points so the editor's mousemove handler can grow the rectangle. await root.mouse.move(box.x + start.x, box.y + start.y); await root.mouse.down(); await root.mouse.move(box.x + (start.x + end.x) / 2, box.y + (start.y + end.y) / 2, { steps: 5 }); await root.mouse.move(box.x + end.x, box.y + end.y, { steps: 5 }); await root.mouse.up(); }; const runChangeFieldTypeFlow = async (surface: TEnvelopeEditorSurface): Promise => { const externalId = `e2e-change-type-${nanoid()}`; const root = surface.root; if (surface.isEmbedded && !surface.envelopeId) { await addEnvelopeItemPdf(root, 'embedded-fields.pdf'); } await updateExternalId(surface, externalId); await setupRecipientsForFieldPlacement(surface); await clickEnvelopeEditorStep(root, 'addFields'); await expect(root.locator('.konva-container canvas').first()).toBeVisible(); // Place two fields of different types: Signature (A) and Name (B). await placeFieldOnPdf(root, 'Signature', FIELD_A_POSITION); await placeFieldOnPdf(root, 'Name', FIELD_B_POSITION); let fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group'); expect(fieldCount).toBe(2); // --- Phase 1: single field type change --- // Select field A (Signature) and change it to Text via the toolbar. await selectFieldOnCanvas(root, FIELD_A_POSITION); await changeFieldTypeViaToolbar(root, 'Text'); // Field count must remain stable -- changing type doesn't add/remove fields. fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group'); expect(fieldCount).toBe(2); // Navigate away and back to verify the change is persisted in local state. await clickEnvelopeEditorStep(root, 'upload'); await clickEnvelopeEditorStep(root, 'addFields'); fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group'); expect(fieldCount).toBe(2); // --- Phase 2: multi-field type change --- // Use a marquee drag-selection rectangle to capture both fields at once. // Fields are at (150, 150) and (150, 250) with default dims ~90x30; drag from // (50, 100) to (260, 290) encloses both with margin. await marqueeSelectFieldsOnCanvas(root, { x: 50, y: 100 }, { x: 260, y: 290 }); // With mixed-type selection (Text + Name), change both to Date. await changeFieldTypeViaToolbar(root, 'Date'); fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group'); expect(fieldCount).toBe(2); // Navigate away and back to verify persistence. await clickEnvelopeEditorStep(root, 'upload'); await clickEnvelopeEditorStep(root, 'addFields'); fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group'); expect(fieldCount).toBe(2); return { externalId }; }; const assertChangeFieldTypePersistedInDatabase = async ({ surface, externalId, }: { surface: TEnvelopeEditorSurface; externalId: string; }) => { const envelope = await prisma.envelope.findFirstOrThrow({ where: { externalId, userId: surface.userId, teamId: surface.teamId, type: surface.envelopeType, }, orderBy: { createdAt: 'desc' }, include: { fields: true }, }); // Started with Signature + Name, then both were converted to Date. // Use sorted .map() in the assertion so any failure prints which types were found. const actualTypes = envelope.fields.map((field) => field.type).sort(); const expectedTypes = [FieldType.DATE, FieldType.DATE]; expect(envelope.fields).toHaveLength(2); expect(actualTypes).toEqual(expectedTypes); // Each field's meta must have been reset to the new type's defaults. const actualMetaTypes = envelope.fields.map((field) => getFieldMetaType(field.fieldMeta)).sort(); expect(actualMetaTypes).toEqual(['date', 'date']); }; // --- Test describe blocks --- test.describe('document editor', () => { test('add and persist signature/text fields', async ({ page }) => { const surface = await openDocumentEnvelopeEditor(page); const result = await runAddAndPersistSignatureTextFields(surface); await assertFieldsPersistedInDatabase({ surface, ...result, }); }); test('multi-recipient field placement, switching, and cascade deletion', async ({ page }) => { const surface = await openDocumentEnvelopeEditor(page); const result = await runMultiRecipientFieldFlow(surface); await assertMultiRecipientCascadePersistedInDatabase({ surface, ...result, }); }); test('duplicate and delete fields via canvas action toolbar', async ({ page }) => { const surface = await openDocumentEnvelopeEditor(page); const result = await runDuplicateDeleteFieldFlow(surface); await assertDuplicateDeleteFieldPersistedInDatabase({ surface, ...result, }); }); test('place and configure all 10 field types', async ({ page }) => { const surface = await openDocumentEnvelopeEditor(page); const result = await runAllFieldTypesFlow(surface); await assertAllFieldTypesPersistedInDatabase({ surface, ...result, }); }); test('change field type via canvas action toolbar (single and multi-select)', async ({ page }) => { const surface = await openDocumentEnvelopeEditor(page); const result = await runChangeFieldTypeFlow(surface); await assertChangeFieldTypePersistedInDatabase({ surface, ...result, }); }); }); test.describe('template editor', () => { test('add and persist signature/text fields', async ({ page }) => { const surface = await openTemplateEnvelopeEditor(page); const result = await runAddAndPersistSignatureTextFields(surface); await assertFieldsPersistedInDatabase({ surface, ...result, }); }); test('multi-recipient field placement, switching, and cascade deletion', async ({ page }) => { const surface = await openTemplateEnvelopeEditor(page); const result = await runMultiRecipientFieldFlow(surface); await assertMultiRecipientCascadePersistedInDatabase({ surface, ...result, }); }); test('duplicate and delete fields via canvas action toolbar', async ({ page }) => { const surface = await openTemplateEnvelopeEditor(page); const result = await runDuplicateDeleteFieldFlow(surface); await assertDuplicateDeleteFieldPersistedInDatabase({ surface, ...result, }); }); test('place and configure all 10 field types', async ({ page }) => { const surface = await openTemplateEnvelopeEditor(page); const result = await runAllFieldTypesFlow(surface); await assertAllFieldTypesPersistedInDatabase({ surface, ...result, }); }); test('change field type via canvas action toolbar (single and multi-select)', async ({ page }) => { const surface = await openTemplateEnvelopeEditor(page); const result = await runChangeFieldTypeFlow(surface); await assertChangeFieldTypePersistedInDatabase({ surface, ...result, }); }); }); test.describe('embedded create', () => { test('add and persist signature/text fields', async ({ page }) => { const surface = await openEmbeddedEnvelopeEditor(page, { envelopeType: 'DOCUMENT', tokenNamePrefix: 'e2e-embed-fields', }); const result = await runAddAndPersistSignatureTextFields(surface); await persistEmbeddedEnvelope(surface); await assertFieldsPersistedInDatabase({ surface, ...result, }); }); test('multi-recipient field placement, switching, and cascade deletion', async ({ page }) => { const surface = await openEmbeddedEnvelopeEditor(page, { envelopeType: 'DOCUMENT', tokenNamePrefix: 'e2e-embed-multi-recip', }); const result = await runMultiRecipientFieldFlow(surface); await persistEmbeddedEnvelope(surface); await assertMultiRecipientCascadePersistedInDatabase({ surface, ...result, }); }); test('duplicate and delete fields via canvas action toolbar', async ({ page }) => { const surface = await openEmbeddedEnvelopeEditor(page, { envelopeType: 'DOCUMENT', tokenNamePrefix: 'e2e-embed-dup-del', }); const result = await runDuplicateDeleteFieldFlow(surface); await persistEmbeddedEnvelope(surface); await assertDuplicateDeleteFieldPersistedInDatabase({ surface, ...result, }); }); test('place and configure all 10 field types', async ({ page }) => { const surface = await openEmbeddedEnvelopeEditor(page, { envelopeType: 'DOCUMENT', tokenNamePrefix: 'e2e-embed-all-fields', }); const result = await runAllFieldTypesFlow(surface); await persistEmbeddedEnvelope(surface); await assertAllFieldTypesPersistedInDatabase({ surface, ...result, }); }); test('change field type via canvas action toolbar (single and multi-select)', async ({ page }) => { const surface = await openEmbeddedEnvelopeEditor(page, { envelopeType: 'DOCUMENT', tokenNamePrefix: 'e2e-embed-change-type', }); const result = await runChangeFieldTypeFlow(surface); await persistEmbeddedEnvelope(surface); await assertChangeFieldTypePersistedInDatabase({ surface, ...result, }); }); }); test.describe('embedded edit', () => { test('add and persist signature/text fields', async ({ page }) => { const surface = await openEmbeddedEnvelopeEditor(page, { envelopeType: 'TEMPLATE', mode: 'edit', tokenNamePrefix: 'e2e-embed-fields', }); const result = await runAddAndPersistSignatureTextFields(surface); await persistEmbeddedEnvelope(surface); await assertFieldsPersistedInDatabase({ surface, ...result, }); }); test('multi-recipient field placement, switching, and cascade deletion', async ({ page }) => { const surface = await openEmbeddedEnvelopeEditor(page, { envelopeType: 'TEMPLATE', mode: 'edit', tokenNamePrefix: 'e2e-embed-multi-recip', }); const result = await runMultiRecipientFieldFlow(surface); await persistEmbeddedEnvelope(surface); await assertMultiRecipientCascadePersistedInDatabase({ surface, ...result, }); }); test('duplicate and delete fields via canvas action toolbar', async ({ page }) => { const surface = await openEmbeddedEnvelopeEditor(page, { envelopeType: 'TEMPLATE', mode: 'edit', tokenNamePrefix: 'e2e-embed-dup-del', }); const result = await runDuplicateDeleteFieldFlow(surface); await persistEmbeddedEnvelope(surface); await assertDuplicateDeleteFieldPersistedInDatabase({ surface, ...result, }); }); test('place and configure all 10 field types', async ({ page }) => { const surface = await openEmbeddedEnvelopeEditor(page, { envelopeType: 'TEMPLATE', mode: 'edit', tokenNamePrefix: 'e2e-embed-all-fields', }); const result = await runAllFieldTypesFlow(surface); await persistEmbeddedEnvelope(surface); await assertAllFieldTypesPersistedInDatabase({ surface, ...result, }); }); test('change field type via canvas action toolbar (single and multi-select)', async ({ page }) => { const surface = await openEmbeddedEnvelopeEditor(page, { envelopeType: 'TEMPLATE', mode: 'edit', tokenNamePrefix: 'e2e-embed-change-type', }); const result = await runChangeFieldTypeFlow(surface); await persistEmbeddedEnvelope(surface); await assertChangeFieldTypePersistedInDatabase({ surface, ...result, }); }); });