mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
5 Commits
fix/downlo
...
v1.12.5
| Author | SHA1 | Date | |
|---|---|---|---|
| 197d17ed7b | |||
| 3c646d9475 | |||
| ed4dfc9b55 | |||
| 32ce573de4 | |||
| 2ecfdbdde5 |
@ -15,7 +15,6 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import {
|
||||
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
|
||||
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
|
||||
isTemplateRecipientEmailPlaceholder,
|
||||
} from '@documenso/lib/constants/template';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
@ -46,50 +45,22 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
|
||||
import type { Toast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ZAddRecipientsForNewDocumentSchema = z
|
||||
.object({
|
||||
distributeDocument: z.boolean(),
|
||||
useCustomDocument: z.boolean().default(false),
|
||||
customDocumentData: z
|
||||
.any()
|
||||
.refine((data) => data instanceof File || data === undefined)
|
||||
.optional(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
signingOrder: z.number().optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
// Display exactly which rows are duplicates.
|
||||
.superRefine((items, ctx) => {
|
||||
const uniqueEmails = new Map<string, number>();
|
||||
|
||||
for (const [index, recipients] of items.recipients.entries()) {
|
||||
const email = recipients.email.toLowerCase();
|
||||
|
||||
const firstFoundIndex = uniqueEmails.get(email);
|
||||
|
||||
if (firstFoundIndex === undefined) {
|
||||
uniqueEmails.set(email, index);
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Emails must be unique',
|
||||
path: ['recipients', index, 'email'],
|
||||
});
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Emails must be unique',
|
||||
path: ['recipients', firstFoundIndex, 'email'],
|
||||
});
|
||||
}
|
||||
});
|
||||
const ZAddRecipientsForNewDocumentSchema = z.object({
|
||||
distributeDocument: z.boolean(),
|
||||
useCustomDocument: z.boolean().default(false),
|
||||
customDocumentData: z
|
||||
.any()
|
||||
.refine((data) => data instanceof File || data === undefined)
|
||||
.optional(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
signingOrder: z.number().optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
||||
|
||||
@ -278,14 +249,7 @@ export function TemplateUseDialog({
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={
|
||||
isTemplateRecipientEmailPlaceholder(field.value)
|
||||
? ''
|
||||
: _(msg`Email`)
|
||||
}
|
||||
/>
|
||||
<Input {...field} aria-label="Email" placeholder={_(msg`Email`)} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@ -306,6 +270,7 @@ export function TemplateUseDialog({
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
aria-label="Name"
|
||||
placeholder={recipients[index].name || _(msg`Name`)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@ -239,7 +239,27 @@ export const DocumentEditForm = ({
|
||||
|
||||
const onAddSignersFormAutoSave = async (data: TAddSignersFormSchema) => {
|
||||
try {
|
||||
await saveSignersData(data);
|
||||
// For autosave, we need to return the recipients response for form state sync
|
||||
const [, recipientsResponse] = await Promise.all([
|
||||
updateDocument({
|
||||
documentId: document.id,
|
||||
meta: {
|
||||
allowDictateNextSigner: data.allowDictateNextSigner,
|
||||
signingOrder: data.signingOrder,
|
||||
},
|
||||
}),
|
||||
|
||||
setRecipients({
|
||||
documentId: document.id,
|
||||
recipients: data.signers.map((signer) => ({
|
||||
...signer,
|
||||
// Explicitly set to null to indicate we want to remove auth if required.
|
||||
actionAuth: signer.actionAuth ?? [],
|
||||
})),
|
||||
}),
|
||||
]);
|
||||
|
||||
return recipientsResponse;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
@ -248,6 +268,8 @@ export const DocumentEditForm = ({
|
||||
description: _(msg`An error occurred while adding signers.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
throw err; // Re-throw so the autosave hook can handle the error
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -54,7 +54,7 @@ export const FolderCard = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Link to={formatPath()} key={folder.id}>
|
||||
<Link to={formatPath()} data-folder-id={folder.id} data-folder-name={folder.name}>
|
||||
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
|
||||
@ -182,7 +182,7 @@ export const TemplateEditForm = ({
|
||||
};
|
||||
|
||||
const saveTemplatePlaceholderData = async (data: TAddTemplatePlacholderRecipientsFormSchema) => {
|
||||
return Promise.all([
|
||||
const [, recipients] = await Promise.all([
|
||||
updateTemplateSettings({
|
||||
templateId: template.id,
|
||||
meta: {
|
||||
@ -196,6 +196,8 @@ export const TemplateEditForm = ({
|
||||
recipients: data.signers,
|
||||
}),
|
||||
]);
|
||||
|
||||
return recipients;
|
||||
};
|
||||
|
||||
const onAddTemplatePlaceholderFormSubmit = async (
|
||||
@ -218,7 +220,7 @@ export const TemplateEditForm = ({
|
||||
data: TAddTemplatePlacholderRecipientsFormSchema,
|
||||
) => {
|
||||
try {
|
||||
await saveTemplatePlaceholderData(data);
|
||||
return await saveTemplatePlaceholderData(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
@ -227,6 +229,8 @@ export const TemplateEditForm = ({
|
||||
description: _(msg`An error occurred while auto-saving the template placeholders.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -23,10 +23,12 @@ export const loader = async () => {
|
||||
|
||||
try {
|
||||
const certStatus = getCertificateStatus();
|
||||
|
||||
if (certStatus.isAvailable) {
|
||||
checks.certificate = { status: 'ok' };
|
||||
} else {
|
||||
checks.certificate = { status: 'warning' };
|
||||
|
||||
if (overallStatus === 'ok') {
|
||||
overallStatus = 'warning';
|
||||
}
|
||||
|
||||
@ -101,5 +101,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "1.12.2-rc.6"
|
||||
"version": "1.12.5"
|
||||
}
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "1.12.2-rc.6",
|
||||
"version": "1.12.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "1.12.2-rc.6",
|
||||
"version": "1.12.5",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
@ -89,7 +89,7 @@
|
||||
},
|
||||
"apps/remix": {
|
||||
"name": "@documenso/remix",
|
||||
"version": "1.12.2-rc.6",
|
||||
"version": "1.12.5",
|
||||
"dependencies": {
|
||||
"@documenso/api": "*",
|
||||
"@documenso/assets": "*",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "1.12.2-rc.6",
|
||||
"version": "1.12.5",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --filter=@documenso/remix",
|
||||
|
||||
@ -310,12 +310,11 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
||||
)
|
||||
.refine(
|
||||
(schema) => {
|
||||
const emails = schema.map((signer) => signer.email.toLowerCase());
|
||||
const ids = schema.map((signer) => signer.id);
|
||||
|
||||
return new Set(emails).size === emails.length && new Set(ids).size === ids.length;
|
||||
return new Set(ids).size === ids.length;
|
||||
},
|
||||
{ message: 'Recipient IDs and emails must be unique' },
|
||||
{ message: 'Recipient IDs must be unique' },
|
||||
),
|
||||
meta: z
|
||||
.object({
|
||||
|
||||
@ -33,7 +33,7 @@ const setupDocumentAndNavigateToFieldsStep = async (page: Page) => {
|
||||
};
|
||||
|
||||
const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('body').click({ position: { x: 0, y: 0 } });
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
@ -70,7 +70,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
@ -127,7 +127,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
@ -140,7 +140,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
|
||||
|
||||
await page.getByText('Text').nth(1).click();
|
||||
@ -191,7 +191,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
@ -204,7 +204,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
|
||||
|
||||
await page.getByText('Signature').nth(1).click();
|
||||
|
||||
@ -24,7 +24,7 @@ const setupDocument = async (page: Page) => {
|
||||
};
|
||||
|
||||
const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('body').click({ position: { x: 0, y: 0 } });
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
@ -26,7 +26,7 @@ const setupDocumentAndNavigateToSignersStep = async (page: Page) => {
|
||||
};
|
||||
|
||||
const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('body').click({ position: { x: 0, y: 0 } });
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
@ -92,7 +92,7 @@ test.describe('AutoSave Signers Step', () => {
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Receives copy' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
@ -160,9 +160,20 @@ test.describe('AutoSave Signers Step', () => {
|
||||
expect(retrievedDocumentData.documentMeta?.signingOrder).toBe('SEQUENTIAL');
|
||||
expect(retrievedDocumentData.documentMeta?.allowDictateNextSigner).toBe(true);
|
||||
expect(retrievedRecipients.length).toBe(3);
|
||||
expect(retrievedRecipients[0].signingOrder).toBe(2);
|
||||
expect(retrievedRecipients[1].signingOrder).toBe(3);
|
||||
expect(retrievedRecipients[2].signingOrder).toBe(1);
|
||||
|
||||
const firstRecipient = retrievedRecipients.find(
|
||||
(r) => r.email === 'recipient1@documenso.com',
|
||||
);
|
||||
const secondRecipient = retrievedRecipients.find(
|
||||
(r) => r.email === 'recipient2@documenso.com',
|
||||
);
|
||||
const thirdRecipient = retrievedRecipients.find(
|
||||
(r) => r.email === 'recipient3@documenso.com',
|
||||
);
|
||||
|
||||
expect(firstRecipient?.signingOrder).toBe(2);
|
||||
expect(secondRecipient?.signingOrder).toBe(3);
|
||||
expect(thirdRecipient?.signingOrder).toBe(1);
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
|
||||
@ -42,7 +42,7 @@ export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => {
|
||||
};
|
||||
|
||||
export const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('body').click({ position: { x: 0, y: 0 } });
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test('[DOCUMENT_FLOW]: Simple duplicate recipients test', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
// Step 1: Settings - Continue with defaults
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// Step 2: Add duplicate recipients
|
||||
await page.getByPlaceholder('Email').fill('duplicate@example.com');
|
||||
await page.getByPlaceholder('Name').fill('Duplicate 1');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
await page.getByLabel('Email').nth(1).fill('duplicate@example.com');
|
||||
await page.getByLabel('Name').nth(1).fill('Duplicate 2');
|
||||
|
||||
// Continue to fields
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Step 3: Add fields
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
|
||||
// Switch to second duplicate and add field
|
||||
await page.getByText('Duplicate 2 (duplicate@example.com)').first().click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
|
||||
// Continue to send
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
|
||||
// Send document
|
||||
await page.waitForTimeout(2500);
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
});
|
||||
@ -0,0 +1,355 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
import type { Document, Team } from '@prisma/client';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { signSignaturePad } from '../fixtures/signature';
|
||||
|
||||
/**
|
||||
* Test helper to complete the document creation flow with duplicate recipients
|
||||
*/
|
||||
const completeDocumentFlowWithDuplicateRecipients = async (options: {
|
||||
page: Page;
|
||||
team: Team;
|
||||
document: Document;
|
||||
}) => {
|
||||
const { page, team, document } = options;
|
||||
|
||||
// Step 1: Settings - Continue with defaults
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// Step 2: Add duplicate recipients
|
||||
await page.getByPlaceholder('Email').fill('duplicate@example.com');
|
||||
await page.getByPlaceholder('Name').fill('Duplicate Recipient 1');
|
||||
|
||||
// Add second signer with same email
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
await page.getByLabel('Email').nth(1).fill('duplicate@example.com');
|
||||
await page.getByLabel('Name').nth(1).fill('Duplicate Recipient 2');
|
||||
|
||||
// Add third signer with different email for comparison
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
await page.getByLabel('Email').nth(2).fill('unique@example.com');
|
||||
await page.getByLabel('Name').nth(2).fill('Unique Recipient');
|
||||
|
||||
// Continue to fields
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Step 3: Add fields for each recipient
|
||||
// Add signature field for first duplicate recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
|
||||
await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click();
|
||||
|
||||
// Switch to second duplicate recipient and add their field
|
||||
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
|
||||
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
|
||||
|
||||
// Switch to unique recipient and add their field
|
||||
await page.getByText('Unique Recipient (unique@example.com)').click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
|
||||
|
||||
// Continue to subject
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
|
||||
// Step 4: Complete with subject and send
|
||||
await page.waitForTimeout(2500);
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
// Wait for send confirmation
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
};
|
||||
|
||||
test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
||||
test('should allow creating document with duplicate recipient emails', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
// Complete the flow
|
||||
await completeDocumentFlowWithDuplicateRecipients({
|
||||
page,
|
||||
team,
|
||||
document,
|
||||
});
|
||||
|
||||
// Verify document was created successfully
|
||||
await expect(page).toHaveURL(new RegExp(`/t/${team.url}/documents`));
|
||||
});
|
||||
|
||||
test('should allow adding duplicate recipient after saving document initially', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
// Step 1: Settings - Continue with defaults
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// Step 2: Add initial recipient
|
||||
await page.getByPlaceholder('Email').fill('test@example.com');
|
||||
await page.getByPlaceholder('Name').fill('Test Recipient');
|
||||
|
||||
// Continue to fields and add a field
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
|
||||
// Save the document by going to subject
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
|
||||
// Navigate back to signers to add duplicate
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// Add duplicate recipient
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
await page.getByLabel('Email').nth(1).fill('test@example.com');
|
||||
await page.getByLabel('Name').nth(1).fill('Test Recipient Duplicate');
|
||||
|
||||
// Continue and add field for duplicate
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Switch to duplicate recipient and add field
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Test Recipient Duplicate (test@example.com)').first().click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
|
||||
// Complete the flow
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
await page.waitForTimeout(2500);
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should isolate fields per recipient token even with duplicate emails', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
// Complete the document flow
|
||||
await completeDocumentFlowWithDuplicateRecipients({
|
||||
page,
|
||||
team,
|
||||
document,
|
||||
});
|
||||
|
||||
// Navigate to documents list and get the document
|
||||
await expect(page).toHaveURL(new RegExp(`/t/${team.url}/documents`));
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(recipients).toHaveLength(3);
|
||||
|
||||
const tokens = recipients.map((r) => r.token);
|
||||
|
||||
expect(new Set(tokens).size).toBe(3); // All tokens should be unique
|
||||
|
||||
// Test each signing experience in separate browser contexts
|
||||
for (const recipient of recipients) {
|
||||
// Navigate to signing URL
|
||||
await page.goto(`/sign/${recipient.token}`, {
|
||||
waitUntil: 'networkidle',
|
||||
});
|
||||
|
||||
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
// Verify only one signature field is visible for this recipient
|
||||
expect(
|
||||
await page.locator(`[data-field-type="SIGNATURE"]:not([data-readonly="true"])`).all(),
|
||||
).toHaveLength(1);
|
||||
|
||||
// Verify recipient name is correct
|
||||
await expect(page.getByLabel('Full Name')).toHaveValue(recipient.name);
|
||||
|
||||
// Sign the document
|
||||
await signSignaturePad(page);
|
||||
|
||||
await page
|
||||
.locator('[data-field-type="SIGNATURE"]:not([data-readonly="true"])')
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
|
||||
// Verify completion
|
||||
await page.waitForURL(`/sign/${recipient?.token}/complete`);
|
||||
await expect(page.getByText('Document Signed')).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle duplicate recipient workflow with different field types', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
// Step 1: Settings
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Step 2: Add duplicate recipients with different roles
|
||||
await page.getByPlaceholder('Email').fill('signer@example.com');
|
||||
await page.getByPlaceholder('Name').fill('Signer Role');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
await page.getByLabel('Email').nth(1).fill('signer@example.com');
|
||||
await page.getByLabel('Name').nth(1).fill('Approver Role');
|
||||
|
||||
// Change second recipient role if role selector is available
|
||||
const roleDropdown = page.getByLabel('Role').nth(1);
|
||||
|
||||
if (await roleDropdown.isVisible()) {
|
||||
await roleDropdown.click();
|
||||
await page.getByText('Approver').click();
|
||||
}
|
||||
|
||||
// Step 3: Add different field types for each duplicate
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add signature for first recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
|
||||
// Add name field for second recipient
|
||||
await page.getByRole('combobox').first().click();
|
||||
|
||||
await page.getByText('Approver Role (signer@example.com)').first().click();
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
|
||||
// Add date field for second recipient
|
||||
await page.getByRole('button', { name: 'Date' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 150 } });
|
||||
|
||||
// Complete the document
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should preserve field assignments when editing document with duplicates', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
// Create document with duplicates and fields
|
||||
await completeDocumentFlowWithDuplicateRecipients({
|
||||
page,
|
||||
team,
|
||||
document,
|
||||
});
|
||||
|
||||
// Navigate back to edit the document
|
||||
await page.goto(`/t/${team.url}/documents/${document.id}/edit`);
|
||||
|
||||
// Go to fields step
|
||||
await page.getByRole('button', { name: 'Continue' }).click(); // Settings
|
||||
await page.getByRole('button', { name: 'Continue' }).click(); // Signers
|
||||
|
||||
// Verify fields are assigned to correct recipients
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Click on first duplicate recipient
|
||||
await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click();
|
||||
|
||||
// Verify their field is visible and can be selected
|
||||
const firstRecipientFields = await page
|
||||
.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`)
|
||||
.all();
|
||||
expect(firstRecipientFields.length).toBeGreaterThan(0);
|
||||
|
||||
// Switch to second duplicate recipient
|
||||
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
|
||||
|
||||
// Verify they have their own field
|
||||
const secondRecipientFields = await page
|
||||
.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`)
|
||||
.all();
|
||||
expect(secondRecipientFields.length).toBeGreaterThan(0);
|
||||
|
||||
// Add another field to the second duplicate
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 250, y: 150 } });
|
||||
|
||||
// Save changes
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
await page.waitForTimeout(2500);
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@ -573,6 +573,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
|
||||
y: 100 * i,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByText(`User ${i} (user${i}@example.com)`).click();
|
||||
}
|
||||
|
||||
|
||||
@ -277,13 +277,13 @@ test('[TEAMS]: document folder and its contents can be deleted', async ({ page }
|
||||
|
||||
await page.goto(`/t/${team.url}/documents`);
|
||||
|
||||
await expect(page.locator('div').filter({ hasText: folder.name })).not.toBeVisible();
|
||||
await expect(page.locator(`[data-folder-id="${folder.id}"]`)).not.toBeVisible();
|
||||
await expect(page.getByText(proposal.title)).not.toBeVisible();
|
||||
|
||||
await page.goto(`/t/${team.url}/documents/f/${folder.id}`);
|
||||
|
||||
await expect(page.getByText(report.title)).not.toBeVisible();
|
||||
await expect(page.locator('div').filter({ hasText: reportsFolder.name })).not.toBeVisible();
|
||||
await expect(page.locator(`[data-folder-id="${reportsFolder.id}"]`)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: create folder button is visible on templates page', async ({ page }) => {
|
||||
@ -318,9 +318,7 @@ test('[TEAMS]: can create a template folder', async ({ page }) => {
|
||||
await expect(page.getByText('Team template folder')).toBeVisible();
|
||||
|
||||
await page.goto(`/t/${team.url}/templates`);
|
||||
await expect(
|
||||
page.locator('div').filter({ hasText: 'Team template folder' }).nth(3),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(`[data-folder-name="Team template folder"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: can create a template subfolder inside a template folder', async ({ page }) => {
|
||||
@ -374,11 +372,8 @@ test('[TEAMS]: can create a template inside a template folder', async ({ page })
|
||||
|
||||
await page.getByRole('button', { name: 'New Template' }).click();
|
||||
|
||||
await page
|
||||
.locator('div')
|
||||
.filter({ hasText: /^Upload Template DocumentDrag & drop your PDF here\.$/ })
|
||||
.nth(2)
|
||||
.click();
|
||||
await page.getByText('Upload Template Document').click();
|
||||
|
||||
await page.locator('input[type="file"]').nth(0).waitFor({ state: 'attached' });
|
||||
|
||||
await page
|
||||
@ -537,7 +532,7 @@ test('[TEAMS]: template folder can be moved to another template folder', async (
|
||||
await expect(page.getByText('Team Contract Templates')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: template folder and its contents can be deleted', async ({ page }) => {
|
||||
test('[TEAMS]: template folder can be deleted', async ({ page }) => {
|
||||
const { team, teamOwner } = await seedTeamDocuments();
|
||||
|
||||
const folder = await seedBlankFolder(teamOwner, team.id, {
|
||||
@ -585,13 +580,16 @@ test('[TEAMS]: template folder and its contents can be deleted', async ({ page }
|
||||
|
||||
await page.goto(`/t/${team.url}/templates`);
|
||||
|
||||
await expect(page.locator('div').filter({ hasText: folder.name })).not.toBeVisible();
|
||||
await expect(page.getByText(template.title)).not.toBeVisible();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// !: This is no longer the case, when deleting a folder its contents will be moved to the root folder.
|
||||
// await expect(page.locator(`[data-folder-id="${folder.id}"]`)).not.toBeVisible();
|
||||
// await expect(page.getByText(template.title)).not.toBeVisible();
|
||||
|
||||
await page.goto(`/t/${team.url}/templates/f/${folder.id}`);
|
||||
|
||||
await expect(page.getByText(reportTemplate.title)).not.toBeVisible();
|
||||
await expect(page.locator('div').filter({ hasText: subfolder.name })).not.toBeVisible();
|
||||
await expect(page.locator(`[data-folder-id="${subfolder.id}"]`)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: can navigate between template folders', async ({ page }) => {
|
||||
@ -843,10 +841,15 @@ test('[TEAMS]: documents inherit folder visibility', async ({ page }) => {
|
||||
|
||||
await page.getByText('Admin Only Folder').click();
|
||||
|
||||
const fileInput = page.locator('input[type="file"]').nth(1);
|
||||
await fileInput.waitFor({ state: 'attached' });
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/f/.+`));
|
||||
|
||||
await fileInput.setInputFiles(
|
||||
// Upload document.
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page.getByRole('button', { name: 'Upload Document' }).click(),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(
|
||||
path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'),
|
||||
);
|
||||
|
||||
|
||||
@ -0,0 +1,283 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
import type { Team, Template } from '@prisma/client';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
/**
|
||||
* Test helper to complete template creation with duplicate recipients
|
||||
*/
|
||||
const completeTemplateFlowWithDuplicateRecipients = async (options: {
|
||||
page: Page;
|
||||
team: Team;
|
||||
template: Template;
|
||||
}) => {
|
||||
const { page, team, template } = options;
|
||||
// Step 1: Settings - Continue with defaults
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
|
||||
|
||||
// Step 2: Add duplicate recipients with real emails for testing
|
||||
await page.getByPlaceholder('Email').fill('duplicate@example.com');
|
||||
await page.getByPlaceholder('Name').fill('First Instance');
|
||||
|
||||
// Add second signer with same email
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
await page.getByPlaceholder('Email').nth(1).fill('duplicate@example.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('Second Instance');
|
||||
|
||||
// Add third signer with different email
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
await page.getByPlaceholder('Email').nth(2).fill('unique@example.com');
|
||||
await page.getByPlaceholder('Name').nth(2).fill('Different Recipient');
|
||||
|
||||
// Continue to fields
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Step 3: Add fields for each recipient instance
|
||||
// Add signature field for first instance
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
|
||||
// Switch to second instance and add their field
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Second Instance').first().click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
|
||||
// Switch to different recipient and add their field
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Different Recipient').first().click();
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
|
||||
|
||||
// Save template
|
||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||
|
||||
// Wait for creation confirmation
|
||||
await page.waitForURL(`/t/${team.url}/templates`);
|
||||
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
|
||||
};
|
||||
|
||||
test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
|
||||
test('should allow creating template with duplicate recipient emails', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedBlankTemplate(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
// Complete the template flow
|
||||
await completeTemplateFlowWithDuplicateRecipients({ page, team, template });
|
||||
|
||||
// Verify template was created successfully
|
||||
await expect(page).toHaveURL(`/t/${team.url}/templates`);
|
||||
});
|
||||
|
||||
test('should create document from template with duplicate recipients using same email', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedBlankTemplate(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
// Complete template creation
|
||||
await completeTemplateFlowWithDuplicateRecipients({ page, team, template });
|
||||
|
||||
// Navigate to template and create document
|
||||
await page.goto(`/t/${team.url}/templates`);
|
||||
|
||||
await page
|
||||
.getByRole('row', { name: template.title })
|
||||
.getByRole('button', { name: 'Use Template' })
|
||||
.click();
|
||||
|
||||
// Fill recipient information with same email for both instances
|
||||
await expect(page.getByRole('heading', { name: 'Create document' })).toBeVisible();
|
||||
|
||||
// Set same email for both recipient instances
|
||||
const emailInputs = await page.locator('[aria-label="Email"]').all();
|
||||
const nameInputs = await page.locator('[aria-label="Name"]').all();
|
||||
|
||||
// First instance
|
||||
await emailInputs[0].fill('same@example.com');
|
||||
await nameInputs[0].fill('John Doe - Role 1');
|
||||
|
||||
// Second instance (same email)
|
||||
await emailInputs[1].fill('same@example.com');
|
||||
await nameInputs[1].fill('John Doe - Role 2');
|
||||
|
||||
// Different recipient
|
||||
await emailInputs[2].fill('different@example.com');
|
||||
await nameInputs[2].fill('Jane Smith');
|
||||
|
||||
await page.getByLabel('Send document').click();
|
||||
|
||||
// Create document
|
||||
await page.getByRole('button', { name: 'Create and send' }).click();
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
|
||||
// Get the document ID from URL for database queries
|
||||
const url = page.url();
|
||||
const documentIdMatch = url.match(/\/documents\/(\d+)/);
|
||||
|
||||
const documentId = documentIdMatch ? parseInt(documentIdMatch[1]) : null;
|
||||
|
||||
expect(documentId).not.toBeNull();
|
||||
|
||||
// Get recipients directly from database
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
documentId: documentId!,
|
||||
},
|
||||
});
|
||||
|
||||
expect(recipients).toHaveLength(3);
|
||||
|
||||
// Verify all tokens are unique
|
||||
const tokens = recipients.map((r) => r.token);
|
||||
expect(new Set(tokens).size).toBe(3);
|
||||
|
||||
// Test signing experience for duplicate email recipients
|
||||
const duplicateRecipients = recipients.filter((r) => r.email === 'same@example.com');
|
||||
expect(duplicateRecipients).toHaveLength(2);
|
||||
|
||||
for (const recipient of duplicateRecipients) {
|
||||
// Navigate to signing URL
|
||||
await page.goto(`/sign/${recipient.token}`, {
|
||||
waitUntil: 'networkidle',
|
||||
});
|
||||
|
||||
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
// Verify correct recipient name is shown
|
||||
await expect(page.getByLabel('Full Name')).toHaveValue(recipient.name);
|
||||
|
||||
// Verify only one signature field is visible for this recipient
|
||||
expect(
|
||||
await page.locator(`[data-field-type="SIGNATURE"]:not([data-readonly="true"])`).all(),
|
||||
).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle template with different types of duplicate emails', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedBlankTemplate(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
// Step 1: Settings
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Step 2: Add multiple recipients with duplicate emails
|
||||
await page.getByPlaceholder('Email').fill('duplicate@example.com');
|
||||
await page.getByPlaceholder('Name').fill('Duplicate Recipient 1');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
await page.getByPlaceholder('Email').nth(1).fill('duplicate@example.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('Duplicate Recipient 2');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
await page.getByPlaceholder('Email').nth(2).fill('different@example.com');
|
||||
await page.getByPlaceholder('Name').nth(2).fill('Different Recipient');
|
||||
|
||||
// Continue and add fields
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add fields for each recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Duplicate Recipient 2').first().click();
|
||||
await page.getByRole('button', { name: 'Date' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Different Recipient').first().click();
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 200 } });
|
||||
|
||||
// Save template
|
||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||
|
||||
await page.waitForURL(`/t/${team.url}/templates`);
|
||||
|
||||
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should validate field assignments per recipient in template editing', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedBlankTemplate(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
// Create template with duplicates
|
||||
await completeTemplateFlowWithDuplicateRecipients({ page, team, template });
|
||||
|
||||
// Navigate back to edit the template
|
||||
await page.goto(`/t/${team.url}/templates/${template.id}/edit`);
|
||||
|
||||
// Go to fields step
|
||||
await page.getByRole('button', { name: 'Continue' }).click(); // Settings
|
||||
await page.getByRole('button', { name: 'Continue' }).click(); // Signers
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Verify fields are correctly assigned to each recipient instance
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'First Instance' }).first().click();
|
||||
let visibleFields = await page.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`).all();
|
||||
expect(visibleFields.length).toBeGreaterThan(0);
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Second Instance' }).first().click();
|
||||
visibleFields = await page.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`).all();
|
||||
expect(visibleFields.length).toBeGreaterThan(0);
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Different Recipient' }).first().click();
|
||||
const nameFields = await page.locator(`[data-field-type="NAME"]:not(:disabled)`).all();
|
||||
expect(nameFields.length).toBeGreaterThan(0);
|
||||
|
||||
// Add additional field to verify proper assignment
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'First Instance' }).first().click();
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 300 } });
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
// Save changes
|
||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||
|
||||
await page.waitForURL(`/t/${team.url}/templates`);
|
||||
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@ -33,7 +33,7 @@ const setupTemplateAndNavigateToFieldsStep = async (page: Page) => {
|
||||
};
|
||||
|
||||
const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('body').click({ position: { x: 0, y: 0 } });
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
@ -70,7 +70,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
@ -129,7 +129,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
@ -142,7 +142,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
|
||||
|
||||
await page.getByText('Text').nth(1).click();
|
||||
@ -195,7 +195,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
@ -208,7 +208,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
|
||||
|
||||
await page.getByText('Signature').nth(1).click();
|
||||
|
||||
@ -23,7 +23,7 @@ const setupTemplate = async (page: Page) => {
|
||||
};
|
||||
|
||||
const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('body').click({ position: { x: 0, y: 0 } });
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
@ -26,7 +26,7 @@ const setupTemplateAndNavigateToSignersStep = async (page: Page) => {
|
||||
};
|
||||
|
||||
const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('body').click({ position: { x: 0, y: 0 } });
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
@ -17,7 +17,7 @@ export default defineConfig({
|
||||
testDir: './e2e',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
workers: 4,
|
||||
workers: 2,
|
||||
maxFailures: process.env.CI ? 1 : undefined,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
@ -33,7 +33,7 @@ export default defineConfig({
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on',
|
||||
|
||||
video: 'retain-on-failure',
|
||||
video: 'on-first-retry',
|
||||
|
||||
/* Add explicit timeouts for actions */
|
||||
actionTimeout: 15_000,
|
||||
|
||||
@ -1,23 +1,56 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
export const useAutoSave = <T>(onSave: (data: T) => Promise<void>) => {
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
type SaveRequest<T, R> = {
|
||||
data: T;
|
||||
onResponse?: (response: R) => void;
|
||||
};
|
||||
|
||||
const saveFormData = async (data: T) => {
|
||||
try {
|
||||
await onSave(data);
|
||||
} catch (error) {
|
||||
console.error('Auto-save failed:', error);
|
||||
export const useAutoSave = <T, R = void>(
|
||||
onSave: (data: T) => Promise<R>,
|
||||
options: { delay?: number } = {},
|
||||
) => {
|
||||
const { delay = 2000 } = options;
|
||||
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const saveQueueRef = useRef<SaveRequest<T, R>[]>([]);
|
||||
const isProcessingRef = useRef(false);
|
||||
|
||||
const processQueue = async () => {
|
||||
if (isProcessingRef.current || saveQueueRef.current.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessingRef.current = true;
|
||||
|
||||
while (saveQueueRef.current.length > 0) {
|
||||
const request = saveQueueRef.current.shift()!;
|
||||
|
||||
try {
|
||||
const response = await onSave(request.data);
|
||||
request.onResponse?.(response);
|
||||
} catch (error) {
|
||||
console.error('Auto-save failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
isProcessingRef.current = false;
|
||||
};
|
||||
|
||||
const scheduleSave = useCallback((data: T) => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
const saveFormData = async (data: T, onResponse?: (response: R) => void) => {
|
||||
saveQueueRef.current.push({ data, onResponse });
|
||||
await processQueue();
|
||||
};
|
||||
|
||||
saveTimeoutRef.current = setTimeout(() => void saveFormData(data), 2000);
|
||||
}, []);
|
||||
const scheduleSave = useCallback(
|
||||
(data: T, onResponse?: (response: R) => void) => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
|
||||
saveTimeoutRef.current = setTimeout(() => void saveFormData(data, onResponse), delay);
|
||||
},
|
||||
[delay],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@ -2,18 +2,25 @@ import * as fs from 'node:fs';
|
||||
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
|
||||
export type CertificateStatus = {
|
||||
isAvailable: boolean;
|
||||
};
|
||||
export const getCertificateStatus = () => {
|
||||
if (env('NEXT_PRIVATE_SIGNING_TRANSPORT') !== 'local') {
|
||||
return { isAvailable: true };
|
||||
}
|
||||
|
||||
if (env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS')) {
|
||||
return { isAvailable: true };
|
||||
}
|
||||
|
||||
export const getCertificateStatus = (): CertificateStatus => {
|
||||
const defaultPath =
|
||||
env('NODE_ENV') === 'production' ? '/opt/documenso/cert.p12' : './example/cert.p12';
|
||||
|
||||
const filePath = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH') || defaultPath;
|
||||
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.F_OK | fs.constants.R_OK);
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
return { isAvailable: stats.size > 0 };
|
||||
} catch {
|
||||
return { isAvailable: false };
|
||||
|
||||
@ -84,9 +84,7 @@ export const setFieldsForDocument = async ({
|
||||
const linkedFields = fields.map((field) => {
|
||||
const existing = existingFields.find((existingField) => existingField.id === field.id);
|
||||
|
||||
const recipient = document.recipients.find(
|
||||
(recipient) => recipient.email.toLowerCase() === field.signerEmail.toLowerCase(),
|
||||
);
|
||||
const recipient = document.recipients.find((recipient) => recipient.id === field.recipientId);
|
||||
|
||||
// Each field MUST have a recipient associated with it.
|
||||
if (!recipient) {
|
||||
@ -226,10 +224,8 @@ export const setFieldsForDocument = async ({
|
||||
},
|
||||
recipient: {
|
||||
connect: {
|
||||
documentId_email: {
|
||||
documentId,
|
||||
email: fieldSignerEmail,
|
||||
},
|
||||
id: field.recipientId,
|
||||
documentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -330,6 +326,7 @@ type FieldData = {
|
||||
id?: number | null;
|
||||
type: FieldType;
|
||||
signerEmail: string;
|
||||
recipientId: number;
|
||||
pageNumber: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
|
||||
@ -26,6 +26,7 @@ export type SetFieldsForTemplateOptions = {
|
||||
id?: number | null;
|
||||
type: FieldType;
|
||||
signerEmail: string;
|
||||
recipientId: number;
|
||||
pageNumber: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
@ -169,10 +170,8 @@ export const setFieldsForTemplate = async ({
|
||||
},
|
||||
recipient: {
|
||||
connect: {
|
||||
templateId_email: {
|
||||
templateId,
|
||||
email: field.signerEmail.toLowerCase(),
|
||||
},
|
||||
id: field.recipientId,
|
||||
templateId,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -85,20 +85,6 @@ export const createDocumentRecipients = async ({
|
||||
email: recipient.email.toLowerCase(),
|
||||
}));
|
||||
|
||||
const duplicateRecipients = normalizedRecipients.filter((newRecipient) => {
|
||||
const existingRecipient = document.recipients.find(
|
||||
(existingRecipient) => existingRecipient.email === newRecipient.email,
|
||||
);
|
||||
|
||||
return existingRecipient !== undefined;
|
||||
});
|
||||
|
||||
if (duplicateRecipients.length > 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Duplicate recipient(s) found for ${duplicateRecipients.map((recipient) => recipient.email).join(', ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
const createdRecipients = await prisma.$transaction(async (tx) => {
|
||||
return await Promise.all(
|
||||
normalizedRecipients.map(async (recipient) => {
|
||||
|
||||
@ -71,20 +71,6 @@ export const createTemplateRecipients = async ({
|
||||
email: recipient.email.toLowerCase(),
|
||||
}));
|
||||
|
||||
const duplicateRecipients = normalizedRecipients.filter((newRecipient) => {
|
||||
const existingRecipient = template.recipients.find(
|
||||
(existingRecipient) => existingRecipient.email === newRecipient.email,
|
||||
);
|
||||
|
||||
return existingRecipient !== undefined;
|
||||
});
|
||||
|
||||
if (duplicateRecipients.length > 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Duplicate recipient(s) found for ${duplicateRecipients.map((recipient) => recipient.email).join(', ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
const createdRecipients = await prisma.$transaction(async (tx) => {
|
||||
return await Promise.all(
|
||||
normalizedRecipients.map(async (recipient) => {
|
||||
|
||||
@ -122,16 +122,12 @@ export const setDocumentRecipients = async ({
|
||||
|
||||
const removedRecipients = existingRecipients.filter(
|
||||
(existingRecipient) =>
|
||||
!normalizedRecipients.find(
|
||||
(recipient) =>
|
||||
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
|
||||
),
|
||||
!normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
|
||||
);
|
||||
|
||||
const linkedRecipients = normalizedRecipients.map((recipient) => {
|
||||
const existing = existingRecipients.find(
|
||||
(existingRecipient) =>
|
||||
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
|
||||
(existingRecipient) => existingRecipient.id === recipient.id,
|
||||
);
|
||||
|
||||
const canPersistedRecipientBeModified =
|
||||
|
||||
@ -94,10 +94,7 @@ export const setTemplateRecipients = async ({
|
||||
|
||||
const removedRecipients = existingRecipients.filter(
|
||||
(existingRecipient) =>
|
||||
!normalizedRecipients.find(
|
||||
(recipient) =>
|
||||
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
|
||||
),
|
||||
!normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
|
||||
);
|
||||
|
||||
if (template.directLink !== null) {
|
||||
@ -124,8 +121,7 @@ export const setTemplateRecipients = async ({
|
||||
|
||||
const linkedRecipients = normalizedRecipients.map((recipient) => {
|
||||
const existing = existingRecipients.find(
|
||||
(existingRecipient) =>
|
||||
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
|
||||
(existingRecipient) => existingRecipient.id === recipient.id,
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@ -91,17 +91,6 @@ export const updateDocumentRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const duplicateRecipientWithSameEmail = document.recipients.find(
|
||||
(existingRecipient) =>
|
||||
existingRecipient.email === recipient.email && existingRecipient.id !== recipient.id,
|
||||
);
|
||||
|
||||
if (duplicateRecipientWithSameEmail) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Duplicate recipient with the same email found: ${duplicateRecipientWithSameEmail.email}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!canRecipientBeModified(originalRecipient, document.fields)) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Cannot modify a recipient who has already interacted with the document',
|
||||
|
||||
@ -80,17 +80,6 @@ export const updateTemplateRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const duplicateRecipientWithSameEmail = template.recipients.find(
|
||||
(existingRecipient) =>
|
||||
existingRecipient.email === recipient.email && existingRecipient.id !== recipient.id,
|
||||
);
|
||||
|
||||
if (duplicateRecipientWithSameEmail) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Duplicate recipient with the same email found: ${duplicateRecipientWithSameEmail.email}`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
originalRecipient,
|
||||
recipientUpdateData: recipient,
|
||||
|
||||
@ -19,6 +19,8 @@ export type CreateDocumentFromTemplateLegacyOptions = {
|
||||
}[];
|
||||
};
|
||||
|
||||
// !TODO: Make this work
|
||||
|
||||
/**
|
||||
* Legacy server function for /api/v1
|
||||
*/
|
||||
@ -58,6 +60,15 @@ export const createDocumentFromTemplateLegacy = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const recipientsToCreate = template.recipients.map((recipient) => ({
|
||||
id: recipient.id,
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
token: nanoid(),
|
||||
}));
|
||||
|
||||
const document = await prisma.document.create({
|
||||
data: {
|
||||
qrToken: prefixedId('qr'),
|
||||
@ -70,12 +81,12 @@ export const createDocumentFromTemplateLegacy = async ({
|
||||
documentDataId: documentData.id,
|
||||
useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false,
|
||||
recipients: {
|
||||
create: template.recipients.map((recipient) => ({
|
||||
create: recipientsToCreate.map((recipient) => ({
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
token: nanoid(),
|
||||
token: recipient.token,
|
||||
})),
|
||||
},
|
||||
documentMeta: {
|
||||
@ -95,9 +106,11 @@ export const createDocumentFromTemplateLegacy = async ({
|
||||
|
||||
await prisma.field.createMany({
|
||||
data: template.fields.map((field) => {
|
||||
const recipient = template.recipients.find((recipient) => recipient.id === field.recipientId);
|
||||
const recipient = recipientsToCreate.find((recipient) => recipient.id === field.recipientId);
|
||||
|
||||
const documentRecipient = document.recipients.find((doc) => doc.email === recipient?.email);
|
||||
const documentRecipient = document.recipients.find(
|
||||
(documentRecipient) => documentRecipient.token === recipient?.token,
|
||||
);
|
||||
|
||||
if (!documentRecipient) {
|
||||
throw new Error('Recipient not found.');
|
||||
@ -118,28 +131,32 @@ export const createDocumentFromTemplateLegacy = async ({
|
||||
}),
|
||||
});
|
||||
|
||||
// Replicate the old logic, get by index and create if we exceed the number of existing recipients.
|
||||
if (recipients && recipients.length > 0) {
|
||||
document.recipients = await Promise.all(
|
||||
await Promise.all(
|
||||
recipients.map(async (recipient, index) => {
|
||||
const existingRecipient = document.recipients.at(index);
|
||||
|
||||
return await prisma.recipient.upsert({
|
||||
where: {
|
||||
documentId_email: {
|
||||
if (existingRecipient) {
|
||||
return await prisma.recipient.update({
|
||||
where: {
|
||||
id: existingRecipient.id,
|
||||
documentId: document.id,
|
||||
email: existingRecipient?.email ?? recipient.email,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
},
|
||||
create: {
|
||||
data: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.recipient.create({
|
||||
data: {
|
||||
documentId: document.id,
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
token: nanoid(),
|
||||
@ -149,5 +166,18 @@ export const createDocumentFromTemplateLegacy = async ({
|
||||
);
|
||||
}
|
||||
|
||||
return document;
|
||||
// Gross but we need to do the additional fetch since we mutate above.
|
||||
const updatedRecipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
},
|
||||
orderBy: {
|
||||
id: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...document,
|
||||
recipients: updatedRecipients,
|
||||
};
|
||||
};
|
||||
|
||||
@ -53,7 +53,7 @@ import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
type FinalRecipient = Pick<
|
||||
Recipient,
|
||||
'name' | 'email' | 'role' | 'authOptions' | 'signingOrder'
|
||||
'name' | 'email' | 'role' | 'authOptions' | 'signingOrder' | 'token'
|
||||
> & {
|
||||
templateRecipientId: number;
|
||||
fields: Field[];
|
||||
@ -350,6 +350,7 @@ export const createDocumentFromTemplate = async ({
|
||||
role: templateRecipient.role,
|
||||
signingOrder: foundRecipient?.signingOrder ?? templateRecipient.signingOrder,
|
||||
authOptions: templateRecipient.authOptions,
|
||||
token: nanoid(),
|
||||
};
|
||||
});
|
||||
|
||||
@ -441,7 +442,7 @@ export const createDocumentFromTemplate = async ({
|
||||
? SigningStatus.SIGNED
|
||||
: SigningStatus.NOT_SIGNED,
|
||||
signingOrder: recipient.signingOrder,
|
||||
token: nanoid(),
|
||||
token: recipient.token,
|
||||
};
|
||||
}),
|
||||
},
|
||||
@ -500,8 +501,8 @@ export const createDocumentFromTemplate = async ({
|
||||
}
|
||||
}
|
||||
|
||||
Object.values(finalRecipients).forEach(({ email, fields }) => {
|
||||
const recipient = document.recipients.find((recipient) => recipient.email === email);
|
||||
Object.values(finalRecipients).forEach(({ token, fields }) => {
|
||||
const recipient = document.recipients.find((recipient) => recipient.token === token);
|
||||
|
||||
if (!recipient) {
|
||||
throw new Error('Recipient not found.');
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "Recipient_documentId_email_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Recipient_templateId_email_key";
|
||||
@ -527,8 +527,6 @@ model Recipient {
|
||||
fields Field[]
|
||||
signatures Signature[]
|
||||
|
||||
@@unique([documentId, email])
|
||||
@@unique([templateId, email])
|
||||
@@index([documentId])
|
||||
@@index([templateId])
|
||||
@@index([token])
|
||||
|
||||
@ -78,14 +78,7 @@ export const ZCreateDocumentTemporaryRequestSchema = z.object({
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.refine(
|
||||
(recipients) => {
|
||||
const emails = recipients.map((recipient) => recipient.email);
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
{ message: 'Recipients must have unique emails' },
|
||||
)
|
||||
.optional(),
|
||||
meta: z
|
||||
.object({
|
||||
|
||||
@ -47,14 +47,7 @@ export const ZCreateEmbeddingDocumentRequestSchema = z.object({
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.refine(
|
||||
(recipients) => {
|
||||
const emails = recipients.map((recipient) => recipient.email);
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
{ message: 'Recipients must have unique emails' },
|
||||
)
|
||||
.optional(),
|
||||
meta: z
|
||||
.object({
|
||||
|
||||
@ -30,36 +30,27 @@ export const ZUpdateEmbeddingDocumentRequestSchema = z.object({
|
||||
documentId: z.number(),
|
||||
title: ZDocumentTitleSchema,
|
||||
externalId: ZDocumentExternalIdSchema.optional(),
|
||||
recipients: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number().optional(),
|
||||
email: z.string().toLowerCase().email().min(1),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
fields: ZFieldAndMetaSchema.and(
|
||||
z.object({
|
||||
id: z.number().optional(),
|
||||
pageNumber: ZFieldPageNumberSchema,
|
||||
pageX: ZFieldPageXSchema,
|
||||
pageY: ZFieldPageYSchema,
|
||||
width: ZFieldWidthSchema,
|
||||
height: ZFieldHeightSchema,
|
||||
}),
|
||||
)
|
||||
.array()
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.refine(
|
||||
(recipients) => {
|
||||
const emails = recipients.map((recipient) => recipient.email);
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
{ message: 'Recipients must have unique emails' },
|
||||
),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
id: z.number().optional(),
|
||||
email: z.string().toLowerCase().email().min(1),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
fields: ZFieldAndMetaSchema.and(
|
||||
z.object({
|
||||
id: z.number().optional(),
|
||||
pageNumber: ZFieldPageNumberSchema,
|
||||
pageX: ZFieldPageXSchema,
|
||||
pageY: ZFieldPageYSchema,
|
||||
width: ZFieldWidthSchema,
|
||||
height: ZFieldHeightSchema,
|
||||
}),
|
||||
)
|
||||
.array()
|
||||
.optional(),
|
||||
}),
|
||||
),
|
||||
meta: z
|
||||
.object({
|
||||
subject: ZDocumentMetaSubjectSchema.optional(),
|
||||
|
||||
@ -274,6 +274,7 @@ export const fieldRouter = router({
|
||||
fields: fields.map((field) => ({
|
||||
id: field.nativeId,
|
||||
signerEmail: field.signerEmail,
|
||||
recipientId: field.recipientId,
|
||||
type: field.type,
|
||||
pageNumber: field.pageNumber,
|
||||
pageX: field.pageX,
|
||||
@ -513,6 +514,7 @@ export const fieldRouter = router({
|
||||
fields: fields.map((field) => ({
|
||||
id: field.nativeId,
|
||||
signerEmail: field.signerEmail,
|
||||
recipientId: field.recipientId,
|
||||
type: field.type,
|
||||
pageNumber: field.pageNumber,
|
||||
pageX: field.pageX,
|
||||
|
||||
@ -114,6 +114,7 @@ export const ZSetDocumentFieldsRequestSchema = z.object({
|
||||
nativeId: z.number().optional(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
signerEmail: z.string().min(1),
|
||||
recipientId: z.number().min(1),
|
||||
pageNumber: z.number().min(1),
|
||||
pageX: z.number().min(0),
|
||||
pageY: z.number().min(0),
|
||||
@ -136,6 +137,7 @@ export const ZSetFieldsForTemplateRequestSchema = z.object({
|
||||
nativeId: z.number().optional(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
signerEmail: z.string().min(1),
|
||||
recipientId: z.number().min(1),
|
||||
pageNumber: z.number().min(1),
|
||||
pageX: z.number().min(0),
|
||||
pageY: z.number().min(0),
|
||||
|
||||
@ -50,16 +50,7 @@ export const ZCreateDocumentRecipientResponseSchema = ZRecipientLiteSchema;
|
||||
|
||||
export const ZCreateDocumentRecipientsRequestSchema = z.object({
|
||||
documentId: z.number(),
|
||||
recipients: z.array(ZCreateRecipientSchema).refine(
|
||||
(recipients) => {
|
||||
const emails = recipients.map((recipient) => recipient.email.toLowerCase());
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
{
|
||||
message: 'Recipients must have unique emails',
|
||||
},
|
||||
),
|
||||
recipients: z.array(ZCreateRecipientSchema),
|
||||
});
|
||||
|
||||
export const ZCreateDocumentRecipientsResponseSchema = z.object({
|
||||
@ -75,18 +66,7 @@ export const ZUpdateDocumentRecipientResponseSchema = ZRecipientSchema;
|
||||
|
||||
export const ZUpdateDocumentRecipientsRequestSchema = z.object({
|
||||
documentId: z.number(),
|
||||
recipients: z.array(ZUpdateRecipientSchema).refine(
|
||||
(recipients) => {
|
||||
const emails = recipients
|
||||
.filter((recipient) => recipient.email !== undefined)
|
||||
.map((recipient) => recipient.email?.toLowerCase());
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
{
|
||||
message: 'Recipients must have unique emails',
|
||||
},
|
||||
),
|
||||
recipients: z.array(ZUpdateRecipientSchema),
|
||||
});
|
||||
|
||||
export const ZUpdateDocumentRecipientsResponseSchema = z.object({
|
||||
@ -97,29 +77,19 @@ export const ZDeleteDocumentRecipientRequestSchema = z.object({
|
||||
recipientId: z.number(),
|
||||
});
|
||||
|
||||
export const ZSetDocumentRecipientsRequestSchema = z
|
||||
.object({
|
||||
documentId: z.number(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().toLowerCase().email().min(1).max(254),
|
||||
name: z.string().max(255),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
const emails = schema.recipients.map((recipient) => recipient.email.toLowerCase());
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
// Dirty hack to handle errors when .root is populated for an array type
|
||||
{ message: 'Recipients must have unique emails', path: ['recipients__root'] },
|
||||
);
|
||||
export const ZSetDocumentRecipientsRequestSchema = z.object({
|
||||
documentId: z.number(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().toLowerCase().email().min(1).max(254),
|
||||
name: z.string().max(255),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const ZSetDocumentRecipientsResponseSchema = z.object({
|
||||
recipients: ZRecipientLiteSchema.array(),
|
||||
@ -134,16 +104,7 @@ export const ZCreateTemplateRecipientResponseSchema = ZRecipientLiteSchema;
|
||||
|
||||
export const ZCreateTemplateRecipientsRequestSchema = z.object({
|
||||
templateId: z.number(),
|
||||
recipients: z.array(ZCreateRecipientSchema).refine(
|
||||
(recipients) => {
|
||||
const emails = recipients.map((recipient) => recipient.email);
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
{
|
||||
message: 'Recipients must have unique emails',
|
||||
},
|
||||
),
|
||||
recipients: z.array(ZCreateRecipientSchema),
|
||||
});
|
||||
|
||||
export const ZCreateTemplateRecipientsResponseSchema = z.object({
|
||||
@ -159,18 +120,7 @@ export const ZUpdateTemplateRecipientResponseSchema = ZRecipientSchema;
|
||||
|
||||
export const ZUpdateTemplateRecipientsRequestSchema = z.object({
|
||||
templateId: z.number(),
|
||||
recipients: z.array(ZUpdateRecipientSchema).refine(
|
||||
(recipients) => {
|
||||
const emails = recipients
|
||||
.filter((recipient) => recipient.email !== undefined)
|
||||
.map((recipient) => recipient.email);
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
{
|
||||
message: 'Recipients must have unique emails',
|
||||
},
|
||||
),
|
||||
recipients: z.array(ZUpdateRecipientSchema),
|
||||
});
|
||||
|
||||
export const ZUpdateTemplateRecipientsResponseSchema = z.object({
|
||||
@ -181,43 +131,30 @@ export const ZDeleteTemplateRecipientRequestSchema = z.object({
|
||||
recipientId: z.number(),
|
||||
});
|
||||
|
||||
export const ZSetTemplateRecipientsRequestSchema = z
|
||||
.object({
|
||||
templateId: z.number(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
nativeId: z.number().optional(),
|
||||
email: z
|
||||
.string()
|
||||
.toLowerCase()
|
||||
.refine(
|
||||
(email) => {
|
||||
return (
|
||||
isTemplateRecipientEmailPlaceholder(email) ||
|
||||
z.string().email().safeParse(email).success
|
||||
);
|
||||
},
|
||||
{ message: 'Please enter a valid email address' },
|
||||
),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
// Filter out placeholder emails and only check uniqueness for actual emails
|
||||
const nonPlaceholderEmails = schema.recipients
|
||||
.map((recipient) => recipient.email)
|
||||
.filter((email) => !isTemplateRecipientEmailPlaceholder(email));
|
||||
|
||||
return new Set(nonPlaceholderEmails).size === nonPlaceholderEmails.length;
|
||||
},
|
||||
// Dirty hack to handle errors when .root is populated for an array type
|
||||
{ message: 'Recipients must have unique emails', path: ['recipients__root'] },
|
||||
);
|
||||
export const ZSetTemplateRecipientsRequestSchema = z.object({
|
||||
templateId: z.number(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
nativeId: z.number().optional(),
|
||||
email: z
|
||||
.string()
|
||||
.toLowerCase()
|
||||
.refine(
|
||||
(email) => {
|
||||
return (
|
||||
isTemplateRecipientEmailPlaceholder(email) ||
|
||||
z.string().email().safeParse(email).success
|
||||
);
|
||||
},
|
||||
{ message: 'Please enter a valid email address' },
|
||||
),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const ZSetTemplateRecipientsResponseSchema = z.object({
|
||||
recipients: ZRecipientLiteSchema.array(),
|
||||
|
||||
@ -101,12 +101,7 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
|
||||
name: z.string().max(255).optional(),
|
||||
}),
|
||||
)
|
||||
.describe('The information of the recipients to create the document with.')
|
||||
.refine((recipients) => {
|
||||
const emails = recipients.map((signer) => signer.email);
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
}, 'Recipients must have unique emails'),
|
||||
.describe('The information of the recipients to create the document with.'),
|
||||
distributeDocument: z
|
||||
.boolean()
|
||||
.describe('Whether to create the document as pending and distribute it to recipients.')
|
||||
|
||||
@ -105,6 +105,7 @@ export const DocumentReadOnlyFields = ({
|
||||
<FieldRootContainer
|
||||
field={field}
|
||||
key={field.id}
|
||||
readonly={true}
|
||||
color={
|
||||
showRecipientColors
|
||||
? getRecipientColorStyles(
|
||||
|
||||
@ -70,9 +70,16 @@ export type FieldRootContainerProps = {
|
||||
color?: RecipientColorStyles;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
readonly?: boolean;
|
||||
};
|
||||
|
||||
export function FieldRootContainer({ field, children, color, className }: FieldRootContainerProps) {
|
||||
export function FieldRootContainer({
|
||||
field,
|
||||
children,
|
||||
color,
|
||||
className,
|
||||
readonly,
|
||||
}: FieldRootContainerProps) {
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
@ -103,6 +110,7 @@ export function FieldRootContainer({ field, children, color, className }: FieldR
|
||||
ref={ref}
|
||||
data-field-type={field.type}
|
||||
data-inserted={field.inserted ? 'true' : 'false'}
|
||||
data-readonly={readonly ? 'true' : 'false'}
|
||||
className={cn(
|
||||
'field--FieldRootContainer field-card-container dark-mode-disabled group relative z-20 flex h-full w-full items-center rounded-[2px] bg-white/90 ring-2 ring-gray-200 transition-all',
|
||||
color?.base,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
@ -46,7 +47,7 @@ import { Form } from '../form/form';
|
||||
import { RecipientSelector } from '../recipient-selector';
|
||||
import { useStep } from '../stepper';
|
||||
import { useToast } from '../use-toast';
|
||||
import type { TAddFieldsFormSchema } from './add-fields.types';
|
||||
import { type TAddFieldsFormSchema, ZAddFieldsFormSchema } from './add-fields.types';
|
||||
import {
|
||||
DocumentFlowFormContainerActions,
|
||||
DocumentFlowFormContainerContent,
|
||||
@ -75,6 +76,7 @@ export type FieldFormType = {
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
signerEmail: string;
|
||||
recipientId: number;
|
||||
fieldMeta?: FieldMeta;
|
||||
};
|
||||
|
||||
@ -127,9 +129,11 @@ export const AddFieldsFormPartial = ({
|
||||
pageHeight: Number(field.height),
|
||||
signerEmail:
|
||||
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
||||
recipientId: field.recipientId,
|
||||
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
|
||||
})),
|
||||
},
|
||||
resolver: zodResolver(ZAddFieldsFormSchema),
|
||||
});
|
||||
|
||||
useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt));
|
||||
@ -323,6 +327,7 @@ export const AddFieldsFormPartial = ({
|
||||
|
||||
const field = {
|
||||
formId: nanoid(12),
|
||||
nativeId: undefined,
|
||||
type: selectedField,
|
||||
pageNumber,
|
||||
pageX,
|
||||
@ -330,6 +335,7 @@ export const AddFieldsFormPartial = ({
|
||||
pageWidth: fieldPageWidth,
|
||||
pageHeight: fieldPageHeight,
|
||||
signerEmail: selectedSigner.email,
|
||||
recipientId: selectedSigner.id,
|
||||
fieldMeta: undefined,
|
||||
};
|
||||
|
||||
@ -414,6 +420,7 @@ export const AddFieldsFormPartial = ({
|
||||
nativeId: undefined,
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||
recipientId: selectedSigner?.id ?? lastActiveField.recipientId,
|
||||
pageX: lastActiveField.pageX + 3,
|
||||
pageY: lastActiveField.pageY + 3,
|
||||
};
|
||||
@ -438,6 +445,7 @@ export const AddFieldsFormPartial = ({
|
||||
nativeId: undefined,
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||
recipientId: selectedSigner?.id ?? lastActiveField.recipientId,
|
||||
pageNumber,
|
||||
};
|
||||
|
||||
@ -470,6 +478,7 @@ export const AddFieldsFormPartial = ({
|
||||
nativeId: undefined,
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
|
||||
recipientId: selectedSigner?.id ?? copiedField.recipientId,
|
||||
pageX: copiedField.pageX + 3,
|
||||
pageY: copiedField.pageY + 3,
|
||||
});
|
||||
@ -663,7 +672,7 @@ export const AddFieldsFormPartial = ({
|
||||
|
||||
{isDocumentPdfLoaded &&
|
||||
localFields.map((field, index) => {
|
||||
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail);
|
||||
const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
|
||||
const hasFieldError =
|
||||
emptyCheckboxFields.find((f) => f.formId === field.formId) ||
|
||||
emptyRadioFields.find((f) => f.formId === field.formId) ||
|
||||
|
||||
@ -10,6 +10,7 @@ export const ZAddFieldsFormSchema = z.object({
|
||||
nativeId: z.number().optional(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
signerEmail: z.string().min(1),
|
||||
recipientId: z.number().min(1),
|
||||
pageNumber: z.number().min(1),
|
||||
pageX: z.number().min(0),
|
||||
pageY: z.number().min(0),
|
||||
|
||||
@ -53,6 +53,10 @@ import {
|
||||
import { SigningOrderConfirmation } from './signing-order-confirmation';
|
||||
import type { DocumentFlowStep } from './types';
|
||||
|
||||
type AutoSaveResponse = {
|
||||
recipients: Recipient[];
|
||||
};
|
||||
|
||||
export type AddSignersFormProps = {
|
||||
documentFlow: DocumentFlowStep;
|
||||
recipients: Recipient[];
|
||||
@ -60,7 +64,7 @@ export type AddSignersFormProps = {
|
||||
signingOrder?: DocumentSigningOrder | null;
|
||||
allowDictateNextSigner?: boolean;
|
||||
onSubmit: (_data: TAddSignersFormSchema) => void;
|
||||
onAutoSave: (_data: TAddSignersFormSchema) => Promise<void>;
|
||||
onAutoSave: (_data: TAddSignersFormSchema) => Promise<AutoSaveResponse>;
|
||||
isDocumentPdfLoaded: boolean;
|
||||
};
|
||||
|
||||
@ -208,7 +212,44 @@ export const AddSignersFormPartial = ({
|
||||
|
||||
const formData = form.getValues();
|
||||
|
||||
scheduleSave(formData);
|
||||
scheduleSave(formData, (response) => {
|
||||
// Sync the response recipients back to form state to prevent duplicates
|
||||
if (response?.recipients) {
|
||||
const currentSigners = form.getValues('signers');
|
||||
const updatedSigners = currentSigners.map((signer) => {
|
||||
// Find the matching recipient from the response using nativeId
|
||||
const matchingRecipient = response.recipients.find(
|
||||
(recipient) => recipient.id === signer.nativeId,
|
||||
);
|
||||
|
||||
if (matchingRecipient) {
|
||||
// Update the signer with the server-returned data, especially the ID
|
||||
return {
|
||||
...signer,
|
||||
nativeId: matchingRecipient.id,
|
||||
};
|
||||
}
|
||||
|
||||
// For new signers without nativeId, match by email and update with server ID
|
||||
if (!signer.nativeId) {
|
||||
const newRecipient = response.recipients.find(
|
||||
(recipient) => recipient.email === signer.email,
|
||||
);
|
||||
if (newRecipient) {
|
||||
return {
|
||||
...signer,
|
||||
nativeId: newRecipient.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return signer;
|
||||
});
|
||||
|
||||
// Update the form state with the synced data
|
||||
form.setValue('signers', updatedSigners, { shouldValidate: false });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email);
|
||||
|
||||
@ -4,33 +4,23 @@ import { z } from 'zod';
|
||||
|
||||
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
||||
|
||||
export const ZAddSignersFormSchema = z
|
||||
.object({
|
||||
signers: z.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
email: z
|
||||
.string()
|
||||
.email({ message: msg`Invalid email`.id })
|
||||
.min(1),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
||||
}),
|
||||
),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||
allowDictateNextSigner: z.boolean().default(false),
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
const emails = schema.signers.map((signer) => signer.email.toLowerCase());
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
// Dirty hack to handle errors when .root is populated for an array type
|
||||
{ message: msg`Signers must have unique emails`.id, path: ['signers__root'] },
|
||||
);
|
||||
export const ZAddSignersFormSchema = z.object({
|
||||
signers: z.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
email: z
|
||||
.string()
|
||||
.email({ message: msg`Invalid email`.id })
|
||||
.min(1),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
||||
}),
|
||||
),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||
allowDictateNextSigner: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;
|
||||
|
||||
@ -299,6 +299,8 @@ export const FieldItem = ({
|
||||
}}
|
||||
ref={$el}
|
||||
data-field-id={field.nativeId}
|
||||
data-field-type={field.type}
|
||||
data-recipient-id={field.recipientId}
|
||||
>
|
||||
<FieldContent field={field} />
|
||||
|
||||
|
||||
@ -8,19 +8,14 @@ import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
export const ZDocumentFlowFormSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
|
||||
signers: z
|
||||
.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().min(1).email(),
|
||||
name: z.string(),
|
||||
}),
|
||||
)
|
||||
.refine((signers) => {
|
||||
const emails = signers.map((signer) => signer.email);
|
||||
return new Set(emails).size === emails.length;
|
||||
}, 'Signers must have unique emails'),
|
||||
signers: z.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().min(1).email(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
|
||||
fields: z.array(
|
||||
z.object({
|
||||
@ -28,6 +23,7 @@ export const ZDocumentFlowFormSchema = z.object({
|
||||
nativeId: z.number().optional(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
signerEmail: z.string().min(1).optional(),
|
||||
recipientId: z.number().min(1),
|
||||
pageNumber: z.number().min(1),
|
||||
pageX: z.number().min(0),
|
||||
pageY: z.number().min(0),
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
@ -61,7 +62,10 @@ import type { FieldFormType } from '../document-flow/add-fields';
|
||||
import { FieldAdvancedSettings } from '../document-flow/field-item-advanced-settings';
|
||||
import { Form } from '../form/form';
|
||||
import { useStep } from '../stepper';
|
||||
import type { TAddTemplateFieldsFormSchema } from './add-template-fields.types';
|
||||
import {
|
||||
type TAddTemplateFieldsFormSchema,
|
||||
ZAddTemplateFieldsFormSchema,
|
||||
} from './add-template-fields.types';
|
||||
|
||||
const MIN_HEIGHT_PX = 12;
|
||||
const MIN_WIDTH_PX = 36;
|
||||
@ -112,7 +116,7 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
pageY: Number(field.positionY),
|
||||
pageWidth: Number(field.width),
|
||||
pageHeight: Number(field.height),
|
||||
signerId: field.recipientId ?? -1,
|
||||
recipientId: field.recipientId ?? -1,
|
||||
signerEmail:
|
||||
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
||||
signerToken:
|
||||
@ -120,6 +124,7 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
|
||||
})),
|
||||
},
|
||||
resolver: zodResolver(ZAddTemplateFieldsFormSchema),
|
||||
});
|
||||
|
||||
const onFormSubmit = form.handleSubmit(onSubmit);
|
||||
@ -170,7 +175,7 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
nativeId: undefined,
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||
signerId: selectedSigner?.id ?? lastActiveField.signerId,
|
||||
recipientId: selectedSigner?.id ?? lastActiveField.recipientId,
|
||||
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
|
||||
pageX: lastActiveField.pageX + 3,
|
||||
pageY: lastActiveField.pageY + 3,
|
||||
@ -197,7 +202,7 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
nativeId: undefined,
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||
signerId: selectedSigner?.id ?? lastActiveField.signerId,
|
||||
recipientId: selectedSigner?.id ?? lastActiveField.recipientId,
|
||||
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
|
||||
pageNumber,
|
||||
};
|
||||
@ -240,7 +245,7 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
formId: nanoid(12),
|
||||
nativeId: undefined,
|
||||
signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
|
||||
signerId: selectedSigner?.id ?? copiedField.signerId,
|
||||
recipientId: selectedSigner?.id ?? copiedField.recipientId,
|
||||
signerToken: selectedSigner?.token ?? copiedField.signerToken,
|
||||
pageX: copiedField.pageX + 3,
|
||||
pageY: copiedField.pageY + 3,
|
||||
@ -371,7 +376,7 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
pageWidth: fieldPageWidth,
|
||||
pageHeight: fieldPageHeight,
|
||||
signerEmail: selectedSigner.email,
|
||||
signerId: selectedSigner.id,
|
||||
recipientId: selectedSigner.id,
|
||||
signerToken: selectedSigner.token ?? '',
|
||||
fieldMeta: undefined,
|
||||
};
|
||||
@ -597,14 +602,14 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
)}
|
||||
|
||||
{localFields.map((field, index) => {
|
||||
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail);
|
||||
const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
|
||||
|
||||
return (
|
||||
<FieldItem
|
||||
key={index}
|
||||
recipientIndex={recipientIndex === -1 ? 0 : recipientIndex}
|
||||
field={field}
|
||||
disabled={selectedSigner?.email !== field.signerEmail}
|
||||
disabled={selectedSigner?.id !== field.recipientId}
|
||||
minHeight={MIN_HEIGHT_PX}
|
||||
minWidth={MIN_WIDTH_PX}
|
||||
defaultHeight={DEFAULT_HEIGHT_PX}
|
||||
|
||||
@ -10,8 +10,8 @@ export const ZAddTemplateFieldsFormSchema = z.object({
|
||||
nativeId: z.number().optional(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
signerEmail: z.string().min(1),
|
||||
recipientId: z.number().min(1),
|
||||
signerToken: z.string(),
|
||||
signerId: z.number().optional(),
|
||||
pageNumber: z.number().min(1),
|
||||
pageX: z.number().min(0),
|
||||
pageY: z.number().min(0),
|
||||
|
||||
@ -48,6 +48,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
|
||||
import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
|
||||
import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
|
||||
|
||||
type AutoSaveResponse = {
|
||||
recipients: Recipient[];
|
||||
};
|
||||
|
||||
export type AddTemplatePlaceholderRecipientsFormProps = {
|
||||
documentFlow: DocumentFlowStep;
|
||||
recipients: Recipient[];
|
||||
@ -56,7 +60,7 @@ export type AddTemplatePlaceholderRecipientsFormProps = {
|
||||
allowDictateNextSigner?: boolean;
|
||||
templateDirectLink?: TemplateDirectLink | null;
|
||||
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
|
||||
onAutoSave: (_data: TAddTemplatePlacholderRecipientsFormSchema) => Promise<void>;
|
||||
onAutoSave: (_data: TAddTemplatePlacholderRecipientsFormSchema) => Promise<AutoSaveResponse>;
|
||||
isDocumentPdfLoaded: boolean;
|
||||
};
|
||||
|
||||
@ -146,7 +150,44 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
|
||||
const formData = form.getValues();
|
||||
|
||||
scheduleSave(formData);
|
||||
scheduleSave(formData, (response) => {
|
||||
// Sync the response recipients back to form state to prevent duplicates
|
||||
if (response?.recipients) {
|
||||
const currentSigners = form.getValues('signers');
|
||||
const updatedSigners = currentSigners.map((signer) => {
|
||||
// Find the matching recipient from the response using nativeId
|
||||
const matchingRecipient = response.recipients.find(
|
||||
(recipient) => recipient.id === signer.nativeId,
|
||||
);
|
||||
|
||||
if (matchingRecipient) {
|
||||
// Update the signer with the server-returned data, especially the ID
|
||||
return {
|
||||
...signer,
|
||||
nativeId: matchingRecipient.id,
|
||||
};
|
||||
}
|
||||
|
||||
// For new signers without nativeId, match by email and update with server ID
|
||||
if (!signer.nativeId) {
|
||||
const newRecipient = response.recipients.find(
|
||||
(recipient) => recipient.email === signer.email,
|
||||
);
|
||||
if (newRecipient) {
|
||||
return {
|
||||
...signer,
|
||||
nativeId: newRecipient.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return signer;
|
||||
});
|
||||
|
||||
// Update the form state with the synced data
|
||||
form.setValue('signers', updatedSigners, { shouldValidate: false });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// useEffect(() => {
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX } from '@documenso/lib/constants/template';
|
||||
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
||||
|
||||
export const ZAddTemplatePlacholderRecipientsFormSchema = z
|
||||
@ -20,17 +19,7 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||
allowDictateNextSigner: z.boolean().default(false),
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
const nonPlaceholderEmails = schema.signers
|
||||
.map((signer) => signer.email.toLowerCase())
|
||||
.filter((email) => !TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX.test(email));
|
||||
|
||||
return new Set(nonPlaceholderEmails).size === nonPlaceholderEmails.length;
|
||||
},
|
||||
// Dirty hack to handle errors when .root is populated for an array type
|
||||
{ message: 'Signers must have unique emails', path: ['signers__root'] },
|
||||
)
|
||||
.refine(
|
||||
/*
|
||||
Since placeholder emails are empty, we need to check that the names are unique.
|
||||
|
||||
Reference in New Issue
Block a user