Files
documenso/packages/app-tests/e2e/envelope-editor-v2/envelope-fields.spec.ts
T
2026-06-09 13:48:40 +10:00

1027 lines
36 KiB
TypeScript

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<TFieldFlowResult> => {
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<string, unknown> => 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<TMultiRecipientFlowResult> => {
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<TAllFieldTypesFlowResult> => {
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<string, unknown> => {
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<string, unknown>;
};
// 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<TDuplicateDeleteFlowResult> => {
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<TChangeFieldTypeFlowResult> => {
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,
});
});
});