mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 01:15:49 +10:00
fix: sort CC recipients last (#2930)
This commit is contained in:
@@ -2,10 +2,20 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import {
|
||||
DocumentSigningOrder,
|
||||
DocumentStatus,
|
||||
FieldType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
} from '@documenso/prisma/client';
|
||||
import { seedBlankDocument, seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { signSignaturePad } from '../../fixtures/signature';
|
||||
|
||||
test.describe('Document API', () => {
|
||||
test('sendDocument: should respect sendCompletionEmails setting', async ({ request }) => {
|
||||
@@ -432,4 +442,194 @@ test.describe('Document API', () => {
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(response.status()).toBe(200);
|
||||
});
|
||||
|
||||
test('sendDocument: should complete document immediately when all recipients are CC', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// Create a blank document and get it with envelope items
|
||||
const blankDocument = await seedBlankDocument(user, team.id);
|
||||
const document = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: blankDocument.id },
|
||||
include: { envelopeItems: true },
|
||||
});
|
||||
|
||||
// Add two CC recipients without any fields, mirroring the production
|
||||
// state where CC recipients are created pre-signed.
|
||||
for (const email of ['cc1@example.com', 'cc2@example.com']) {
|
||||
await prisma.recipient.create({
|
||||
data: {
|
||||
email,
|
||||
name: 'Test CC',
|
||||
role: RecipientRole.CC,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
token: nanoid(),
|
||||
envelopeId: document.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const response = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
// The document seals asynchronously and completes without anyone signing.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const updatedDocument = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
});
|
||||
|
||||
return updatedDocument.status;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
test('sendDocument: should not block initial sequential send when CC recipient is first in signing order', async ({
|
||||
request,
|
||||
page,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// Create a blank document and get it with envelope items
|
||||
const blankDocument = await seedBlankDocument(user, team.id);
|
||||
const document = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: blankDocument.id },
|
||||
include: { envelopeItems: true },
|
||||
});
|
||||
|
||||
await prisma.documentMeta.update({
|
||||
where: { id: document.documentMetaId },
|
||||
data: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
});
|
||||
|
||||
// CC recipient first in the signing order, mirroring the production
|
||||
// state where CC recipients are created pre-signed.
|
||||
await prisma.recipient.create({
|
||||
data: {
|
||||
email: 'cc@example.com',
|
||||
name: 'Test CC',
|
||||
role: RecipientRole.CC,
|
||||
signingOrder: 1,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
token: nanoid(),
|
||||
envelopeId: document.id,
|
||||
},
|
||||
});
|
||||
|
||||
const [signerA, signerB] = await Promise.all(
|
||||
[
|
||||
{ email: 'signer-a@example.com', name: 'Signer A', signingOrder: 2 },
|
||||
{ email: 'signer-b@example.com', name: 'Signer B', signingOrder: 3 },
|
||||
].map(async ({ email, name, signingOrder }) =>
|
||||
prisma.recipient.create({
|
||||
data: {
|
||||
email,
|
||||
name,
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder,
|
||||
token: nanoid(),
|
||||
envelopeId: document.id,
|
||||
fields: {
|
||||
create: {
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 1,
|
||||
positionX: signingOrder * 10,
|
||||
positionY: 10,
|
||||
width: 5,
|
||||
height: 5,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
envelopeId: document.id,
|
||||
envelopeItemId: document.envelopeItems[0].id,
|
||||
fieldMeta: { type: 'signature', fontSize: 14 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const response = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
// The CC recipient at order 1 must not block signer A at order 2.
|
||||
await page.goto(`/sign/${signerA.token}`);
|
||||
await expect(page).not.toHaveURL(`/sign/${signerA.token}/waiting`);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
// Signer B at order 3 must still wait for signer A.
|
||||
await page.goto(`/sign/${signerB.token}`);
|
||||
await expect(page).toHaveURL(`/sign/${signerB.token}/waiting`);
|
||||
|
||||
// Sign as signer A then signer B.
|
||||
for (const signer of [signerA, signerB]) {
|
||||
await page.goto(`/sign/${signer.token}`);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
await signSignaturePad(page);
|
||||
|
||||
const signerField = await prisma.field.findFirstOrThrow({
|
||||
where: { recipientId: signer.id },
|
||||
});
|
||||
|
||||
await page.locator(`#field-${signerField.id}`).getByRole('button').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`/sign/${signer.token}/complete`);
|
||||
}
|
||||
|
||||
// The document completes without any action from the CC recipient.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const updatedDocument = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
});
|
||||
|
||||
return updatedDocument.status;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,14 @@ 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, DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import {
|
||||
DocumentSigningOrder,
|
||||
DocumentStatus,
|
||||
FieldType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { signDirectSignaturePad, signSignaturePad } from '../fixtures/signature';
|
||||
|
||||
@@ -370,3 +377,221 @@ test('[NEXT_RECIPIENT_DICTATION]: should allow assistant to dictate next signer'
|
||||
expect(thirdRecipient.role).toBe(RecipientRole.SIGNER);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('[NEXT_RECIPIENT_DICTATION]: should skip CC recipient when dictating next signer', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: firstSigner } = await seedUser();
|
||||
const { user: ccUser } = await seedUser();
|
||||
const { user: secondSigner } = await seedUser();
|
||||
|
||||
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: [firstSigner, ccUser, secondSigner],
|
||||
recipientsCreateOptions: [
|
||||
{ signingOrder: 1 },
|
||||
{
|
||||
// CC recipients are created pre-signed, mirroring production behaviour.
|
||||
signingOrder: 2,
|
||||
role: RecipientRole.CC,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
{ signingOrder: 3 },
|
||||
],
|
||||
updateDocumentOptions: {
|
||||
documentMeta: {
|
||||
upsert: {
|
||||
create: {
|
||||
allowDictateNextSigner: true,
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
update: {
|
||||
allowDictateNextSigner: true,
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const firstRecipient = recipients.find((r) => r.email === firstSigner.email);
|
||||
const ccRecipient = recipients.find((r) => r.email === ccUser.email);
|
||||
|
||||
if (!firstRecipient || !ccRecipient) {
|
||||
throw new Error('Recipients not found');
|
||||
}
|
||||
|
||||
// CC recipients cannot have fields.
|
||||
await prisma.field.deleteMany({
|
||||
where: {
|
||||
recipientId: ccRecipient.id,
|
||||
},
|
||||
});
|
||||
|
||||
const { token, fields } = firstRecipient;
|
||||
|
||||
const signUrl = `/sign/${token}`;
|
||||
|
||||
await page.goto(signUrl);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
await signSignaturePad(page);
|
||||
|
||||
// Fill in all fields
|
||||
for (const field of fields) {
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
|
||||
if (field.type === FieldType.TEXT) {
|
||||
await page.locator('#custom-text').fill('TEXT');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
}
|
||||
|
||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
}
|
||||
|
||||
// Complete signing and verify the offered next recipient
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByText('Next Recipient Name')).toBeVisible();
|
||||
|
||||
// The dictation dialog must offer the second signer, not the CC recipient.
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByLabel('Name')).toHaveValue(secondSigner.name ?? '');
|
||||
await expect(dialog.getByLabel('Email')).toHaveValue(secondSigner.email);
|
||||
|
||||
// Submit and verify completion
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`${signUrl}/complete`);
|
||||
|
||||
// Verify document and recipient states
|
||||
const updatedDocument = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: document.id },
|
||||
include: {
|
||||
recipients: {
|
||||
orderBy: { signingOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Document should still be pending as the second signer has not signed
|
||||
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
|
||||
|
||||
// The CC recipient must remain untouched
|
||||
const updatedCcRecipient = updatedDocument.recipients[1];
|
||||
expect(updatedCcRecipient.email).toBe(ccUser.email);
|
||||
expect(updatedCcRecipient.role).toBe(RecipientRole.CC);
|
||||
expect(updatedCcRecipient.signingStatus).toBe(SigningStatus.SIGNED);
|
||||
|
||||
// The second signer must remain the next pending recipient
|
||||
const updatedSecondRecipient = updatedDocument.recipients[2];
|
||||
expect(updatedSecondRecipient.email).toBe(secondSigner.email);
|
||||
expect(updatedSecondRecipient.signingOrder).toBe(3);
|
||||
expect(updatedSecondRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||
});
|
||||
|
||||
test('[NEXT_RECIPIENT_DICTATION]: should not offer dictation when CC recipient is last', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: firstSigner } = await seedUser();
|
||||
const { user: secondSigner } = await seedUser();
|
||||
const { user: ccUser } = await seedUser();
|
||||
|
||||
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: [firstSigner, secondSigner, ccUser],
|
||||
recipientsCreateOptions: [
|
||||
{ signingOrder: 1 },
|
||||
{ signingOrder: 2 },
|
||||
{
|
||||
// CC recipients are created pre-signed, mirroring production behaviour.
|
||||
signingOrder: 3,
|
||||
role: RecipientRole.CC,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
],
|
||||
updateDocumentOptions: {
|
||||
documentMeta: {
|
||||
upsert: {
|
||||
create: {
|
||||
allowDictateNextSigner: true,
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
update: {
|
||||
allowDictateNextSigner: true,
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const firstRecipient = recipients.find((r) => r.email === firstSigner.email);
|
||||
const secondRecipient = recipients.find((r) => r.email === secondSigner.email);
|
||||
const ccRecipient = recipients.find((r) => r.email === ccUser.email);
|
||||
|
||||
if (!firstRecipient || !secondRecipient || !ccRecipient) {
|
||||
throw new Error('Recipients not found');
|
||||
}
|
||||
|
||||
// CC recipients cannot have fields.
|
||||
await prisma.field.deleteMany({
|
||||
where: {
|
||||
recipientId: ccRecipient.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Sign as both signers in order.
|
||||
for (const recipient of [firstRecipient, secondRecipient]) {
|
||||
const { token, fields } = recipient;
|
||||
|
||||
const signUrl = `/sign/${token}`;
|
||||
|
||||
await page.goto(signUrl);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
await signSignaturePad(page);
|
||||
|
||||
// Fill in all fields
|
||||
for (const field of fields) {
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
|
||||
if (field.type === FieldType.TEXT) {
|
||||
await page.locator('#custom-text').fill('TEXT');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
}
|
||||
|
||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
}
|
||||
|
||||
// Complete signing
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
if (recipient.id === secondRecipient.id) {
|
||||
// The last actionable signer must not be offered the CC recipient.
|
||||
await expect(page.getByText('Next Recipient Name')).not.toBeVisible();
|
||||
}
|
||||
|
||||
// Submit and verify completion
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`${signUrl}/complete`);
|
||||
}
|
||||
|
||||
// The document completes without any action from the CC recipient.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const finalDocument = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: document.id },
|
||||
});
|
||||
|
||||
return finalDocument.status;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,14 @@ import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankDocument, seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentSigningOrder, DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import {
|
||||
DocumentSigningOrder,
|
||||
DocumentStatus,
|
||||
FieldType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
} from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
@@ -225,15 +232,20 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await page.getByLabel('Receives copy').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByLabel('Email').nth(2).fill('user3@example.com');
|
||||
await page.getByLabel('Name').nth(2).fill('User 3');
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
// CC recipients are kept last, so new rows are inserted above the CC row.
|
||||
await expect(page.getByLabel('Email')).toHaveCount(3);
|
||||
|
||||
await page.getByLabel('Email').nth(1).fill('user3@example.com');
|
||||
await page.getByLabel('Name').nth(1).fill('User 3');
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByLabel('Email').nth(3).fill('user4@example.com');
|
||||
await page.getByLabel('Name').nth(3).fill('User 4');
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await expect(page.getByLabel('Email')).toHaveCount(4);
|
||||
|
||||
await page.getByLabel('Email').nth(2).fill('user4@example.com');
|
||||
await page.getByLabel('Name').nth(2).fill('User 4');
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@@ -661,3 +673,182 @@ test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode',
|
||||
await expect(page).not.toHaveURL(`/sign/${activeRecipient?.token}/waiting`);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: should skip CC recipients in sequential signing order', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
teamId: team.id,
|
||||
owner: user,
|
||||
recipients: ['signer1@example.com', 'cc@example.com', 'signer2@example.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
recipientsCreateOptions: [
|
||||
{ signingOrder: 1 },
|
||||
{
|
||||
// CC recipients are created pre-signed, mirroring production behaviour.
|
||||
signingOrder: 2,
|
||||
role: RecipientRole.CC,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
{ signingOrder: 3 },
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.documentMeta.update({
|
||||
where: {
|
||||
id: document.documentMetaId,
|
||||
},
|
||||
data: {
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
});
|
||||
|
||||
const firstSigner = recipients.find((r) => r.email === 'signer1@example.com');
|
||||
const ccRecipient = recipients.find((r) => r.email === 'cc@example.com');
|
||||
const lastSigner = recipients.find((r) => r.email === 'signer2@example.com');
|
||||
|
||||
// CC recipients cannot have fields.
|
||||
await prisma.field.deleteMany({
|
||||
where: {
|
||||
recipientId: ccRecipient?.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Sequential order is enforced: the last signer must wait while the first signer is pending.
|
||||
await page.goto(`/sign/${lastSigner?.token}`);
|
||||
await expect(page).toHaveURL(`/sign/${lastSigner?.token}/waiting`);
|
||||
|
||||
// Sign as the first signer.
|
||||
await page.goto(`/sign/${firstSigner?.token}`);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
await signSignaturePad(page);
|
||||
|
||||
const firstSignerField = await prisma.field.findFirstOrThrow({
|
||||
where: { recipientId: firstSigner?.id },
|
||||
});
|
||||
|
||||
await page.locator(`#field-${firstSignerField.id}`).getByRole('button').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`/sign/${firstSigner?.token}/complete`);
|
||||
|
||||
// The CC recipient at order 2 must not block the last signer at order 3.
|
||||
await page.goto(`/sign/${lastSigner?.token}`);
|
||||
await expect(page).not.toHaveURL(`/sign/${lastSigner?.token}/waiting`);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
await signSignaturePad(page);
|
||||
|
||||
const lastSignerField = await prisma.field.findFirstOrThrow({
|
||||
where: { recipientId: lastSigner?.id },
|
||||
});
|
||||
|
||||
await page.locator(`#field-${lastSignerField.id}`).getByRole('button').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`/sign/${lastSigner?.token}/complete`);
|
||||
|
||||
// The document completes without any action from the CC recipient.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const finalDocument = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
});
|
||||
|
||||
return finalDocument.status;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: should skip unsigned CC recipients in sequential signing order', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
teamId: team.id,
|
||||
owner: user,
|
||||
recipients: ['signer1@example.com', 'cc@example.com', 'signer2@example.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
recipientsCreateOptions: [
|
||||
{ signingOrder: 1 },
|
||||
{
|
||||
// Legacy/inconsistent data: a CC recipient that was never marked as signed.
|
||||
signingOrder: 2,
|
||||
role: RecipientRole.CC,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
},
|
||||
{ signingOrder: 3 },
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.documentMeta.update({
|
||||
where: {
|
||||
id: document.documentMetaId,
|
||||
},
|
||||
data: {
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
});
|
||||
|
||||
const firstSigner = recipients.find((r) => r.email === 'signer1@example.com');
|
||||
const ccRecipient = recipients.find((r) => r.email === 'cc@example.com');
|
||||
const lastSigner = recipients.find((r) => r.email === 'signer2@example.com');
|
||||
|
||||
// CC recipients cannot have fields.
|
||||
await prisma.field.deleteMany({
|
||||
where: {
|
||||
recipientId: ccRecipient?.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Sign as the first signer.
|
||||
await page.goto(`/sign/${firstSigner?.token}`);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
await signSignaturePad(page);
|
||||
|
||||
const firstSignerField = await prisma.field.findFirstOrThrow({
|
||||
where: { recipientId: firstSigner?.id },
|
||||
});
|
||||
|
||||
await page.locator(`#field-${firstSignerField.id}`).getByRole('button').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`/sign/${firstSigner?.token}/complete`);
|
||||
|
||||
// The unsigned CC recipient at order 2 must not block the last signer at order 3.
|
||||
await page.goto(`/sign/${lastSigner?.token}`);
|
||||
await expect(page).not.toHaveURL(`/sign/${lastSigner?.token}/waiting`);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
await signSignaturePad(page);
|
||||
|
||||
const lastSignerField = await prisma.field.findFirstOrThrow({
|
||||
where: { recipientId: lastSigner?.id },
|
||||
});
|
||||
|
||||
await page.locator(`#field-${lastSignerField.id}`).getByRole('button').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`/sign/${lastSigner?.token}/complete`);
|
||||
|
||||
// The document completes without any action from the CC recipient.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const finalDocument = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
});
|
||||
|
||||
return finalDocument.status;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import {
|
||||
assertRecipientRole,
|
||||
getRecipientEmailInputs,
|
||||
getRecipientRows,
|
||||
getSigningOrderInputs,
|
||||
openDocumentEnvelopeEditor,
|
||||
setRecipientEmail,
|
||||
setRecipientName,
|
||||
setRecipientRole,
|
||||
toggleSigningOrder,
|
||||
} from '../fixtures/envelope-editor';
|
||||
|
||||
const SIGNER_A = { email: 'cc-order-signer-a@example.com', name: 'Signer A' };
|
||||
const SIGNER_B = { email: 'cc-order-signer-b@example.com', name: 'Signer B' };
|
||||
const CC_RECIPIENT = { email: 'cc-order-cc@example.com', name: 'CC Recipient' };
|
||||
|
||||
const assertCcDisplayedLastWithNoOrderInput = async (root: Page) => {
|
||||
// CC recipient is displayed last despite being added/stored mid-list.
|
||||
await expect(getRecipientEmailInputs(root)).toHaveCount(3);
|
||||
await expect(getRecipientEmailInputs(root).nth(0)).toHaveValue(SIGNER_A.email);
|
||||
await expect(getRecipientEmailInputs(root).nth(1)).toHaveValue(SIGNER_B.email);
|
||||
await expect(getRecipientEmailInputs(root).nth(2)).toHaveValue(CC_RECIPIENT.email);
|
||||
|
||||
await assertRecipientRole(root, 0, 'Needs to sign');
|
||||
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');
|
||||
|
||||
// The CC row itself renders no signing order input (placeholder div instead).
|
||||
const ccRow = getRecipientRows(root).nth(2);
|
||||
await expect(ccRow.locator('[data-testid="signing-order-input"]')).toHaveCount(0);
|
||||
};
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('CC recipient added mid-list is displayed last with no signing order input', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const { root } = surface;
|
||||
|
||||
await toggleSigningOrder(root, true);
|
||||
|
||||
// Add signer A into the initial empty row.
|
||||
await setRecipientEmail(root, 0, SIGNER_A.email);
|
||||
await setRecipientName(root, 0, SIGNER_A.name);
|
||||
|
||||
// Add the CC recipient second.
|
||||
await root.getByRole('button', { name: 'Add Signer' }).click();
|
||||
await setRecipientEmail(root, 1, CC_RECIPIENT.email);
|
||||
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);
|
||||
|
||||
// Add signer B third. The new row is inserted before the CC recipient,
|
||||
// which is kept last by the client-side sorting.
|
||||
await root.getByRole('button', { name: 'Add Signer' }).click();
|
||||
await expect(getRecipientEmailInputs(root).nth(2)).toHaveValue(CC_RECIPIENT.email);
|
||||
|
||||
await setRecipientEmail(root, 1, SIGNER_B.email);
|
||||
await setRecipientName(root, 1, SIGNER_B.name);
|
||||
|
||||
await assertCcDisplayedLastWithNoOrderInput(root);
|
||||
|
||||
// The editor autosaves with a debounce, poll the DB until all three
|
||||
// recipients have been persisted before reloading the page.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId: surface.envelopeId },
|
||||
});
|
||||
|
||||
return recipients.length;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(3);
|
||||
|
||||
// Reload the editor and assert the CC recipient is still displayed last.
|
||||
await root.reload();
|
||||
await expect(root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||
|
||||
await assertCcDisplayedLastWithNoOrderInput(root);
|
||||
});
|
||||
|
||||
test('CC recipient seeded with mid-list signing order is displayed last', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const document = await seedBlankDocument(user, team.id, {
|
||||
internalVersion: 2,
|
||||
});
|
||||
|
||||
// Seed a CC recipient directly in the DB with a mid-list signing order
|
||||
// (2 of 3) BEFORE opening the editor, so the editor's autosave cannot
|
||||
// race with the seeded recipients, and assert the editor renders it last.
|
||||
await prisma.envelope.update({
|
||||
where: { id: document.id },
|
||||
data: {
|
||||
documentMeta: {
|
||||
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
},
|
||||
recipients: {
|
||||
createMany: {
|
||||
data: [
|
||||
{
|
||||
email: SIGNER_A.email,
|
||||
name: SIGNER_A.name,
|
||||
token: nanoid(),
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: 1,
|
||||
},
|
||||
{
|
||||
email: CC_RECIPIENT.email,
|
||||
name: CC_RECIPIENT.name,
|
||||
token: nanoid(),
|
||||
role: RecipientRole.CC,
|
||||
signingOrder: 2,
|
||||
},
|
||||
{
|
||||
email: SIGNER_B.email,
|
||||
name: SIGNER_B.name,
|
||||
token: nanoid(),
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||
|
||||
await assertCcDisplayedLastWithNoOrderInput(page);
|
||||
});
|
||||
});
|
||||
@@ -110,7 +110,7 @@ test.describe('Default Recipients', () => {
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Add a regular signer using the v2 editor
|
||||
await page.getByTestId('signer-email-input').last().fill('regular-signer@documenso.com');
|
||||
await page.getByTestId('signer-email-input').first().fill('regular-signer@documenso.com');
|
||||
await page
|
||||
.getByPlaceholder(/Recipient/)
|
||||
.first()
|
||||
|
||||
@@ -7,9 +7,10 @@ import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
||||
import { useId } from 'react';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { prop, sortBy } from 'remeda';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isCcRecipient, normalizeRecipientSigningOrders, sortRecipientsForSigningOrder } from '../../utils/recipients';
|
||||
|
||||
const LocalRecipientSchema = z.object({
|
||||
formId: z.string().min(1),
|
||||
id: z.number().optional(),
|
||||
@@ -94,13 +95,13 @@ export const useEditorRecipients = ({ envelope }: EditorRecipientsProps): UseEdi
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder ?? index + 1,
|
||||
signingOrder: isCcRecipient(recipient) ? undefined : (recipient.signingOrder ?? index + 1),
|
||||
actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
|
||||
}));
|
||||
|
||||
const signers: TLocalRecipient[] =
|
||||
formRecipients.length > 0
|
||||
? sortBy(formRecipients, [prop('signingOrder'), 'asc'], [prop('id'), 'asc'])
|
||||
? normalizeRecipientSigningOrders(sortRecipientsForSigningOrder(formRecipients))
|
||||
: [
|
||||
{
|
||||
formId: initialId,
|
||||
|
||||
@@ -5,7 +5,7 @@ import EnvelopeSchema from '@documenso/prisma/generated/zod/modelSchema/Envelope
|
||||
import SignatureSchema from '@documenso/prisma/generated/zod/modelSchema/SignatureSchema';
|
||||
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
|
||||
import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
|
||||
import { DocumentSigningOrder, DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
import { DocumentSigningOrder, DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
@@ -266,6 +266,11 @@ export const getEnvelopeForRecipientSigning = async ({
|
||||
|
||||
if (envelope.documentMeta.signingOrder === DocumentSigningOrder.SEQUENTIAL && currentRecipientIndex !== -1) {
|
||||
for (let i = 0; i < currentRecipientIndex; i++) {
|
||||
// CC recipients have no action to take, so they can never block the flow.
|
||||
if (envelope.recipients[i].role === RecipientRole.CC) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (envelope.recipients[i].signingStatus !== SigningStatus.SIGNED) {
|
||||
isRecipientsTurn = false;
|
||||
break;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentSigningOrder, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
|
||||
export type GetIsRecipientTurnOptions = {
|
||||
token: string;
|
||||
@@ -38,6 +38,11 @@ export async function getIsRecipientsTurnToSign({ token }: GetIsRecipientTurnOpt
|
||||
}
|
||||
|
||||
for (let i = 0; i < currentRecipientIndex; i++) {
|
||||
// CC recipients have no action to take, so they can never block the flow.
|
||||
if (recipients[i].role === RecipientRole.CC) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (recipients[i].signingStatus !== SigningStatus.SIGNED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { EnvelopeType, RecipientRole } from '@prisma/client';
|
||||
|
||||
import { mapDocumentIdToSecondaryId } from '../../utils/envelope';
|
||||
|
||||
@@ -16,6 +16,11 @@ export const getNextPendingRecipient = async ({
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
secondaryId: mapDocumentIdToSecondaryId(documentId),
|
||||
},
|
||||
// CC recipients are informational only and never take part in signing,
|
||||
// so they must never be offered as the next pending recipient.
|
||||
role: {
|
||||
not: RecipientRole.CC,
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isAssistantLastSigner, normalizeRecipientSigningOrders, sortRecipientsForSigningOrder } from './recipients';
|
||||
|
||||
describe('recipient signing order helpers', () => {
|
||||
it('sorts CC recipients after ordered active recipients', () => {
|
||||
const recipients = [
|
||||
{ id: 1, role: RecipientRole.CC, signingOrder: 1 },
|
||||
{ id: 2, role: RecipientRole.SIGNER, signingOrder: 2 },
|
||||
{ id: 3, role: RecipientRole.APPROVER, signingOrder: 1 },
|
||||
];
|
||||
|
||||
expect(sortRecipientsForSigningOrder(recipients).map((recipient) => recipient.id)).toEqual([3, 2, 1]);
|
||||
});
|
||||
|
||||
it('keeps original order when recipients have the same signing order', () => {
|
||||
const recipients = [
|
||||
{ id: 2, role: RecipientRole.SIGNER, signingOrder: 1 },
|
||||
{ id: 1, role: RecipientRole.APPROVER, signingOrder: 1 },
|
||||
];
|
||||
|
||||
expect(sortRecipientsForSigningOrder(recipients).map((recipient) => recipient.id)).toEqual([2, 1]);
|
||||
});
|
||||
|
||||
it('sorts and normalizes active recipient signing order and removes it from CC recipients', () => {
|
||||
const recipients = [
|
||||
{ id: 1, role: RecipientRole.CC, signingOrder: 1 },
|
||||
{ id: 2, role: RecipientRole.SIGNER, signingOrder: 4 },
|
||||
{ id: 3, role: RecipientRole.APPROVER, signingOrder: 2 },
|
||||
];
|
||||
|
||||
expect(normalizeRecipientSigningOrders(sortRecipientsForSigningOrder(recipients))).toEqual([
|
||||
{ id: 3, role: RecipientRole.APPROVER, signingOrder: 1 },
|
||||
{ id: 2, role: RecipientRole.SIGNER, signingOrder: 2 },
|
||||
{ id: 1, role: RecipientRole.CC, signingOrder: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves caller order while normalizing signing order', () => {
|
||||
const recipients = [
|
||||
{ id: 2, role: RecipientRole.ASSISTANT, signingOrder: 2 },
|
||||
{ id: 1, role: RecipientRole.SIGNER, signingOrder: 1 },
|
||||
{ id: 3, role: RecipientRole.CC, signingOrder: 1 },
|
||||
];
|
||||
|
||||
expect(normalizeRecipientSigningOrders(recipients)).toEqual([
|
||||
{ id: 2, role: RecipientRole.ASSISTANT, signingOrder: 1 },
|
||||
{ id: 1, role: RecipientRole.SIGNER, signingOrder: 2 },
|
||||
{ id: 3, role: RecipientRole.CC, signingOrder: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it('checks whether the last non-CC recipient is an assistant', () => {
|
||||
expect(
|
||||
isAssistantLastSigner([
|
||||
{ role: RecipientRole.SIGNER },
|
||||
{ role: RecipientRole.ASSISTANT },
|
||||
{ role: RecipientRole.CC },
|
||||
]),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isAssistantLastSigner([
|
||||
{ role: RecipientRole.ASSISTANT },
|
||||
{ role: RecipientRole.SIGNER },
|
||||
{ role: RecipientRole.CC },
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import type { Envelope } from '@prisma/client';
|
||||
import { type Field, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import type { Envelope, Field, Recipient } from '@prisma/client';
|
||||
import { RecipientRole, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||
import { AppError, AppErrorCode } from '../errors/app-error';
|
||||
@@ -15,6 +15,58 @@ import { zEmail } from './zod';
|
||||
*/
|
||||
export const RECIPIENT_ROLES_THAT_REQUIRE_FIELDS = [RecipientRole.SIGNER] as const;
|
||||
|
||||
// signingOrder isn't required when submitting the recipient form (Zod: z.number().optional())
|
||||
type RecipientWithSigningOrder = Pick<Recipient, 'role'> & Partial<Pick<Recipient, 'signingOrder'>>;
|
||||
|
||||
export const isCcRecipient = (recipient: Pick<Recipient, 'role'>) => {
|
||||
return recipient.role === RecipientRole.CC;
|
||||
};
|
||||
|
||||
export const isAssistantLastSigner = (recipients: Pick<Recipient, 'role'>[]) => {
|
||||
const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient));
|
||||
const lastNonCcRecipient = nonCcRecipients[nonCcRecipients.length - 1];
|
||||
|
||||
return lastNonCcRecipient?.role === RecipientRole.ASSISTANT;
|
||||
};
|
||||
|
||||
export const sortRecipientsForSigningOrder = <T extends RecipientWithSigningOrder>(recipients: T[]): T[] => {
|
||||
return [...recipients].sort((r1, r2) => {
|
||||
const r1IsCcRecipient = isCcRecipient(r1);
|
||||
const r2IsCcRecipient = isCcRecipient(r2);
|
||||
|
||||
// CC recipients always sort after non-CC recipients.
|
||||
if (r1IsCcRecipient !== r2IsCcRecipient) {
|
||||
return r1IsCcRecipient ? 1 : -1;
|
||||
}
|
||||
|
||||
// Order by signing order; missing orders sort last.
|
||||
const r1SigningOrder = r1.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
const r2SigningOrder = r2.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
|
||||
return r1SigningOrder - r2SigningOrder;
|
||||
});
|
||||
};
|
||||
|
||||
export const normalizeRecipientSigningOrders = <T extends RecipientWithSigningOrder>(
|
||||
recipients: T[],
|
||||
canUpdateRecipient: (recipient: T) => boolean = () => true,
|
||||
): Array<T & { signingOrder?: number }> => {
|
||||
const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient));
|
||||
const ccRecipients = recipients.filter((recipient) => isCcRecipient(recipient));
|
||||
|
||||
const normalizedNonCcRecipients = nonCcRecipients.map((recipient, index) => ({
|
||||
...recipient,
|
||||
signingOrder: canUpdateRecipient(recipient) ? index + 1 : (recipient.signingOrder ?? index + 1),
|
||||
}));
|
||||
|
||||
const normalizedCcRecipients = ccRecipients.map((recipient) => ({
|
||||
...recipient,
|
||||
signingOrder: undefined,
|
||||
}));
|
||||
|
||||
return [...normalizedNonCcRecipients, ...normalizedCcRecipients];
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns recipients who are missing required fields for their role.
|
||||
*
|
||||
|
||||
@@ -6,7 +6,13 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
|
||||
import {
|
||||
isAssistantLastSigner,
|
||||
isCcRecipient,
|
||||
normalizeRecipientSigningOrders,
|
||||
sortRecipientsForSigningOrder,
|
||||
canRecipientBeModified as utilCanRecipientBeModified,
|
||||
} from '@documenso/lib/utils/recipients';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
|
||||
@@ -24,7 +30,6 @@ import { motion } from 'framer-motion';
|
||||
import { GripVerticalIcon, HelpCircle, Plus, Trash } from 'lucide-react';
|
||||
import { useCallback, useId, useMemo, useRef, useState } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { prop, sortBy } from 'remeda';
|
||||
|
||||
import { DocumentReadOnlyFields, mapFieldsWithRecipients } from '../../components/document/document-read-only-fields';
|
||||
import type { RecipientAutoCompleteOption } from '../../components/recipient/recipient-autocomplete-input';
|
||||
@@ -118,18 +123,18 @@ export const AddSignersFormPartial = ({
|
||||
defaultValues: {
|
||||
signers:
|
||||
recipients.length > 0
|
||||
? sortBy(
|
||||
recipients.map((recipient, index) => ({
|
||||
nativeId: recipient.id,
|
||||
formId: String(recipient.id),
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder ?? index + 1,
|
||||
actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
|
||||
})),
|
||||
[prop('signingOrder'), 'asc'],
|
||||
[prop('nativeId'), 'asc'],
|
||||
? normalizeRecipientSigningOrders(
|
||||
sortRecipientsForSigningOrder(
|
||||
recipients.map((recipient, index) => ({
|
||||
nativeId: recipient.id,
|
||||
formId: String(recipient.id),
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
signingOrder: isCcRecipient(recipient) ? undefined : (recipient.signingOrder ?? index + 1),
|
||||
actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
|
||||
})),
|
||||
),
|
||||
)
|
||||
: defaultRecipients,
|
||||
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
|
||||
@@ -168,18 +173,14 @@ export const AddSignersFormPartial = ({
|
||||
}, [watchedSigners]);
|
||||
|
||||
const normalizeSigningOrders = (signers: typeof watchedSigners) => {
|
||||
return signers
|
||||
.sort((a, b) => (a.signingOrder ?? 0) - (b.signingOrder ?? 0))
|
||||
.map((signer, index) => ({ ...signer, signingOrder: index + 1 }));
|
||||
return normalizeRecipientSigningOrders(signers, (signer) => canRecipientBeModified(signer.nativeId));
|
||||
};
|
||||
|
||||
const activeRecipientCount = watchedSigners.filter((signer) => !isCcRecipient(signer)).length;
|
||||
|
||||
const onFormSubmit = form.handleSubmit(onSubmit);
|
||||
|
||||
const {
|
||||
append: appendSigner,
|
||||
fields: signers,
|
||||
remove: removeSigner,
|
||||
} = useFieldArray({
|
||||
const { fields: signers, remove: removeSigner } = useFieldArray({
|
||||
control,
|
||||
name: 'signers',
|
||||
});
|
||||
@@ -258,14 +259,31 @@ export const AddSignersFormPartial = ({
|
||||
return utilCanRecipientBeModified(recipient, fields);
|
||||
};
|
||||
|
||||
const appendNormalizedSigner = (signer: (typeof watchedSigners)[number], shouldFocus = false) => {
|
||||
const updatedSigners = normalizeSigningOrders([...form.getValues('signers'), signer]);
|
||||
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
if (shouldFocus) {
|
||||
const signerIndex = updatedSigners.findIndex((updatedSigner) => updatedSigner.formId === signer.formId);
|
||||
|
||||
if (signerIndex !== -1) {
|
||||
requestAnimationFrame(() => form.setFocus(`signers.${signerIndex}.email`));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onAddSigner = () => {
|
||||
appendSigner({
|
||||
appendNormalizedSigner({
|
||||
formId: nanoid(12),
|
||||
name: '',
|
||||
email: '',
|
||||
role: RecipientRole.SIGNER,
|
||||
actionAuth: [],
|
||||
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
|
||||
signingOrder: activeRecipientCount + 1,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -310,18 +328,16 @@ export const AddSignersFormPartial = ({
|
||||
|
||||
form.setFocus(`signers.${emptySignerIndex}.email`);
|
||||
} else {
|
||||
appendSigner(
|
||||
appendNormalizedSigner(
|
||||
{
|
||||
formId: nanoid(12),
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
role: RecipientRole.SIGNER,
|
||||
actionAuth: [],
|
||||
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
|
||||
},
|
||||
{
|
||||
shouldFocus: true,
|
||||
signingOrder: activeRecipientCount + 1,
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
void form.trigger('signers');
|
||||
@@ -356,18 +372,14 @@ export const AddSignersFormPartial = ({
|
||||
|
||||
items.splice(insertIndex, 0, reorderedSigner);
|
||||
|
||||
const updatedSigners = items.map((signer, index) => ({
|
||||
...signer,
|
||||
signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : index + 1,
|
||||
}));
|
||||
const updatedSigners = normalizeSigningOrders(items);
|
||||
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
const lastSigner = updatedSigners[updatedSigners.length - 1];
|
||||
if (lastSigner.role === RecipientRole.ASSISTANT) {
|
||||
if (isAssistantLastSigner(updatedSigners)) {
|
||||
toast({
|
||||
title: _(msg`Warning: Assistant as last signer`),
|
||||
description: _(
|
||||
@@ -402,18 +414,19 @@ export const AddSignersFormPartial = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedSigners = currentSigners.map((signer, idx) => ({
|
||||
...signer,
|
||||
role: idx === index ? role : signer.role,
|
||||
signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : idx + 1,
|
||||
}));
|
||||
const updatedSigners = normalizeSigningOrders(
|
||||
currentSigners.map((signer, idx) => ({
|
||||
...signer,
|
||||
role: idx === index ? role : signer.role,
|
||||
})),
|
||||
);
|
||||
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
|
||||
if (role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) {
|
||||
toast({
|
||||
title: _(msg`Warning: Assistant as last signer`),
|
||||
description: _(
|
||||
@@ -440,22 +453,30 @@ export const AddSignersFormPartial = ({
|
||||
const currentSigners = form.getValues('signers');
|
||||
const signer = currentSigners[index];
|
||||
|
||||
// Remove signer from current position and insert at new position
|
||||
const remainingSigners = currentSigners.filter((_, idx) => idx !== index);
|
||||
const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1);
|
||||
remainingSigners.splice(newPosition, 0, signer);
|
||||
if (isCcRecipient(signer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedSigners = remainingSigners.map((s, idx) => ({
|
||||
...s,
|
||||
signingOrder: !canRecipientBeModified(s.nativeId) ? s.signingOrder : idx + 1,
|
||||
}));
|
||||
const nonCcSigners = currentSigners.filter((s) => !isCcRecipient(s));
|
||||
const ccSigners = currentSigners.filter((s) => isCcRecipient(s));
|
||||
const currentSigningOrderIndex = nonCcSigners.findIndex((s) => s.formId === signer.formId);
|
||||
|
||||
if (currentSigningOrderIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [reorderedSigner] = nonCcSigners.splice(currentSigningOrderIndex, 1);
|
||||
const newPosition = Math.min(Math.max(0, newOrder - 1), nonCcSigners.length);
|
||||
nonCcSigners.splice(newPosition, 0, reorderedSigner);
|
||||
|
||||
const updatedSigners = normalizeSigningOrders([...nonCcSigners, ...ccSigners]);
|
||||
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
|
||||
if (signer.role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) {
|
||||
toast({
|
||||
title: _(msg`Warning: Assistant as last signer`),
|
||||
description: _(
|
||||
@@ -471,10 +492,12 @@ export const AddSignersFormPartial = ({
|
||||
setShowSigningOrderConfirmation(false);
|
||||
|
||||
const currentSigners = form.getValues('signers');
|
||||
const updatedSigners = currentSigners.map((signer) => ({
|
||||
...signer,
|
||||
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
|
||||
}));
|
||||
const updatedSigners = normalizeSigningOrders(
|
||||
currentSigners.map((signer) => ({
|
||||
...signer,
|
||||
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
|
||||
})),
|
||||
);
|
||||
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
@@ -642,6 +665,7 @@ export const AddSignersFormPartial = ({
|
||||
isDragDisabled={
|
||||
!isSigningOrderSequential ||
|
||||
isSubmitting ||
|
||||
isCcRecipient(signer) ||
|
||||
!canRecipientBeModified(signer.nativeId) ||
|
||||
!signer.signingOrder
|
||||
}
|
||||
@@ -663,7 +687,9 @@ export const AddSignersFormPartial = ({
|
||||
'grid-cols-12 pr-3': isSigningOrderSequential,
|
||||
})}
|
||||
>
|
||||
{isSigningOrderSequential && (
|
||||
{isSigningOrderSequential && isCcRecipient(signer) && <div className="col-span-2" />}
|
||||
|
||||
{isSigningOrderSequential && !isCcRecipient(signer) && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`signers.${index}.signingOrder`}
|
||||
@@ -679,7 +705,7 @@ export const AddSignersFormPartial = ({
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
max={signers.length}
|
||||
max={activeRecipientCount}
|
||||
data-testid="signing-order-input"
|
||||
className={cn(
|
||||
'w-full text-center',
|
||||
|
||||
Reference in New Issue
Block a user