test: drive recipient grouping e2e through drag and drop

This commit is contained in:
David Nguyen
2026-08-04 20:11:22 +10:00
parent 6a1236cac2
commit 34f6102a3f
4 changed files with 197 additions and 56 deletions
@@ -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.
@@ -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);
@@ -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<Recipi
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
await toggleSigningOrder(surface.root, true);
await expect(getSigningOrderInputs(surface.root)).toHaveCount(2);
await expect(getRecipientStepCards(surface.root)).toHaveCount(2);
// Typing an existing step number would group the recipients together, so an
// out-of-bounds value (> 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,
},
],
@@ -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) => {