mirror of
https://github.com/documenso/documenso.git
synced 2026-08-27 08:42:22 +10:00
fix: wip
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
+100
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user