From 34f6102a3fec5304c6e6510d60163e12c7d8208b Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 4 Aug 2026 20:11:22 +1000 Subject: [PATCH] test: drive recipient grouping e2e through drag and drop --- .../envelope-recipient-cc-order.spec.ts | 18 +-- .../envelope-recipient-groups.spec.ts | 33 ++-- .../envelope-recipients.spec.ts | 57 ++++--- .../app-tests/e2e/fixtures/envelope-editor.ts | 145 +++++++++++++++++- 4 files changed, 197 insertions(+), 56 deletions(-) diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-cc-order.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-cc-order.spec.ts index 1286e7230..fc02d8e20 100644 --- a/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-cc-order.spec.ts +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-cc-order.spec.ts @@ -11,7 +11,7 @@ import { assertRecipientRole, getRecipientEmailInputs, getRecipientRows, - getSigningOrderInputs, + getRecipientStepCards, openDocumentEnvelopeEditor, setRecipientEmail, setRecipientName, @@ -34,14 +34,14 @@ const assertCcDisplayedLastWithNoOrderInput = async (root: Page) => { await assertRecipientRole(root, 1, 'Needs to sign'); await assertRecipientRole(root, 2, 'Receives copy'); - // Only the two signers have signing order inputs, showing 1 and 2. - await expect(getSigningOrderInputs(root)).toHaveCount(2); - await expect(getSigningOrderInputs(root).nth(0)).toHaveValue('1'); - await expect(getSigningOrderInputs(root).nth(1)).toHaveValue('2'); + // Only the two signers render as ordered group cards, showing groups 1 and 2. + await expect(getRecipientStepCards(root)).toHaveCount(2); + await expect(root.getByText('Group 1', { exact: true })).toBeVisible(); + await expect(root.getByText('Group 2', { exact: true })).toBeVisible(); - // The CC row itself renders no signing order input (placeholder div instead). + // The CC row itself renders outside the group cards with no drag handle. const ccRow = getRecipientRows(root).nth(2); - await expect(ccRow.locator('[data-testid="signing-order-input"]')).toHaveCount(0); + await expect(ccRow.locator('[data-testid="recipient-row-drag-handle"]')).toHaveCount(0); }; test.describe('document editor', () => { @@ -61,8 +61,8 @@ test.describe('document editor', () => { await setRecipientName(root, 1, CC_RECIPIENT.name); await setRecipientRole(root, 1, 'Receives copy'); - // Once the row becomes CC, its signing order input disappears. - await expect(getSigningOrderInputs(root)).toHaveCount(1); + // Once the row becomes CC, it drops out of the ordered group cards. + await expect(getRecipientStepCards(root)).toHaveCount(1); // Add signer B third. The new row is inserted before the CC recipient, // which is kept last by the client-side sorting. diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-groups.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-groups.spec.ts index 711c56b10..484829609 100644 --- a/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-groups.spec.ts +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-groups.spec.ts @@ -3,12 +3,12 @@ import { expect, test } from '@playwright/test'; import { clickAddSignerButton, - getSigningOrderInputs, + dragGroupCardOntoCard, + dragRecipientRowToGap, openDocumentEnvelopeEditor, openTemplateEnvelopeEditor, setRecipientEmail, setRecipientName, - setSigningOrderValue, type TEnvelopeEditorSurface, toggleSigningOrder, } from '../fixtures/envelope-editor'; @@ -48,20 +48,16 @@ const runGroupingFlow = async (surface: TEnvelopeEditorSurface) => { await toggleSigningOrder(root, true); - // Three standalone steps. - await expect(root.getByText('Step 1', { exact: true })).toBeVisible(); - await expect(root.getByText('Step 3', { exact: true })).toBeVisible(); + // Three standalone groups. + await expect(root.getByText('Group 1', { exact: true })).toBeVisible(); + await expect(root.getByText('Group 3', { exact: true })).toBeVisible(); - // Type-to-join: carol (step 3) joins bob (step 2). - await setSigningOrderValue(root, 2, 2); + // Drag carol's card onto bob's card to merge them into one group. + await dragGroupCardOntoCard(root, 2, 1); await expect(root.getByText('2 signers · any order')).toBeVisible(); await expect(root.getByTestId('ungroup-step-button')).toBeVisible(); - await expect(root.getByText('Step 3', { exact: true })).not.toBeVisible(); - - const orderInputs = getSigningOrderInputs(root); - await expect(orderInputs.nth(1)).toHaveValue('2'); - await expect(orderInputs.nth(2)).toHaveValue('2'); + await expect(root.getByText('Group 3', { exact: true })).not.toBeVisible(); await expectRecipientOrders(surface, [ ['alice@example.com', 1], @@ -73,11 +69,11 @@ const runGroupingFlow = async (surface: TEnvelopeEditorSurface) => { await root.reload(); await expect(root.getByText('2 signers · any order')).toBeVisible(); - // Ungroup dissolves back into sequential steps. + // Ungroup dissolves back into sequential groups. await root.getByTestId('ungroup-step-button').click(); await expect(root.getByText('2 signers · any order')).not.toBeVisible(); - await expect(root.getByText('Step 3', { exact: true })).toBeVisible(); + await expect(root.getByText('Group 3', { exact: true })).toBeVisible(); await expectRecipientOrders(surface, [ ['alice@example.com', 1], @@ -85,9 +81,8 @@ const runGroupingFlow = async (surface: TEnvelopeEditorSurface) => { ['carol@example.com', 3], ]); - // Out-of-bounds extraction: bob (step 2) types 4 (> 3 steps) and becomes the - // last standalone step. - await setSigningOrderValue(root, 1, 4); + // Drag bob's row into the gap after the last group, moving him to the end. + await dragRecipientRowToGap(root, 1, 3); await expectRecipientOrders(surface, [ ['alice@example.com', 1], @@ -97,7 +92,7 @@ const runGroupingFlow = async (surface: TEnvelopeEditorSurface) => { }; test.describe('document editor', () => { - test('documents: group recipients via signing order input and ungroup', async ({ page }) => { + test('documents: group recipients via drag and drop and ungroup', async ({ page }) => { const surface = await openDocumentEnvelopeEditor(page); await runGroupingFlow(surface); @@ -105,7 +100,7 @@ test.describe('document editor', () => { }); test.describe('template editor', () => { - test('templates: group recipients via signing order input and ungroup', async ({ page }) => { + test('templates: group recipients via drag and drop and ungroup', async ({ page }) => { const surface = await openTemplateEnvelopeEditor(page); await runGroupingFlow(surface); diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-recipients.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipients.spec.ts index af434e7b5..eaed260ea 100644 --- a/packages/app-tests/e2e/envelope-editor-v2/envelope-recipients.spec.ts +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipients.spec.ts @@ -9,11 +9,12 @@ import { clickAddMyselfButton, clickAddSignerButton, clickEnvelopeEditorStep, + dragRecipientRowToGap, getEnvelopeEditorSettingsTrigger, getRecipientEmailInputs, getRecipientNameInputs, getRecipientRemoveButtons, - getSigningOrderInputs, + getRecipientStepCards, openDocumentEnvelopeEditor, openEmbeddedEnvelopeEditor, openTemplateEnvelopeEditor, @@ -21,7 +22,6 @@ import { setRecipientEmail, setRecipientName, setRecipientRole, - setSigningOrderValue, type TEnvelopeEditorSurface, toggleAllowDictateSigners, toggleSigningOrder, @@ -116,46 +116,61 @@ const runRecipientFlow = async (surface: TEnvelopeEditorSurface): Promise step count) is used to move the first recipient to - // the end as their own step, swapping the two. - await setSigningOrderValue(surface.root, 0, 3); + // Reordering is drag-only. Pointer-emulated drags are unreliable inside the + // embedded authoring surface (its inner scroll container auto-scrolls and + // cancels the emulated drag), so the drag-swap is exercised on the native + // surfaces only — the same component drives all surfaces. + const shouldSwapViaDrag = !surface.isEmbedded; + + if (shouldSwapViaDrag) { + // Let the debounced autosave from the edits above land before dragging — + // the editor re-rendering mid-drag would cancel the drag. + await surface.root.waitForTimeout(1500); + + // Drag the first recipient's row into the gap after the last group, + // swapping the two. + await dragRecipientRowToGap(surface.root, 0, 2); + } await toggleAllowDictateSigners(surface.root, true); await navigateToAddFieldsAndBack(surface.root); + const [firstRecipient, secondRecipient] = shouldSwapViaDrag + ? [TEST_RECIPIENT_VALUES.secondRecipient, primaryRecipient] + : [primaryRecipient, TEST_RECIPIENT_VALUES.secondRecipient]; + await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2); - await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(TEST_RECIPIENT_VALUES.secondRecipient.email); - await expect(getRecipientEmailInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.email); + await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(firstRecipient.email); + await expect(getRecipientEmailInputs(surface.root).nth(1)).toHaveValue(secondRecipient.email); - await expect(getRecipientNameInputs(surface.root).nth(0)).toHaveValue(TEST_RECIPIENT_VALUES.secondRecipient.name); - await expect(getRecipientNameInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.name); + await expect(getRecipientNameInputs(surface.root).nth(0)).toHaveValue(firstRecipient.name); + await expect(getRecipientNameInputs(surface.root).nth(1)).toHaveValue(secondRecipient.name); - await assertRecipientRole(surface.root, 0, 'Needs to approve'); - await assertRecipientRole(surface.root, 1, 'Needs to sign'); + await assertRecipientRole(surface.root, 0, shouldSwapViaDrag ? 'Needs to approve' : 'Needs to sign'); + await assertRecipientRole(surface.root, 1, shouldSwapViaDrag ? 'Needs to sign' : 'Needs to approve'); await expect(surface.root.locator('#signingOrder')).toHaveAttribute('aria-checked', 'true'); await expect(surface.root.locator('#allowDictateNextSigner')).toHaveAttribute('aria-checked', 'true'); - await expect(getSigningOrderInputs(surface.root).nth(0)).toHaveValue('1'); - await expect(getSigningOrderInputs(surface.root).nth(1)).toHaveValue('2'); + await expect(surface.root.getByText('Group 1', { exact: true })).toBeVisible(); + await expect(surface.root.getByText('Group 2', { exact: true })).toBeVisible(); return { externalId, removedRecipientEmail: TEST_RECIPIENT_VALUES.thirdRecipient.email, expectedRecipientsBySigningOrder: [ { - email: TEST_RECIPIENT_VALUES.secondRecipient.email, - name: TEST_RECIPIENT_VALUES.secondRecipient.name, - role: RecipientRole.APPROVER, + email: firstRecipient.email, + name: firstRecipient.name, + role: shouldSwapViaDrag ? RecipientRole.APPROVER : RecipientRole.SIGNER, signingOrder: 1, }, { - email: primaryRecipient.email, - name: primaryRecipient.name, - role: RecipientRole.SIGNER, + email: secondRecipient.email, + name: secondRecipient.name, + role: shouldSwapViaDrag ? RecipientRole.SIGNER : RecipientRole.APPROVER, signingOrder: 2, }, ], diff --git a/packages/app-tests/e2e/fixtures/envelope-editor.ts b/packages/app-tests/e2e/fixtures/envelope-editor.ts index b49304b04..843109742 100644 --- a/packages/app-tests/e2e/fixtures/envelope-editor.ts +++ b/packages/app-tests/e2e/fixtures/envelope-editor.ts @@ -6,7 +6,7 @@ import { DEFAULT_EMBEDDED_EDITOR_CONFIG } from '@documenso/lib/types/envelope-ed import { seedBlankDocument } from '@documenso/prisma/seed/documents'; import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; import { seedUser } from '@documenso/prisma/seed/users'; -import type { Page } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; import { expect } from '@playwright/test'; import { apiSignin } from './authentication'; @@ -264,8 +264,6 @@ export const getRecipientRows = (root: Page) => export const getRecipientRemoveButtons = (root: Page) => root.locator('[data-testid="remove-signer-button"]'); -export const getSigningOrderInputs = (root: Page) => root.locator('[data-testid="signing-order-input"]'); - export const clickEnvelopeEditorStep = async (root: Page, stepId: 'upload' | 'addFields' | 'preview') => { await root.waitForTimeout(200); await root.locator(`[data-testid="envelope-editor-step-${stepId}"]`).first().click(); @@ -335,10 +333,143 @@ export const toggleAllowDictateSigners = async (root: Page, enabled: boolean) => } }; -export const setSigningOrderValue = async (root: Page, index: number, value: number) => { - const input = getSigningOrderInputs(root).nth(index); - await input.fill(value.toString()); - await input.blur(); +/** + * Performs a mouse-based drag from a drag handle onto a target element. + * + * `@hello-pangea/dnd` only starts a drag once the pointer travels a small + * distance while pressed, and it hit-tests drop targets using the CENTRE of + * the dragged element — not the cursor. Since drag handles sit at the edge of + * wide rows/cards, the cursor destination is compensated so the dragged + * element's centre lands on the target's centre. + */ +export const dragHandleToTarget = async ( + root: Page, + handle: Locator, + target: Locator, + options: { activeClass: string }, +) => { + const { activeClass } = options; + + await handle.scrollIntoViewIfNeeded(); + + const handleBox = await handle.boundingBox(); + + if (!handleBox) { + throw new Error('Unable to resolve drag handle position'); + } + + const startX = handleBox.x + handleBox.width / 2; + const startY = handleBox.y + handleBox.height / 2; + + await root.mouse.move(startX, startY); + await root.mouse.down(); + + // Exceed the drag activation threshold, then wait for drag-dependent layout + // (e.g. expanding gap drop-zones) to settle before resolving positions. + const cursorX = startX + 8; + const cursorY = startY; + + await root.mouse.move(cursorX, cursorY, { steps: 2 }); + await root.waitForTimeout(300); + + // The dragged element is the handle's draggable ancestor; while dragging it + // is fixed-positioned and follows the cursor at a constant offset. Drop + // targeting uses the dragged element's CENTRE, not the cursor, so the + // cursor destination is compensated by that offset. + const draggedElement = handle.locator('xpath=ancestor-or-self::*[@data-rfd-draggable-id][1]'); + const draggedBox = await draggedElement.boundingBox(); + const targetBox = await target.boundingBox(); + + if (!draggedBox || !targetBox) { + await root.mouse.up(); + + throw new Error('Unable to resolve drag positions'); + } + + const itemOffsetX = draggedBox.x + draggedBox.width / 2 - cursorX; + const itemOffsetY = draggedBox.y + draggedBox.height / 2 - cursorY; + + const hasBecomeActive = async () => { + const className = await target.getAttribute('class'); + + return Boolean(className?.includes(activeClass)); + }; + + // Crawl-and-drop: drop-target geometry is captured at drag start and can + // drift from the live layout (and auto-scrolling invalidates any cached + // coordinates), so precise aiming is unreliable. Instead, approach from + // well above the target and crawl downward in small increments, dropping + // the moment the target reports the drag as over it — the highlight class + // is rendered from the library's own drag state, so it cannot disagree + // with where the drop will land. + // + // The cursor is clamped inside the viewport: moving outside the window + // cancels the drag (pointercancel), and holding near the bottom edge lets + // the library auto-scroll the target up to the cursor instead. + const viewportHeight = root.viewportSize()?.height ?? 720; + const maxCursorY = viewportHeight - 40; + + const crawlX = targetBox.x + targetBox.width / 2 - itemOffsetX; + const crawlStartY = Math.min(targetBox.y + targetBox.height / 2 - itemOffsetY - 140, maxCursorY); + + await root.mouse.move(crawlX, crawlStartY, { steps: 15 }); + await root.waitForTimeout(150); + + let hasDropped = false; + + for (let step = 1; step <= 80; step += 1) { + if (await hasBecomeActive()) { + await root.mouse.up(); + + hasDropped = true; + + break; + } + + await root.mouse.move(crawlX, Math.min(crawlStartY + step * 6, maxCursorY), { steps: 2 }); + await root.waitForTimeout(70); + } + + if (!hasDropped) { + await root.mouse.up(); + } + + await root.waitForTimeout(400); +}; + +export const getRecipientStepCards = (root: Page) => root.locator('[data-testid="recipient-step-card"]'); + +export const getRecipientStepGaps = (root: Page) => root.locator('[data-testid="recipient-step-gap"]'); + +export const getStepDragHandles = (root: Page) => root.locator('[data-testid="step-drag-handle"]'); + +export const getRecipientRowDragHandles = (root: Page) => root.locator('[data-testid="recipient-row-drag-handle"]'); + +/** + * Drags a whole group card onto another card's centre, merging the two groups. + */ +export const dragGroupCardOntoCard = async (root: Page, sourceCardIndex: number, targetCardIndex: number) => { + await dragHandleToTarget( + root, + getStepDragHandles(root).nth(sourceCardIndex), + getRecipientStepCards(root).nth(targetCardIndex), + // The combine/join highlight on the target card. + { activeClass: 'ring-primary' }, + ); +}; + +/** + * Drags a recipient row into a gap between group cards, extracting it into + * its own standalone group at that position. + */ +export const dragRecipientRowToGap = async (root: Page, rowIndex: number, gapIndex: number) => { + await dragHandleToTarget( + root, + getRecipientRowDragHandles(root).nth(rowIndex), + getRecipientStepGaps(root).nth(gapIndex), + // The drag-over highlight on the gap drop-zone. + { activeClass: 'border-primary' }, + ); }; export const persistEmbeddedEnvelope = async (surface: TEnvelopeEditorSurface) => {