This commit is contained in:
David Nguyen
2026-08-26 12:10:29 +10:00
parent 9dc83bdb06
commit 6db71b13d4
70 changed files with 5964 additions and 892 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.
@@ -0,0 +1,259 @@
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { expect, type Page, test } from '@playwright/test';
import { DocumentSigningOrder } from '@prisma/client';
import {
clickAddSignerButton,
dragGroupCardOntoCard,
getRecipientEmailInputs,
getRecipientStepCards,
moveGroupCardUp,
openDocumentEnvelopeEditor,
setRecipientEmail,
sweepRecipientRowOverCard,
type TEnvelopeEditorSurface,
toggleSigningOrder,
} from '../fixtures/envelope-editor';
/**
* Recipient signing groups are an SES feature: on AES/QES (CSC-mode)
* instances every signing recipient must hold a distinct signing order, so
* the editor must not offer the group affordances (card combine, row-to-card
* join) while still allowing step reordering and ungrouping of invalid
* API-created state.
*/
const GROUP_BADGE_TEXT = '2 recipients · any order';
/**
* Forces the client bundle into CSC mode for this page.
*
* `IS_INSTANCE_CSC_MODE()` reads `window.__ENV__.NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC`
* on the client, and `window.__ENV__` is assigned by an inline script during
* hydration — the property trap rewrites the flag whenever that assignment
* happens, regardless of script ordering.
*
* Passed as a raw string: the test runner's esbuild transform decorates
* serialized functions with `__name` helper calls that don't exist in the
* browser, which would make the script throw before installing the trap.
*/
const forceCscClientMode = async (page: Page) => {
await page.addInitScript(
`(() => {
let currentEnv;
Object.defineProperty(window, '__ENV__', {
configurable: true,
get: () => currentEnv,
set: (value) => {
currentEnv = { ...value, NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC: 'true' };
},
});
})();`,
);
};
/**
* CSC envelopes are always SEQUENTIAL, but the seeded blank document defaults
* to PARALLEL and the signing-order toggle is hidden in CSC mode — flip the
* meta directly and reload so the editor renders the sequential step UI.
*/
const makeEnvelopeSequential = async (surface: TEnvelopeEditorSurface) => {
if (!surface.envelopeId) {
throw new Error('Expected surface to have an envelope ID');
}
await prisma.envelope.update({
where: { id: surface.envelopeId },
data: {
documentMeta: {
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
},
},
});
await surface.root.reload();
};
const setupTwoSequentialSigners = async (surface: TEnvelopeEditorSurface) => {
const { root } = surface;
await setRecipientEmail(root, 0, 'alice@example.com');
await clickAddSignerButton(root);
await setRecipientEmail(root, 1, 'bob@example.com');
await expect(getRecipientStepCards(root)).toHaveCount(2);
};
const expectRecipientOrders = async (surface: TEnvelopeEditorSurface, expected: Array<[string, number]>) => {
const { envelopeId } = surface;
if (!envelopeId) {
throw new Error('Expected surface to have an envelope ID');
}
await expect
.poll(
async () => {
const recipients = await prisma.recipient.findMany({
where: { envelopeId },
});
return recipients.map((r) => [r.email, r.signingOrder] as const).sort((a, b) => a[0].localeCompare(b[0]));
},
{ timeout: 15_000 },
)
.toEqual([...expected].sort((a, b) => a[0].localeCompare(b[0])));
};
test.describe('document editor (csc mode)', () => {
test('csc: merging step cards into a group is unavailable', async ({ page }) => {
await forceCscClientMode(page);
const surface = await openDocumentEnvelopeEditor(page);
await makeEnvelopeSequential(surface);
await setupTwoSequentialSigners(surface);
// The keyboard combine helper throws when the target card never enters
// the combine state — exactly what "combining is disabled" looks like.
await expect(dragGroupCardOntoCard(surface.root, 1, 0)).rejects.toThrow(
'Combine drag did not reach the target card',
);
await expect(surface.root.getByText(GROUP_BADGE_TEXT)).not.toBeVisible();
await expect(getRecipientStepCards(surface.root)).toHaveCount(2);
await expectRecipientOrders(surface, [
['alice@example.com', 1],
['bob@example.com', 2],
]);
});
test('csc: dropping a recipient row onto a card does not join the group', async ({ page }) => {
await forceCscClientMode(page);
const surface = await openDocumentEnvelopeEditor(page);
await makeEnvelopeSequential(surface);
await setupTwoSequentialSigners(surface);
const sweep = await sweepRecipientRowOverCard(surface.root, 1, 0);
// The gap zones activating proves the drag itself was live, so the card
// staying inactive is a real refusal rather than a failed gesture.
expect(sweep.sawGapActive).toBe(true);
expect(sweep.sawCardActive).toBe(false);
expect(sweep.dropped).toBe(false);
await expect(surface.root.getByText(GROUP_BADGE_TEXT)).not.toBeVisible();
await expect(getRecipientStepCards(surface.root)).toHaveCount(2);
await expectRecipientOrders(surface, [
['alice@example.com', 1],
['bob@example.com', 2],
]);
});
test('csc: step cards can still be reordered', async ({ page }) => {
await forceCscClientMode(page);
const surface = await openDocumentEnvelopeEditor(page);
await makeEnvelopeSequential(surface);
await setupTwoSequentialSigners(surface);
await moveGroupCardUp(surface.root, 1);
await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue('bob@example.com');
await expect(getRecipientEmailInputs(surface.root).nth(1)).toHaveValue('alice@example.com');
await expectRecipientOrders(surface, [
['alice@example.com', 2],
['bob@example.com', 1],
]);
});
test('csc: an existing group can still be ungrouped', async ({ page }) => {
await forceCscClientMode(page);
const surface = await openDocumentEnvelopeEditor(page);
if (!surface.envelopeId) {
throw new Error('Expected surface to have an envelope ID');
}
// A signing group can only exist on a CSC envelope through out-of-band
// writes (API-created state); ungrouping must stay available to repair it.
await prisma.recipient.createMany({
data: [
{
envelopeId: surface.envelopeId,
email: 'alice@example.com',
name: 'Alice',
token: nanoid(),
signingOrder: 1,
},
{
envelopeId: surface.envelopeId,
email: 'bob@example.com',
name: 'Bob',
token: nanoid(),
signingOrder: 1,
},
],
});
await makeEnvelopeSequential(surface);
await expect(surface.root.getByText(GROUP_BADGE_TEXT)).toBeVisible();
const ungroupButton = surface.root.getByTestId('ungroup-step-button');
await expect(ungroupButton).toBeEnabled();
await ungroupButton.click();
await expect(surface.root.getByText(GROUP_BADGE_TEXT)).not.toBeVisible();
await expectRecipientOrders(surface, [
['alice@example.com', 1],
['bob@example.com', 2],
]);
});
});
test.describe('document editor (non-csc control)', () => {
// Control test proving `dragRecipientRowOntoCard` performs a real join when
// grouping is available — without it the disabled-join test above could
// pass vacuously because the drag itself silently failed.
test('control: dropping a recipient row onto a card joins the group', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const { root } = surface;
await setRecipientEmail(root, 0, 'alice@example.com');
await clickAddSignerButton(root);
await setRecipientEmail(root, 1, 'bob@example.com');
await toggleSigningOrder(root, true);
await expect(getRecipientStepCards(root)).toHaveCount(2);
// The mouse-driven join drag is timing-sensitive under load, so retry the
// whole gesture until the group forms; a cancelled sweep leaves the order
// untouched, and a completed drop joins the two rows into one step.
await expect(async () => {
const sweep = await sweepRecipientRowOverCard(root, 1, 0);
expect(sweep.dropped).toBe(true);
await expect(root.getByText(GROUP_BADGE_TEXT)).toBeVisible({ timeout: 2_000 });
}).toPass({ timeout: 90_000 });
await expectRecipientOrders(surface, [
['alice@example.com', 1],
['bob@example.com', 1],
]);
});
});
@@ -0,0 +1,137 @@
import { prisma } from '@documenso/prisma';
import { expect, test } from '@playwright/test';
import {
clickAddSignerButton,
dragGroupCardOntoCard,
dragRecipientRowToGap,
getRecipientEmailInputs,
getRecipientStepCards,
moveGroupCardUp,
openDocumentEnvelopeEditor,
openTemplateEnvelopeEditor,
setRecipientEmail,
setRecipientName,
type TEnvelopeEditorSurface,
toggleSigningOrder,
} from '../fixtures/envelope-editor';
const expectRecipientOrders = async (surface: TEnvelopeEditorSurface, expected: Array<[string, number]>) => {
const { envelopeId } = surface;
if (!envelopeId) {
throw new Error('Expected surface to have an envelope ID');
}
await expect
.poll(
async () => {
const recipients = await prisma.recipient.findMany({
where: { envelopeId },
});
return recipients.map((r) => [r.email, r.signingOrder] as const).sort((a, b) => a[0].localeCompare(b[0]));
},
{ timeout: 15_000 },
)
.toEqual([...expected].sort((a, b) => a[0].localeCompare(b[0])));
};
const runGroupingFlow = async (surface: TEnvelopeEditorSurface) => {
const { root } = surface;
await setRecipientEmail(root, 0, 'alice@example.com');
await setRecipientName(root, 0, 'Alice');
await clickAddSignerButton(root);
await setRecipientEmail(root, 1, 'bob@example.com');
await clickAddSignerButton(root);
await setRecipientEmail(root, 2, 'carol@example.com');
await toggleSigningOrder(root, true);
// Three standalone groups.
await expect(root.getByText('Group 1', { exact: true })).toBeVisible();
await expect(root.getByText('Group 3', { exact: true })).toBeVisible();
// Drag carol's card onto bob's card to merge them into one group.
await dragGroupCardOntoCard(root, 2, 1);
await expect(root.getByText('2 recipients · any order')).toBeVisible();
await expect(root.getByTestId('ungroup-step-button')).toBeVisible();
await expect(root.getByText('Group 3', { exact: true })).not.toBeVisible();
await expectRecipientOrders(surface, [
['alice@example.com', 1],
['bob@example.com', 2],
['carol@example.com', 2],
]);
// Groups survive a reload (grouped normalization on load).
await root.reload();
await expect(root.getByText('2 recipients · any order')).toBeVisible();
// Ungroup dissolves back into sequential groups.
await root.getByTestId('ungroup-step-button').click();
await expect(root.getByText('2 recipients · any order')).not.toBeVisible();
await expect(root.getByText('Group 3', { exact: true })).toBeVisible();
await expectRecipientOrders(surface, [
['alice@example.com', 1],
['bob@example.com', 2],
['carol@example.com', 3],
]);
// 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],
['bob@example.com', 3],
['carol@example.com', 2],
]);
};
test.describe('document editor', () => {
test('documents: group recipients via drag and drop and ungroup', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
await runGroupingFlow(surface);
});
test('documents: reordered group cards can still be dragged', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const { root } = surface;
await setRecipientEmail(root, 0, 'alice@example.com');
await clickAddSignerButton(root);
await setRecipientEmail(root, 1, 'bob@example.com');
await toggleSigningOrder(root, true);
await expect(getRecipientStepCards(root)).toHaveCount(2);
// Move bob's card into position 1.
await moveGroupCardUp(root, 1);
await expect(getRecipientEmailInputs(root).nth(0)).toHaveValue('bob@example.com');
await expect(getRecipientEmailInputs(root).nth(1)).toHaveValue('alice@example.com');
// Regression: after a reorder, the card moved into position 2 must still
// be draggable — positional drag-and-drop ids used to go stale on mounted
// cards, silently killing their drag handles. Prove it by completing a
// merge with the repositioned card.
await dragGroupCardOntoCard(root, 1, 0);
await expect(root.getByText('2 recipients · any order')).toBeVisible();
});
});
test.describe('template editor', () => {
test('templates: group recipients via drag and drop and ungroup', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
await runGroupingFlow(surface);
});
});
@@ -0,0 +1,124 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { expect, test } from '@playwright/test';
import {
clickAddSignerButton,
openDocumentEnvelopeEditor,
openTemplateEnvelopeEditor,
setRecipientEmail,
setRecipientName,
type TEnvelopeEditorSurface,
} from '../fixtures/envelope-editor';
/**
* A newly added recipient is created by the first autosave, and the editor
* must adopt the server-assigned id for subsequent saves. Historically the id
* was never synced back into the form while staying on the recipients step,
* so every following autosave resent the signer id-less — the server deleted
* the previously created row and recreated it with a fresh id and signing
* token, polluting the audit log with removed/added pairs on every edit.
*/
const getRecipientByEmail = async (surface: TEnvelopeEditorSurface, email: string) => {
const { envelopeId } = surface;
if (!envelopeId) {
throw new Error('Expected surface to have an envelope ID');
}
await expect.poll(async () => prisma.recipient.count({ where: { envelopeId, email } }), { timeout: 15_000 }).toBe(1);
return await prisma.recipient.findFirstOrThrow({ where: { envelopeId, email } });
};
const waitForRecipientName = async (surface: TEnvelopeEditorSurface, email: string, name: string) => {
await expect
.poll(
async () => {
const recipient = await prisma.recipient.findFirst({
where: { envelopeId: surface.envelopeId, email },
});
return recipient?.name;
},
{ timeout: 15_000 },
)
.toBe(name);
};
test.describe('document editor', () => {
test('documents: recipient id and token remain stable across autosaves', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const { root, envelopeId } = surface;
await setRecipientEmail(root, 0, 'alice@example.com');
await setRecipientName(root, 0, 'Alice');
const aliceInitial = await getRecipientByEmail(surface, 'alice@example.com');
// Edit while staying on the recipients step: the same row must be
// updated, not deleted and recreated.
await setRecipientName(root, 0, 'Alice Two');
await waitForRecipientName(surface, 'alice@example.com', 'Alice Two');
const aliceAfterEdit = await getRecipientByEmail(surface, 'alice@example.com');
expect(aliceAfterEdit.id).toBe(aliceInitial.id);
expect(aliceAfterEdit.token).toBe(aliceInitial.token);
// Adding another signer resends the whole set — alice must survive it,
// and bob must then survive an edit to alice.
await clickAddSignerButton(root);
await setRecipientEmail(root, 1, 'bob@example.com');
const bobInitial = await getRecipientByEmail(surface, 'bob@example.com');
await setRecipientName(root, 0, 'Alice Three');
await waitForRecipientName(surface, 'alice@example.com', 'Alice Three');
const aliceFinal = await getRecipientByEmail(surface, 'alice@example.com');
const bobFinal = await getRecipientByEmail(surface, 'bob@example.com');
expect(aliceFinal.id).toBe(aliceInitial.id);
expect(aliceFinal.token).toBe(aliceInitial.token);
expect(bobFinal.id).toBe(bobInitial.id);
expect(bobFinal.token).toBe(bobInitial.token);
// One creation per recipient and zero deletions in the audit trail.
const auditLogs = await prisma.documentAuditLog.findMany({
where: {
envelopeId,
type: {
in: [DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED, DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED],
},
},
});
const createdCount = auditLogs.filter((log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED).length;
const deletedCount = auditLogs.filter((log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED).length;
expect(deletedCount).toBe(0);
expect(createdCount).toBe(2);
});
});
test.describe('template editor', () => {
test('templates: recipient id and token remain stable across autosaves', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const { root } = surface;
await setRecipientEmail(root, 0, 'alice@example.com');
await setRecipientName(root, 0, 'Alice');
const aliceInitial = await getRecipientByEmail(surface, 'alice@example.com');
await setRecipientName(root, 0, 'Alice Two');
await waitForRecipientName(surface, 'alice@example.com', 'Alice Two');
const aliceAfterEdit = await getRecipientByEmail(surface, 'alice@example.com');
expect(aliceAfterEdit.id).toBe(aliceInitial.id);
expect(aliceAfterEdit.token).toBe(aliceInitial.token);
});
});
@@ -0,0 +1,168 @@
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentSigningOrder, SigningStatus } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import { getRecipientStepCards } from '../fixtures/envelope-editor';
/**
* Signing is sequential, so a recipient who has already acted is at or before
* the current step. Those steps hold persisted signing orders that the server
* will not let us rewrite, so ordering is locked up to and including the last
* of them. Later steps can only contain recipients who have not acted, so they
* stay fully rearrangeable.
*/
test('[LOCKED_STEPS]: ordering is locked up to the signed step and free afterwards', async ({ page }) => {
const { user, team } = await seedUser();
const { user: signed } = await seedUser();
const { user: signedPeer } = await seedUser();
const { user: pendingB } = await seedUser();
const { user: pendingC } = await seedUser();
const { document } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [signed, signedPeer, pendingB, pendingC],
recipientsCreateOptions: [
// Step 1 is a group, and one of its members has signed.
{ signingOrder: 1, signingStatus: SigningStatus.SIGNED },
{ signingOrder: 1, signingStatus: SigningStatus.NOT_SIGNED },
{ signingOrder: 2, signingStatus: SigningStatus.NOT_SIGNED },
{ signingOrder: 3, signingStatus: SigningStatus.NOT_SIGNED },
],
fields: [],
updateDocumentOptions: {
internalVersion: 2,
documentMeta: {
upsert: {
create: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
},
},
},
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`,
});
await expect(getRecipientStepCards(page)).toHaveCount(3);
const stepHandles = page.getByTestId('step-drag-handle');
// Step 1 contains a signed recipient, so it is locked.
await expect(stepHandles.nth(0)).toHaveClass(/pointer-events-none/);
// Its ungroup control is unavailable too — splitting it would rewrite the
// signed recipient's persisted order.
await expect(page.getByTestId('ungroup-step-button')).toBeDisabled();
// Steps after it hold only recipients who cannot have acted yet.
await expect(stepHandles.nth(1)).not.toHaveClass(/pointer-events-none/);
await expect(stepHandles.nth(2)).not.toHaveClass(/pointer-events-none/);
// Nothing was rewritten by simply opening the editor.
const recipients = await prisma.recipient.findMany({ where: { envelopeId: document.id } });
expect(recipients.find((r) => r.email === signed.email)?.signingOrder).toBe(1);
expect(recipients.find((r) => r.email === signedPeer.email)?.signingOrder).toBe(1);
expect(recipients.find((r) => r.email === pendingB.email)?.signingOrder).toBe(2);
expect(recipients.find((r) => r.email === pendingC.email)?.signingOrder).toBe(3);
});
/**
* A signed recipient can sit out of sequence — a direct template signs at its
* own template order, field insertion has no turn check, and a document can be
* switched from parallel to sequential mid-flight. The rule is "up to and
* including the last signed step" rather than "the signed prefix" precisely so
* these stay safe: the earlier unsigned step is locked too.
*/
test('[LOCKED_STEPS]: a signed recipient mid-sequence locks the steps before it', async ({ page }) => {
const { user, team } = await seedUser();
const { user: firstPending } = await seedUser();
const { user: signedSecond } = await seedUser();
const { user: lastPending } = await seedUser();
const { document } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [firstPending, signedSecond, lastPending],
recipientsCreateOptions: [
{ signingOrder: 1, signingStatus: SigningStatus.NOT_SIGNED },
{ signingOrder: 2, signingStatus: SigningStatus.SIGNED },
{ signingOrder: 3, signingStatus: SigningStatus.NOT_SIGNED },
],
fields: [],
updateDocumentOptions: {
internalVersion: 2,
documentMeta: {
upsert: {
create: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
},
},
},
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`,
});
await expect(getRecipientStepCards(page)).toHaveCount(3);
const stepHandles = page.getByTestId('step-drag-handle');
// Step 1 has no signed recipient, but it sits before one — moving it would
// reshuffle the signed recipient's position, so it is locked as well.
await expect(stepHandles.nth(0)).toHaveClass(/pointer-events-none/);
await expect(stepHandles.nth(1)).toHaveClass(/pointer-events-none/);
// Only the step after the signed one remains movable.
await expect(stepHandles.nth(2)).not.toHaveClass(/pointer-events-none/);
});
test('[LOCKED_STEPS]: every step stays draggable when nobody has signed', async ({ page }) => {
const { user, team } = await seedUser();
const { user: first } = await seedUser();
const { user: second } = await seedUser();
const { document } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [first, second],
recipientsCreateOptions: [
{ signingOrder: 1, signingStatus: SigningStatus.NOT_SIGNED },
{ signingOrder: 2, signingStatus: SigningStatus.NOT_SIGNED },
],
fields: [],
updateDocumentOptions: {
internalVersion: 2,
documentMeta: {
upsert: {
create: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
},
},
},
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`,
});
await expect(getRecipientStepCards(page)).toHaveCount(2);
const stepHandles = page.getByTestId('step-drag-handle');
await expect(stepHandles.nth(0)).not.toHaveClass(/pointer-events-none/);
await expect(stepHandles.nth(1)).not.toHaveClass(/pointer-events-none/);
});
@@ -0,0 +1,100 @@
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentSigningOrder, SigningStatus } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import { getRecipientEmailInputs, getRecipientStepCards, setRecipientName } from '../fixtures/envelope-editor';
/**
* A recipient with no persisted signing order means "last" everywhere on the
* server (queries sort NULLS LAST, and `effectiveOrder` maps null to the end).
* The editor must not invent an order from array position: the guess can land
* on a real order — which now means "same signing step" — or move the
* recipient ahead of one that was meant to sign first.
*/
const seedMixedOrderEnvelope = async (options: { firstOrder: number }) => {
const { user, team } = await seedUser();
const { user: ordered } = await seedUser();
const { user: unordered } = await seedUser();
const { document } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [ordered, unordered],
recipientsCreateOptions: [
{ signingOrder: options.firstOrder, signingStatus: SigningStatus.NOT_SIGNED },
// Created second, so it takes the higher id — this is the position the
// editor used to turn into `index + 1`.
{ signingOrder: null, signingStatus: SigningStatus.NOT_SIGNED },
],
fields: [],
updateDocumentOptions: {
internalVersion: 2,
documentMeta: {
upsert: {
create: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
},
},
},
});
return { user, team, document, orderedEmail: ordered.email, unorderedEmail: unordered.email };
};
test('[NULL_ORDER_HYDRATION]: a null-order recipient does not join an existing step', async ({ page }) => {
// The unordered recipient sits at index 1, so `index + 1` would collide with
// the persisted order 2 and render the two as one group.
const { user, team, document, orderedEmail, unorderedEmail } = await seedMixedOrderEnvelope({ firstOrder: 2 });
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`,
});
await expect(getRecipientEmailInputs(page)).toHaveCount(2);
// Two independent steps, not a single group.
await expect(getRecipientStepCards(page)).toHaveCount(2);
await expect(page.getByText('2 recipients · any order')).not.toBeVisible();
// Persisted orders must stay distinct once the editor saves.
await setRecipientName(page, 1, 'Renamed Unordered');
await expect
.poll(async () => {
const recipients = await prisma.recipient.findMany({ where: { envelopeId: document.id } });
return recipients.find((recipient) => recipient.email === unorderedEmail)?.name;
})
.toBe('Renamed Unordered');
const recipients = await prisma.recipient.findMany({ where: { envelopeId: document.id } });
const orderedRecipient = recipients.find((recipient) => recipient.email === orderedEmail);
const unorderedRecipient = recipients.find((recipient) => recipient.email === unorderedEmail);
expect(orderedRecipient?.signingOrder).not.toBe(unorderedRecipient?.signingOrder);
});
test('[NULL_ORDER_HYDRATION]: a null-order recipient stays last', async ({ page }) => {
// Persisted order 3 with the unordered recipient at index 1: `index + 1`
// would give it 2 and move it ahead of the recipient meant to sign first.
const { user, team, document, orderedEmail, unorderedEmail } = await seedMixedOrderEnvelope({ firstOrder: 3 });
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`,
});
await expect(getRecipientEmailInputs(page)).toHaveCount(2);
// The ordered recipient must still be shown first.
await expect(getRecipientEmailInputs(page).nth(0)).toHaveValue(orderedEmail);
await expect(getRecipientEmailInputs(page).nth(1)).toHaveValue(unorderedEmail);
});
@@ -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,
@@ -112,46 +112,71 @@ const runRecipientFlow = async (surface: TEnvelopeEditorSurface): Promise<Recipi
await setRecipientRole(surface.root, 1, 'Needs to approve');
await setRecipientRole(surface.root, 2, 'Receives copy');
// The role selects must reflect the change immediately, without requiring a
// navigation or reload (regression: leaf controllers going stale after a
// root-level signers array update).
await assertRecipientRole(surface.root, 1, 'Needs to approve');
await assertRecipientRole(surface.root, 2, 'Receives copy');
await getRecipientRemoveButtons(surface.root).nth(2).click();
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
await toggleSigningOrder(surface.root, true);
await expect(getSigningOrderInputs(surface.root)).toHaveCount(2);
await setSigningOrderValue(surface.root, 0, 2);
await expect(getRecipientStepCards(surface.root)).toHaveCount(2);
// 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,
},
],