Compare commits

...

28 Commits

Author SHA1 Message Date
Catalin Pit
7e9c7f1b11 Merge branch 'main' into feat/allow-same-signer-email-multiple-times 2025-02-13 13:58:03 +02:00
Catalin Pit
bc2ec9a2d7 chore: disable next step on backend for signers without signature field 2025-02-13 13:57:20 +02:00
Catalin Pit
763b7f82c9 chore: disable next step for signers without signature field 2025-02-13 10:21:24 +02:00
Catalin Pit
c670f64b1f chore: tests for creating a document from a template 2025-02-12 13:30:43 +02:00
Catalin Pit
369e16afab chore: template fields testing 2025-02-11 16:52:02 +02:00
Catalin Pit
4a5f565591 chore: template fields testing 2025-02-11 14:21:23 +02:00
Catalin Pit
f544eae2a6 chore: add template tests 2025-02-11 10:29:56 +02:00
Catalin Pit
a2ffd75c17 chore: add tests 2025-02-11 09:50:46 +02:00
Catalin Pit
8619eec67a chore: self-review pr 2025-02-10 16:47:07 +02:00
Catalin Pit
f325a04cb5 chore: update templates 2025-02-10 15:49:21 +02:00
Ephraim Duncan
2ff330f9d4 chore: update local seed data (#1622)
## Description

Add multiple example documents, pending documents, and templates for
both admin and example users

## Changes Made
- Added seeding of multiple example documents and templates for both
example and admin users

## Checklist

- [x] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [ ] I have updated the documentation to reflect these changes, if
applicable.
- [x] I have followed the project's coding style guidelines.
- [x] I have addressed the code review feedback from the previous
submission, if applicable.
2025-02-10 22:55:12 +11:00
Catalin Pit
6a47b3a6e5 chore: documents work properly 2025-02-10 13:44:13 +02:00
Catalin Pit
a7adb77e47 chore: allow document creation from template 2025-02-07 18:08:14 +02:00
Catalin Pit
bfcbaea3a9 chore: remove unique email constraint 2025-02-07 15:22:50 +02:00
Catalin Pit
64964f420a chore: make duplicate recipients work for remplates 2025-02-07 14:29:38 +02:00
Catalin Pit
2896673a23 chore: allow duplicate recipient in templates 2025-02-06 16:58:16 +02:00
Catalin Pit
b684b9574d chore: reverse some code changes 2025-02-06 15:17:06 +02:00
Catalin Pit
12803d1a5e chore: undo code 2025-02-06 14:32:18 +02:00
Catalin Pit
c41002313a chore: allow same signer docs 2025-02-06 14:27:37 +02:00
Mythie
ce1c93b2a6 v1.9.1-rc.1 2025-02-05 21:03:15 +11:00
Catalin Pit
82337e4e3a fix: typed signature not working (#1635)
The `typedSignatureEnabled` prop was removed from the `SignatureField`
component, which broke the typed signature meaning that nobody could
sign documents by typing their signature.
2025-02-05 21:02:21 +11:00
Catalin Pit
516435fa2a Merge branch 'main' into feat/allow-same-signer-email-multiple-times 2025-02-05 10:27:18 +02:00
Mythie
7d9a3f9776 fix: assistant mode breaks for number fields 2025-02-04 07:59:41 +11:00
Catalin Pit
0216af4ae8 Merge branch 'main' into feat/allow-same-signer-email-multiple-times 2025-02-03 14:12:42 +02:00
Catalin Pit
3cde3cb7b2 Merge branch 'main' into feat/allow-same-signer-email-multiple-times 2025-01-28 12:40:45 +02:00
Catalin Pit
071f5c546d chore: remove same email check from backend 2025-01-27 17:08:55 +02:00
Catalin Pit
9f9f6701c8 chore: remove same email check from backend 2025-01-27 16:44:21 +02:00
Catalin Pit
b01eaceeb8 feat: allow same signer email multiple times 2025-01-27 16:36:53 +02:00
37 changed files with 1656 additions and 340 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@documenso/web", "name": "@documenso/web",
"version": "1.9.1-rc.0", "version": "1.9.1-rc.1",
"private": true, "private": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {

View File

@@ -47,50 +47,22 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
import type { Toast } from '@documenso/ui/primitives/use-toast'; import type { Toast } from '@documenso/ui/primitives/use-toast';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
const ZAddRecipientsForNewDocumentSchema = z const ZAddRecipientsForNewDocumentSchema = z.object({
.object({ distributeDocument: z.boolean(),
distributeDocument: z.boolean(), useCustomDocument: z.boolean().default(false),
useCustomDocument: z.boolean().default(false), customDocumentData: z
customDocumentData: z .any()
.any() .refine((data) => data instanceof File || data === undefined)
.refine((data) => data instanceof File || data === undefined) .optional(),
.optional(), recipients: z.array(
recipients: z.array( z.object({
z.object({ id: z.number(),
id: z.number(), email: z.string().email(),
email: z.string().email(), name: z.string(),
name: z.string(), signingOrder: z.number().optional(),
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'],
});
}
});
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>; type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;

View File

@@ -189,6 +189,7 @@ export const SignDirectTemplateForm = ({
field={field} field={field}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
/> />
)) ))
.with(FieldType.INITIALS, () => ( .with(FieldType.INITIALS, () => (
@@ -342,6 +343,7 @@ export const SignDirectTemplateForm = ({
onChange={(value) => { onChange={(value) => {
setSignature(value); setSignature(value);
}} }}
allowTypedSignature={template.templateMeta?.typedSignatureEnabled}
/> />
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -179,14 +179,8 @@ export const NumberField = ({ field, onSignField, onUnsignField }: NumberFieldPr
const onRemove = async () => { const onRemove = async () => {
try { try {
if (isAssistantMode && !targetSigner) {
return;
}
const signingRecipient = isAssistantMode && targetSigner ? targetSigner : recipient;
const payload: TRemovedSignedFieldWithTokenMutationSchema = { const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: signingRecipient.token, token: recipient.token,
fieldId: field.id, fieldId: field.id,
}; };

View File

@@ -68,26 +68,16 @@ export const RadioField = ({ field, onSignField, onUnsignField }: RadioFieldProp
const onSign = async (authOptions?: TRecipientActionAuth) => { const onSign = async (authOptions?: TRecipientActionAuth) => {
try { try {
if (isAssistantMode && !targetSigner) {
return;
}
if (!selectedOption) { if (!selectedOption) {
return; return;
} }
const signingRecipient = isAssistantMode && targetSigner ? targetSigner : recipient;
const payload: TSignFieldWithTokenMutationSchema = { const payload: TSignFieldWithTokenMutationSchema = {
token: signingRecipient.token, token: recipient.token,
fieldId: field.id, fieldId: field.id,
value: selectedOption, value: selectedOption,
isBase64: true, isBase64: true,
authOptions, authOptions,
...(isAssistantMode && {
isAssistantPrefill: true,
assistantId: recipient.id,
}),
}; };
if (onSignField) { if (onSignField) {

View File

@@ -179,7 +179,13 @@ export const SigningPageView = ({
) )
.map((field) => .map((field) =>
match(field.type) match(field.type)
.with(FieldType.SIGNATURE, () => <SignatureField key={field.id} field={field} />) .with(FieldType.SIGNATURE, () => (
<SignatureField
key={field.id}
field={field}
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
/>
))
.with(FieldType.INITIALS, () => <InitialsField key={field.id} field={field} />) .with(FieldType.INITIALS, () => <InitialsField key={field.id} field={field} />)
.with(FieldType.NAME, () => <NameField key={field.id} field={field} />) .with(FieldType.NAME, () => <NameField key={field.id} field={field} />)
.with(FieldType.DATE, () => ( .with(FieldType.DATE, () => (

21
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@documenso/root", "name": "@documenso/root",
"version": "1.9.1-rc.0", "version": "1.9.1-rc.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@documenso/root", "name": "@documenso/root",
"version": "1.9.1-rc.0", "version": "1.9.1-rc.1",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*"
@@ -106,7 +106,7 @@
}, },
"apps/web": { "apps/web": {
"name": "@documenso/web", "name": "@documenso/web",
"version": "1.9.1-rc.0", "version": "1.9.1-rc.1",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
"@documenso/api": "*", "@documenso/api": "*",
@@ -35722,6 +35722,21 @@
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
},
"packages/trpc/node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.6.tgz",
"integrity": "sha512-hNukAxq7hu4o5/UjPp5jqoBEtrpCbOmnUqZSKNJG8GrUVzfq0ucdhQFVrHcLRMvQcwqqDh1a5AJN9ORnNDpgBQ==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
} }
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"private": true, "private": true,
"version": "1.9.1-rc.0", "version": "1.9.1-rc.1",
"scripts": { "scripts": {
"build": "turbo run build", "build": "turbo run build",
"build:web": "turbo run build --filter=@documenso/web", "build:web": "turbo run build --filter=@documenso/web",

View File

@@ -270,12 +270,11 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
) )
.refine( .refine(
(schema) => { (schema) => {
const emails = schema.map((signer) => signer.email.toLowerCase());
const ids = schema.map((signer) => signer.id); 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 meta: z
.object({ .object({

View File

@@ -0,0 +1,304 @@
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.describe.configure({ mode: 'parallel' });
test('[DOCUMENT_FLOW]: add signature fields for unique recipients', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(1).fill('recipient2@documenso.com');
await page.getByLabel('Name').nth(1).fill('Recipient 2');
// Advanced settings should not be visible for non EE users.
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
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,
},
});
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 200,
y: 100,
},
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('**/documents');
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
});
test('[DOCUMENT_FLOW]: add signature fields for duplicate recipients', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(1).fill('recipient@documenso.com');
await page.getByLabel('Name').nth(1).fill('Recipient');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(2).fill('recipient@documenso.com');
await page.getByLabel('Name').nth(2).fill('Recipient');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Add signature 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').click();
await page.getByRole('option', { name: 'Recipient (recipient@documenso.com)' }).nth(1).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 200,
y: 100,
},
});
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient (recipient@documenso.com)' }).nth(2).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 300,
y: 100,
},
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('**/documents');
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
});
test('[DOCUMENT_FLOW]: add signature fields for recipients with different roles', async ({
page,
}) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Add a signer
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Documenso Recipient');
await page.getByRole('combobox').click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Add an approver
await page.getByLabel('Email').nth(1).fill('recipient@documenso.com');
await page.getByLabel('Name').nth(1).fill('Documenso Recipient');
await page.getByRole('combobox').nth(1).click();
await page.getByLabel('Needs to approve').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Add a viewer
await page.getByLabel('Email').nth(2).fill('recipient@documenso.com');
await page.getByLabel('Name').nth(2).fill('Documenso Recipient');
await page.getByRole('combobox').nth(2).click();
await page.getByLabel('Needs to view').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Add a CC
await page.getByLabel('Email').nth(3).fill('recipient@documenso.com');
await page.getByLabel('Name').nth(3).fill('Documenso Recipient');
await page.getByRole('combobox').nth(3).click();
await page.getByLabel('Receives copy').click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Add signature fields for signer and approver
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('combobox').click();
await page
.getByRole('option', { name: 'Documenso Recipient (recipient@documenso.com)' })
.nth(1)
.click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 200,
y: 100,
},
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('**/documents');
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
});
test('[DOCUMENT_FLOW]: add signature fields for mixed recipients', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// First recipient (unique)
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('First Recipient');
await page.getByRole('combobox').click();
await page.getByLabel('Needs to approve').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Second recipient (duplicate of first)
await page.getByLabel('Email').nth(1).fill('recipient1@documenso.com');
await page.getByLabel('Name').nth(1).fill('First Recipient');
await page.getByRole('combobox').nth(1).click();
await page.getByLabel('Needs to view').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Third recipient (unique)
await page.getByLabel('Email').nth(2).fill('recipient2@documenso.com');
await page.getByLabel('Name').nth(2).fill('Second Recipient');
await page.getByRole('combobox').nth(2).click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Fourth recipient (duplicate of first)
await page.getByLabel('Email').nth(3).fill('recipient1@documenso.com');
await page.getByLabel('Name').nth(3).fill('First Recipient');
await page.getByRole('combobox').nth(3).click();
await page.getByLabel('Receives copy').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Fifth recipient (unique)
await page.getByLabel('Email').nth(4).fill('recipient3@documenso.com');
await page.getByLabel('Name').nth(4).fill('Third Recipient');
await page.getByRole('combobox').nth(4).click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Add signature fields for approver and signers
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Second Recipient (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 200,
y: 100,
},
});
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Third Recipient (recipient3@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 300,
y: 100,
},
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('**/documents');
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
});

View File

@@ -91,3 +91,191 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
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(); await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
}); });
test('[DOCUMENT_FLOW]: add only recipients with the same email address', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(1).fill('recipient@documenso.com');
await page.getByLabel('Name').nth(1).fill('Recipient');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(2).fill('recipient@documenso.com');
await page.getByLabel('Name').nth(2).fill('Recipient');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
});
test('[DOCUMENT_FLOW]: duplicate email recipients', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Add a signer
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Documenso Recipient');
await page.getByRole('combobox').click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Add an approver
await page.getByLabel('Email').nth(1).fill('recipient@documenso.com');
await page.getByLabel('Name').nth(1).fill('Documenso Recipient');
await page.getByRole('combobox').nth(1).click();
await page.getByLabel('Needs to approve').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Add a viewer
await page.getByLabel('Email').nth(2).fill('recipient@documenso.com');
await page.getByLabel('Name').nth(2).fill('Documenso Recipient');
await page.getByRole('combobox').nth(2).click();
await page.getByLabel('Needs to view').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Add a CC
await page.getByLabel('Email').nth(3).fill('recipient@documenso.com');
await page.getByLabel('Name').nth(3).fill('Documenso Recipient');
await page.getByRole('combobox').nth(3).click();
await page.getByLabel('Receives copy').click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
});
test('[DOCUMENT_FLOW]: same email with different roles', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// First recipient (unique)
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('First Recipient');
await page.getByRole('combobox').click();
await page.getByLabel('Needs to approve').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Second recipient (duplicate of first)
await page.getByLabel('Email').nth(1).fill('recipient1@documenso.com');
await page.getByLabel('Name').nth(1).fill('First Recipient');
await page.getByRole('combobox').nth(1).click();
await page.getByLabel('Needs to view').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Third recipient (unique)
await page.getByLabel('Email').nth(2).fill('recipient2@documenso.com');
await page.getByLabel('Name').nth(2).fill('Second Recipient');
await page.getByRole('combobox').nth(2).click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Fourth recipient (duplicate of first)
await page.getByLabel('Email').nth(3).fill('recipient1@documenso.com');
await page.getByLabel('Name').nth(3).fill('First Recipient');
await page.getByRole('combobox').nth(3).click();
await page.getByLabel('Receives copy').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Fifth recipient (unique)
await page.getByLabel('Email').nth(4).fill('recipient3@documenso.com');
await page.getByLabel('Name').nth(4).fill('Third Recipient');
await page.getByRole('combobox').nth(4).click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
});
test('[DOCUMENT_FLOW]: mixed unique and duplicate recipients', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// First recipient (unique)
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('First Recipient');
await page.getByRole('combobox').click();
await page.getByLabel('Needs to approve').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Second recipient (duplicate of first)
await page.getByLabel('Email').nth(1).fill('recipient1@documenso.com');
await page.getByLabel('Name').nth(1).fill('First Recipient');
await page.getByRole('combobox').nth(1).click();
await page.getByLabel('Needs to view').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Third recipient (unique)
await page.getByLabel('Email').nth(2).fill('recipient2@documenso.com');
await page.getByLabel('Name').nth(2).fill('Second Recipient');
await page.getByRole('combobox').nth(2).click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Fourth recipient (duplicate of first)
await page.getByLabel('Email').nth(3).fill('recipient1@documenso.com');
await page.getByLabel('Name').nth(3).fill('First Recipient');
await page.getByRole('combobox').nth(3).click();
await page.getByLabel('Receives copy').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Fifth recipient (unique)
await page.getByLabel('Email').nth(4).fill('recipient3@documenso.com');
await page.getByLabel('Name').nth(4).fill('Third Recipient');
await page.getByRole('combobox').nth(4).click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
});

View File

@@ -0,0 +1,292 @@
import { expect, test } from '@playwright/test';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEMPLATE_FLOW]: add signature fields for unique recipients', async ({ page }) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Add 2 placeholder recipients.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
// Advanced settings should not be visible for non EE users.
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
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,
},
});
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 200,
y: 100,
},
});
await page.getByRole('button', { name: 'Save Template' }).click();
await page.waitForURL('**/templates');
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
});
test('[TEMPLATE_FLOW]: add signature fields for duplicate recipients', async ({ page }) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(1).fill('recipient@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(2).fill('recipient@documenso.com');
await page.getByPlaceholder('Name').nth(2).fill('Recipient');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Add signature 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').click();
await page.getByRole('option', { name: 'Recipient (recipient@documenso.com)' }).nth(1).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 200,
y: 100,
},
});
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient (recipient@documenso.com)' }).nth(2).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 300,
y: 100,
},
});
await page.getByRole('button', { name: 'Save Template' }).click();
await page.waitForURL('**/templates');
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
});
test('[TEMPLATE_FLOW]: add signature fields for recipients with different roles', async ({
page,
}) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Add a placeholder recipient
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Documenso Recipient');
await page.getByRole('combobox').click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Add an approver
await page.getByPlaceholder('Email').nth(1).fill('recipient@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Documenso Recipient');
await page.getByRole('combobox').nth(1).click();
await page.getByLabel('Needs to approve').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Add a viewer
await page.getByPlaceholder('Email').nth(2).fill('recipient@documenso.com');
await page.getByPlaceholder('Name').nth(2).fill('Documenso Recipient');
await page.getByRole('combobox').nth(2).click();
await page.getByLabel('Needs to view').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Add a CC
await page.getByPlaceholder('Email').nth(3).fill('recipient@documenso.com');
await page.getByPlaceholder('Name').nth(3).fill('Documenso Recipient');
await page.getByRole('combobox').nth(3).click();
await page.getByLabel('Receives copy').click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Add signature fields for signer and approver
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('combobox').click();
await page
.getByRole('option', { name: 'Documenso Recipient (recipient@documenso.com)' })
.nth(1)
.click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 200,
y: 100,
},
});
await page.getByRole('button', { name: 'Save Template' }).click();
await page.waitForURL('**/templates');
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
});
test('[TEMPLATE_FLOW]: add signature fields for mixed recipients', async ({ page }) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// First placeholder recipient (unique)
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('First Recipient');
await page.getByRole('combobox').click();
await page.getByLabel('Needs to approve').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Second placeholder recipient (duplicate of first)
await page.getByPlaceholder('Email').nth(1).fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('First Recipient');
await page.getByRole('combobox').nth(1).click();
await page.getByLabel('Needs to view').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Third placeholder recipient (unique)
await page.getByPlaceholder('Email').nth(2).fill('recipient2@documenso.com');
await page.getByPlaceholder('Name').nth(2).fill('Second Recipient');
await page.getByRole('combobox').nth(2).click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Fourth placeholder recipient (duplicate of first)
await page.getByPlaceholder('Email').nth(3).fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').nth(3).fill('First Recipient');
await page.getByRole('combobox').nth(3).click();
await page.getByLabel('Receives copy').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Fifth placeholder recipient (unique)
await page.getByPlaceholder('Email').nth(4).fill('recipient3@documenso.com');
await page.getByPlaceholder('Name').nth(4).fill('Third Recipient');
await page.getByRole('combobox').nth(4).click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Add signature fields for approver and signers
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Second Recipient (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 200,
y: 100,
},
});
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Third Recipient (recipient3@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 300,
y: 100,
},
});
await page.getByRole('button', { name: 'Save Template' }).click();
await page.waitForURL('**/templates');
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
});

View File

@@ -98,3 +98,135 @@ test('[TEMPLATE_FLOW]: add placeholder', async ({ page }) => {
// Advanced settings should not be visible for non EE users. // Advanced settings should not be visible for non EE users.
await expect(page.getByLabel('Show advanced settings')).toBeHidden(); await expect(page.getByLabel('Show advanced settings')).toBeHidden();
}); });
test('[TEMPLATE_FLOW]: duplicate recipients', async ({ page }) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(1).fill('recipient@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(2).fill('recipient@documenso.com');
await page.getByPlaceholder('Name').nth(2).fill('Recipient');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
});
test('[TEMPLATE_FLOW]: same email different roles', async ({ page }) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add a signer
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Documenso Recipient');
await page.getByRole('combobox').click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Add an approver
await page.getByPlaceholder('Email').nth(1).fill('recipient@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Documenso Recipient');
await page.getByRole('combobox').nth(1).click();
await page.getByLabel('Needs to approve').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Add a viewer
await page.getByPlaceholder('Email').nth(2).fill('recipient@documenso.com');
await page.getByPlaceholder('Name').nth(2).fill('Documenso Recipient');
await page.getByRole('combobox').nth(2).click();
await page.getByLabel('Needs to view').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Add a CC
await page.getByPlaceholder('Email').nth(3).fill('recipient@documenso.com');
await page.getByPlaceholder('Name').nth(3).fill('Documenso Recipient');
await page.getByRole('combobox').nth(3).click();
await page.getByLabel('Receives copy').click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
});
test('[TEMPLATE_FLOW]: mixed recipients', async ({ page }) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// First recipient (unique)
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('First Recipient');
await page.getByRole('combobox').click();
await page.getByLabel('Needs to approve').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Second recipient (duplicate of first)
await page.getByPlaceholder('Email').nth(1).fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('First Recipient');
await page.getByRole('combobox').nth(1).click();
await page.getByLabel('Needs to view').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Third recipient (unique)
await page.getByPlaceholder('Email').nth(2).fill('recipient2@documenso.com');
await page.getByPlaceholder('Name').nth(2).fill('Second Recipient');
await page.getByRole('combobox').nth(2).click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Fourth recipient (duplicate of first)
await page.getByPlaceholder('Email').nth(3).fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').nth(3).fill('First Recipient');
await page.getByRole('combobox').nth(3).click();
await page.getByLabel('Receives copy').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Fifth recipient (unique)
await page.getByPlaceholder('Email').nth(4).fill('recipient3@documenso.com');
await page.getByPlaceholder('Name').nth(4).fill('Third Recipient');
await page.getByRole('combobox').nth(4).click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
});

View File

@@ -110,14 +110,13 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
await page.getByRole('button', { name: 'Save template' }).click(); await page.getByRole('button', { name: 'Save template' }).click();
// Use template // Use template
await page.waitForURL('/templates'); await page.waitForURL('**/templates');
await page.getByRole('button', { name: 'Use Template' }).click(); await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create as draft' }).click(); await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the correct values. // Review that the document was created with the correct values.
await page.waitForURL(/documents/); await page.waitForURL(/\/documents\/\d+/);
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({ const document = await prisma.document.findFirstOrThrow({
where: { where: {
@@ -250,9 +249,8 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
await page.getByRole('button', { name: 'Create as draft' }).click(); await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the correct values. // Review that the document was created with the correct values.
await page.waitForURL(/documents/); await page.waitForURL(/\/documents\/\d+/);
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({ const document = await prisma.document.findFirstOrThrow({
where: { where: {
@@ -353,9 +351,8 @@ test('[TEMPLATE]: should create a document from a template with custom document'
await page.getByRole('button', { name: 'Create as draft' }).click(); await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the custom document data // Review that the document was created with the custom document data
await page.waitForURL(/documents/); await page.waitForURL(/\/documents\/\d+/);
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({ const document = await prisma.document.findFirstOrThrow({
where: { where: {
@@ -434,9 +431,8 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
await page.getByRole('button', { name: 'Create as draft' }).click(); await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the custom document data // Review that the document was created with the custom document data
await page.waitForURL(/documents/); await page.waitForURL(/\/documents\/\d+/);
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({ const document = await prisma.document.findFirstOrThrow({
where: { where: {
@@ -500,9 +496,8 @@ test('[TEMPLATE]: should create a document from a template using template docume
await page.getByRole('button', { name: 'Create as draft' }).click(); await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the template's document data // Review that the document was created with the template's document data
await page.waitForURL(/documents/); await page.waitForURL(/\/documents\/\d+/);
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({ const document = await prisma.document.findFirstOrThrow({
where: { where: {
@@ -591,9 +586,8 @@ test('[TEMPLATE]: should persist document visibility when creating from template
await page.getByRole('button', { name: 'Create as draft' }).click(); await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the correct visibility // Review that the document was created with the correct visibility
await page.waitForURL(/documents/); await page.waitForURL(/\/documents\/\d+/);
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({ const document = await prisma.document.findFirstOrThrow({
where: { where: {
@@ -616,3 +610,368 @@ test('[TEMPLATE]: should persist document visibility when creating from template
// Template should not be visible to regular member // Template should not be visible to regular member
await expect(page.getByRole('button', { name: 'Use Template' })).not.toBeVisible(); await expect(page.getByRole('button', { name: 'Use Template' })).not.toBeVisible();
}); });
/**
* This test verifies that we can create a document from a template with duplicate recipients
**/
test('[TEMPLATE]: should create a document from a template with duplicate recipients', async ({
page,
}) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
const isBillingEnabled =
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' && enterprisePriceId;
await seedUserSubscription({
userId: user.id,
priceId: enterprisePriceId,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
// Set template title.
await page.getByLabel('Title').fill('TEMPLATE_TITLE');
// Set template document access.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Set EE action auth.
if (isBillingEnabled) {
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
}
// Set email options.
await page.getByRole('button', { name: 'Email Options' }).click();
await page.getByLabel('Subject (Optional)').fill('SUBJECT');
await page.getByLabel('Message (Optional)').fill('MESSAGE');
// Set advanced options.
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
await page.getByLabel('DD/MM/YYYY').click();
await page.locator('.time-zone-field').click();
await page.getByRole('option', { name: 'Etc/UTC' }).click();
await page.getByLabel('Redirect URL').fill('https://documenso.com');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(1).fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient 1');
// Apply require passkey for Recipient 1.
if (isBillingEnabled) {
await page.getByLabel('Show advanced settings').check();
await page.getByRole('combobox').first().click();
await page.getByLabel('Require passkey').click();
}
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Add signature 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').click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).nth(1).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 200,
y: 100,
},
});
await page.getByRole('button', { name: 'Save template' }).click();
// Use template
await page.waitForURL('**/templates');
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the correct values.
await page.waitForURL(/\/documents\/\d+/);
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
recipients: true,
documentMeta: true,
},
});
const documentAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
expect(document.title).toEqual('TEMPLATE_TITLE');
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
isBillingEnabled ? 'PASSKEY' : null,
);
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(document.documentMeta?.message).toEqual('MESSAGE');
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
expect(document.documentMeta?.subject).toEqual('SUBJECT');
expect(document.documentMeta?.timezone).toEqual('Etc/UTC');
const recipientOne = document.recipients[0];
const recipientTwo = document.recipients[1];
const recipientOneAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipientOne.authOptions,
});
const recipientTwoAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipientTwo.authOptions,
});
if (isBillingEnabled) {
expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
}
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
await page.getByRole('link', { name: 'Edit' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await expect(page.getByText('SignatureRE').first()).toBeVisible();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'recipient1@documenso.com' }).nth(1).click();
await expect(page.getByText('SignatureRE').nth(1)).toBeVisible();
});
/**
* This test verifies that we can create a document from a template with a mix of duplicate and unique recipients
**/
test('[TEMPLATE]: should create a document from a template with mixed duplicate and unique recipients', async ({
page,
}) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
const isBillingEnabled =
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' && enterprisePriceId;
await seedUserSubscription({
userId: user.id,
priceId: enterprisePriceId,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
// Set template title.
await page.getByLabel('Title').fill('TEMPLATE_MIXED_RECIPIENTS');
// Set template document access.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Set EE action auth.
if (isBillingEnabled) {
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
}
// Set email options.
await page.getByRole('button', { name: 'Email Options' }).click();
await page.getByLabel('Subject (Optional)').fill('SUBJECT');
await page.getByLabel('Message (Optional)').fill('MESSAGE');
// Set advanced options.
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
await page.getByLabel('DD/MM/YYYY').click();
await page.locator('.time-zone-field').click();
await page.getByRole('option', { name: 'Etc/UTC' }).click();
await page.getByLabel('Redirect URL').fill('https://documenso.com');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add 4 signers: 2 duplicates of recipient1 and 2 unique recipients
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(2).fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').nth(2).fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(3).fill('recipient3@documenso.com');
await page.getByPlaceholder('Name').nth(3).fill('Recipient 3');
// Apply require passkey for first instance of Recipient 1
if (isBillingEnabled) {
await page.getByLabel('Show advanced settings').check();
await page.getByRole('combobox').first().click();
await page.getByLabel('Require passkey').click();
}
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Add signature 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').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 200,
y: 100,
},
});
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).nth(1).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 300,
y: 100,
},
});
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 3 (recipient3@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 400,
y: 100,
},
});
await page.getByRole('button', { name: 'Save template' }).click();
// Use template
await page.waitForURL('**/templates');
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the correct values.
await page.waitForURL(/\/documents\/\d+/);
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
recipients: {
orderBy: {
email: 'asc',
},
},
documentMeta: true,
},
});
const documentAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
expect(document.title).toEqual('TEMPLATE_MIXED_RECIPIENTS');
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
isBillingEnabled ? 'PASSKEY' : null,
);
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(document.documentMeta?.message).toEqual('MESSAGE');
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
expect(document.documentMeta?.subject).toEqual('SUBJECT');
expect(document.documentMeta?.timezone).toEqual('Etc/UTC');
// Check auth settings for first instance of recipient1
const firstRecipientOne = document.recipients[0];
const firstRecipientOneAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: firstRecipientOne.authOptions,
});
if (isBillingEnabled) {
expect(firstRecipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
}
expect(firstRecipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
await page.getByRole('link', { name: 'Edit' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await expect(page.getByText('SignatureRE').first()).toBeVisible();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'recipient2@documenso.com' }).click();
await expect(page.getByText('SignatureRE').nth(1)).toBeVisible();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'recipient1@documenso.com' }).nth(1).click();
await expect(page.getByText('SignatureRE').nth(2)).toBeVisible();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'recipient3@documenso.com' }).click();
await expect(page.getByText('SignatureRE').nth(3)).toBeVisible();
});

View File

@@ -72,6 +72,22 @@ export const setFieldsForDocument = async ({
}); });
} }
// Check that every signer has a signature field
const signers = document.recipients.filter((recipient) => recipient.role === 'SIGNER');
const hasEverySignerSignature = signers.every((signer) =>
fields.some(
(field) =>
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
field.recipientId === signer.id,
),
);
if (!hasEverySignerSignature) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Every signer must have at least one signature field',
});
}
if (document.completedAt) { if (document.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete', message: 'Document already complete',
@@ -94,9 +110,7 @@ export const setFieldsForDocument = async ({
const linkedFields = fields.map((field) => { const linkedFields = fields.map((field) => {
const existing = existingFields.find((existingField) => existingField.id === field.id); const existing = existingFields.find((existingField) => existingField.id === field.id);
const recipient = document.recipients.find( const recipient = document.recipients.find((recipient) => recipient.id === field.recipientId);
(recipient) => recipient.email.toLowerCase() === field.signerEmail.toLowerCase(),
);
// Each field MUST have a recipient associated with it. // Each field MUST have a recipient associated with it.
if (!recipient) { if (!recipient) {
@@ -236,10 +250,8 @@ export const setFieldsForDocument = async ({
}, },
recipient: { recipient: {
connect: { connect: {
documentId_email: { documentId,
documentId, id: field.recipientId,
email: fieldSignerEmail,
},
}, },
}, },
}, },
@@ -340,6 +352,7 @@ type FieldData = {
id?: number | null; id?: number | null;
type: FieldType; type: FieldType;
signerEmail: string; signerEmail: string;
recipientId: number;
pageNumber: number; pageNumber: number;
pageX: number; pageX: number;
pageY: number; pageY: number;

View File

@@ -22,6 +22,7 @@ export type SetFieldsForTemplateOptions = {
fields: { fields: {
id?: number | null; id?: number | null;
type: FieldType; type: FieldType;
signerId: number;
signerEmail: string; signerEmail: string;
pageNumber: number; pageNumber: number;
pageX: number; pageX: number;
@@ -57,12 +58,29 @@ export const setFieldsForTemplate = async ({
teamId: null, teamId: null,
}), }),
}, },
include: {
recipients: true,
},
}); });
if (!template) { if (!template) {
throw new Error('Template not found'); throw new Error('Template not found');
} }
// Check that every signer has a signature field
const signers = template.recipients.filter((recipient) => recipient.role === 'SIGNER');
const hasEverySignerSignature = signers.every((signer) =>
fields.some(
(field) =>
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
field.signerId === signer.id,
),
);
if (!hasEverySignerSignature) {
throw new Error('Every signer must have at least one signature field');
}
const existingFields = await prisma.field.findMany({ const existingFields = await prisma.field.findMany({
where: { where: {
templateId, templateId,
@@ -180,10 +198,8 @@ export const setFieldsForTemplate = async ({
}, },
recipient: { recipient: {
connect: { connect: {
templateId_email: { templateId,
templateId, id: field.signerId,
email: field.signerEmail.toLowerCase(),
},
}, },
}, },
}, },

View File

@@ -125,16 +125,12 @@ export const setDocumentRecipients = async ({
const removedRecipients = existingRecipients.filter( const removedRecipients = existingRecipients.filter(
(existingRecipient) => (existingRecipient) =>
!normalizedRecipients.find( !normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
(recipient) =>
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
),
); );
const linkedRecipients = normalizedRecipients.map((recipient) => { const linkedRecipients = normalizedRecipients.map((recipient) => {
const existing = existingRecipients.find( const existing = existingRecipients.find(
(existingRecipient) => (existingRecipient) => existingRecipient.id === recipient.id,
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
); );
if ( if (

View File

@@ -103,10 +103,7 @@ export const setTemplateRecipients = async ({
const removedRecipients = existingRecipients.filter( const removedRecipients = existingRecipients.filter(
(existingRecipient) => (existingRecipient) =>
!normalizedRecipients.find( !normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
(recipient) =>
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
),
); );
if (template.directLink !== null) { if (template.directLink !== null) {
@@ -133,14 +130,10 @@ export const setTemplateRecipients = async ({
const linkedRecipients = normalizedRecipients.map((recipient) => { const linkedRecipients = normalizedRecipients.map((recipient) => {
const existing = existingRecipients.find( const existing = existingRecipients.find(
(existingRecipient) => (existingRecipient) => existingRecipient.id === recipient.id,
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
); );
return { return { ...recipient, _persisted: existing };
...recipient,
_persisted: existing,
};
}); });
const persistedRecipients = await prisma.$transaction(async (tx) => { const persistedRecipients = await prisma.$transaction(async (tx) => {

View File

@@ -141,10 +141,8 @@ export const createDocumentFromTemplateLegacy = async ({
return await prisma.recipient.upsert({ return await prisma.recipient.upsert({
where: { where: {
documentId_email: { documentId: document.id,
documentId: document.id, id: existingRecipient?.id,
email: existingRecipient?.email ?? recipient.email,
},
}, },
update: { update: {
name: recipient.name, name: recipient.name,

View File

@@ -256,10 +256,21 @@ export const createDocumentFromTemplate = async ({
}, },
}); });
const recipientMapping = new Map<number, number>();
template.recipients.forEach((templateRecipient, index) => {
const documentRecipient = document.recipients[index];
if (documentRecipient) {
recipientMapping.set(templateRecipient.id, documentRecipient.id);
}
});
let fieldsToCreate: Omit<Field, 'id' | 'secondaryId' | 'templateId'>[] = []; let fieldsToCreate: Omit<Field, 'id' | 'secondaryId' | 'templateId'>[] = [];
Object.values(finalRecipients).forEach(({ email, fields }) => { finalRecipients.forEach(({ templateRecipientId, fields }) => {
const recipient = document.recipients.find((recipient) => recipient.email === email); const documentRecipientId = recipientMapping.get(templateRecipientId);
const recipient = document.recipients.find((r) => r.id === documentRecipientId);
if (!recipient) { if (!recipient) {
throw new Error('Recipient not found.'); throw new Error('Recipient not found.');

View File

@@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "Recipient_documentId_email_key";

View File

@@ -0,0 +1,5 @@
-- DropIndex
DROP INDEX "Recipient_templateId_email_key";
-- CreateIndex
CREATE INDEX "Recipient_email_idx" ON "Recipient"("email");

View File

@@ -443,11 +443,10 @@ model Recipient {
fields Field[] fields Field[]
signatures Signature[] signatures Signature[]
@@unique([documentId, email])
@@unique([templateId, email])
@@index([documentId]) @@index([documentId])
@@index([templateId]) @@index([templateId])
@@index([token]) @@index([token])
@@index([email])
} }
enum FieldType { enum FieldType {

View File

@@ -5,6 +5,18 @@ import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { prisma } from '..'; import { prisma } from '..';
import { DocumentDataType, DocumentSource, Role, TeamMemberRole } from '../client'; import { DocumentDataType, DocumentSource, Role, TeamMemberRole } from '../client';
import { seedPendingDocument } from './documents';
import { seedDirectTemplate, seedTemplate } from './templates';
const createDocumentData = async ({ documentData }: { documentData: string }) => {
return prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: documentData,
initialData: documentData,
},
});
};
export const seedDatabase = async () => { export const seedDatabase = async () => {
const examplePdf = fs const examplePdf = fs
@@ -39,35 +51,80 @@ export const seedDatabase = async () => {
update: {}, update: {},
}); });
const examplePdfData = await prisma.documentData.upsert({ for (let i = 1; i <= 4; i++) {
where: { const documentData = await createDocumentData({ documentData: examplePdf });
id: 'clmn0kv5k0000pe04vcqg5zla',
},
create: {
id: 'clmn0kv5k0000pe04vcqg5zla',
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
update: {},
});
await prisma.document.create({ await prisma.document.create({
data: { data: {
source: DocumentSource.DOCUMENT, source: DocumentSource.DOCUMENT,
title: 'Example Document', title: `Example Document ${i}`,
documentDataId: examplePdfData.id, documentDataId: documentData.id,
userId: exampleUser.id, userId: exampleUser.id,
recipients: { recipients: {
create: { create: {
name: String(adminUser.name), name: String(adminUser.name),
email: adminUser.email, email: adminUser.email,
token: Math.random().toString(36).slice(2, 9), token: Math.random().toString(36).slice(2, 9),
},
}, },
}, },
});
}
for (let i = 1; i <= 4; i++) {
const documentData = await createDocumentData({ documentData: examplePdf });
await prisma.document.create({
data: {
source: DocumentSource.DOCUMENT,
title: `Document ${i}`,
documentDataId: documentData.id,
userId: adminUser.id,
recipients: {
create: {
name: String(exampleUser.name),
email: exampleUser.email,
token: Math.random().toString(36).slice(2, 9),
},
},
},
});
}
await seedPendingDocument(exampleUser, [adminUser], {
key: 'example-pending',
createDocumentOptions: {
title: 'Pending Document',
}, },
}); });
await seedPendingDocument(adminUser, [exampleUser], {
key: 'admin-pending',
createDocumentOptions: {
title: 'Pending Document',
},
});
await Promise.all([
seedTemplate({
title: 'Template 1',
userId: exampleUser.id,
}),
seedDirectTemplate({
title: 'Direct Template 1',
userId: exampleUser.id,
}),
seedTemplate({
title: 'Template 1',
userId: adminUser.id,
}),
seedDirectTemplate({
title: 'Direct Template 1',
userId: adminUser.id,
}),
]);
const testUsers = [ const testUsers = [
'test@documenso.com', 'test@documenso.com',
'test2@documenso.com', 'test2@documenso.com',

View File

@@ -192,14 +192,6 @@ export const ZCreateDocumentV2RequestSchema = z.object({
.optional(), .optional(),
}), }),
) )
.refine(
(recipients) => {
const emails = recipients.map((recipient) => recipient.email);
return new Set(emails).size === emails.length;
},
{ message: 'Recipients must have unique emails' },
)
.optional(), .optional(),
meta: z meta: z
.object({ .object({

View File

@@ -232,8 +232,9 @@ export const fieldRouter = router({
teamId, teamId,
fields: fields.map((field) => ({ fields: fields.map((field) => ({
id: field.nativeId, id: field.nativeId,
signerEmail: field.signerEmail, recipientId: field.recipientId,
type: field.type, type: field.type,
signerEmail: field.signerEmail,
pageNumber: field.pageNumber, pageNumber: field.pageNumber,
pageX: field.pageX, pageX: field.pageX,
pageY: field.pageY, pageY: field.pageY,
@@ -429,6 +430,7 @@ export const fieldRouter = router({
teamId, teamId,
fields: fields.map((field) => ({ fields: fields.map((field) => ({
id: field.nativeId, id: field.nativeId,
signerId: field.recipientId,
signerEmail: field.signerEmail, signerEmail: field.signerEmail,
type: field.type, type: field.type,
pageNumber: field.pageNumber, pageNumber: field.pageNumber,

View File

@@ -112,13 +112,15 @@ export const ZSetDocumentFieldsRequestSchema = z.object({
z.object({ z.object({
formId: z.string().min(1), formId: z.string().min(1),
nativeId: z.number().optional(), nativeId: z.number().optional(),
id: z.number().optional(),
type: z.nativeEnum(FieldType), type: z.nativeEnum(FieldType),
recipientId: z.number(),
signerEmail: z.string().min(1), signerEmail: z.string().min(1),
pageNumber: z.number().min(1), pageNumber: ZFieldPageNumberSchema,
pageX: z.number().min(0), pageX: ZFieldPageXSchema,
pageY: z.number().min(0), pageY: ZFieldPageYSchema,
pageWidth: z.number().min(0), pageWidth: ZFieldWidthSchema,
pageHeight: z.number().min(0), pageHeight: ZFieldHeightSchema,
fieldMeta: ZFieldMetaSchema, fieldMeta: ZFieldMetaSchema,
}), }),
), ),
@@ -136,6 +138,8 @@ export const ZSetFieldsForTemplateRequestSchema = z.object({
nativeId: z.number().optional(), nativeId: z.number().optional(),
type: z.nativeEnum(FieldType), type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1), signerEmail: z.string().min(1),
recipientId: z.number().min(1),
signerId: z.number().min(1),
pageNumber: z.number().min(1), pageNumber: z.number().min(1),
pageX: z.number().min(0), pageX: z.number().min(0),
pageY: z.number().min(0), pageY: z.number().min(0),

View File

@@ -49,16 +49,7 @@ export const ZCreateDocumentRecipientResponseSchema = ZRecipientLiteSchema;
export const ZCreateDocumentRecipientsRequestSchema = z.object({ export const ZCreateDocumentRecipientsRequestSchema = z.object({
documentId: z.number(), documentId: z.number(),
recipients: z.array(ZCreateRecipientSchema).refine( recipients: z.array(ZCreateRecipientSchema),
(recipients) => {
const emails = recipients.map((recipient) => recipient.email.toLowerCase());
return new Set(emails).size === emails.length;
},
{
message: 'Recipients must have unique emails',
},
),
}); });
export const ZCreateDocumentRecipientsResponseSchema = z.object({ export const ZCreateDocumentRecipientsResponseSchema = z.object({
@@ -74,18 +65,7 @@ export const ZUpdateDocumentRecipientResponseSchema = ZRecipientSchema;
export const ZUpdateDocumentRecipientsRequestSchema = z.object({ export const ZUpdateDocumentRecipientsRequestSchema = z.object({
documentId: z.number(), documentId: z.number(),
recipients: z.array(ZUpdateRecipientSchema).refine( recipients: z.array(ZUpdateRecipientSchema),
(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',
},
),
}); });
export const ZUpdateDocumentRecipientsResponseSchema = z.object({ export const ZUpdateDocumentRecipientsResponseSchema = z.object({
@@ -96,29 +76,19 @@ export const ZDeleteDocumentRecipientRequestSchema = z.object({
recipientId: z.number(), recipientId: z.number(),
}); });
export const ZSetDocumentRecipientsRequestSchema = z export const ZSetDocumentRecipientsRequestSchema = z.object({
.object({ documentId: z.number(),
documentId: z.number(), recipients: z.array(
recipients: z.array( z.object({
z.object({ nativeId: z.number().optional(),
nativeId: z.number().optional(), email: z.string().toLowerCase().email().min(1),
email: z.string().toLowerCase().email().min(1), name: z.string(),
name: z.string(), role: z.nativeEnum(RecipientRole),
role: z.nativeEnum(RecipientRole), signingOrder: z.number().optional(),
signingOrder: z.number().optional(), actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(), }),
}), ),
), });
})
.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 ZSetDocumentRecipientsResponseSchema = z.object({ export const ZSetDocumentRecipientsResponseSchema = z.object({
recipients: ZRecipientLiteSchema.array(), recipients: ZRecipientLiteSchema.array(),
@@ -133,16 +103,7 @@ export const ZCreateTemplateRecipientResponseSchema = ZRecipientLiteSchema;
export const ZCreateTemplateRecipientsRequestSchema = z.object({ export const ZCreateTemplateRecipientsRequestSchema = z.object({
templateId: z.number(), templateId: z.number(),
recipients: z.array(ZCreateRecipientSchema).refine( recipients: z.array(ZCreateRecipientSchema),
(recipients) => {
const emails = recipients.map((recipient) => recipient.email);
return new Set(emails).size === emails.length;
},
{
message: 'Recipients must have unique emails',
},
),
}); });
export const ZCreateTemplateRecipientsResponseSchema = z.object({ export const ZCreateTemplateRecipientsResponseSchema = z.object({
@@ -158,18 +119,7 @@ export const ZUpdateTemplateRecipientResponseSchema = ZRecipientSchema;
export const ZUpdateTemplateRecipientsRequestSchema = z.object({ export const ZUpdateTemplateRecipientsRequestSchema = z.object({
templateId: z.number(), templateId: z.number(),
recipients: z.array(ZUpdateRecipientSchema).refine( recipients: z.array(ZUpdateRecipientSchema),
(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',
},
),
}); });
export const ZUpdateTemplateRecipientsResponseSchema = z.object({ export const ZUpdateTemplateRecipientsResponseSchema = z.object({
@@ -180,29 +130,19 @@ export const ZDeleteTemplateRecipientRequestSchema = z.object({
recipientId: z.number(), recipientId: z.number(),
}); });
export const ZSetTemplateRecipientsRequestSchema = z export const ZSetTemplateRecipientsRequestSchema = z.object({
.object({ templateId: z.number(),
templateId: z.number(), recipients: z.array(
recipients: z.array( z.object({
z.object({ nativeId: z.number().optional(),
nativeId: z.number().optional(), email: z.string().toLowerCase().email().min(1),
email: z.string().toLowerCase().email().min(1), name: z.string(),
name: z.string(), role: z.nativeEnum(RecipientRole),
role: z.nativeEnum(RecipientRole), signingOrder: z.number().optional(),
signingOrder: z.number().optional(), actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(), }),
}), ),
), });
})
.refine(
(schema) => {
const emails = schema.recipients.map((recipient) => recipient.email);
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 ZSetTemplateRecipientsResponseSchema = z.object({ export const ZSetTemplateRecipientsResponseSchema = z.object({
recipients: ZRecipientLiteSchema.array(), recipients: ZRecipientLiteSchema.array(),

View File

@@ -51,12 +51,7 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
name: z.string().optional(), name: z.string().optional(),
}), }),
) )
.describe('The information of the recipients to create the document with.') .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'),
distributeDocument: z distributeDocument: z
.boolean() .boolean()
.describe('Whether to create the document as pending and distribute it to recipients.') .describe('Whether to create the document as pending and distribute it to recipients.')

View File

@@ -92,6 +92,7 @@ export type FieldFormType = {
pageY: number; pageY: number;
pageWidth: number; pageWidth: number;
pageHeight: number; pageHeight: number;
recipientId: number;
signerEmail: string; signerEmail: string;
fieldMeta?: FieldMeta; fieldMeta?: FieldMeta;
}; };
@@ -143,6 +144,7 @@ export const AddFieldsFormPartial = ({
pageY: Number(field.positionY), pageY: Number(field.positionY),
pageWidth: Number(field.width), pageWidth: Number(field.width),
pageHeight: Number(field.height), pageHeight: Number(field.height),
recipientId: field.recipientId,
signerEmail: signerEmail:
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '', recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined, fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
@@ -348,6 +350,7 @@ export const AddFieldsFormPartial = ({
pageY, pageY,
pageWidth: fieldPageWidth, pageWidth: fieldPageWidth,
pageHeight: fieldPageHeight, pageHeight: fieldPageHeight,
recipientId: selectedSigner.id,
signerEmail: selectedSigner.email, signerEmail: selectedSigner.email,
fieldMeta: undefined, fieldMeta: undefined,
}; };
@@ -441,6 +444,7 @@ export const AddFieldsFormPartial = ({
const newField: TAddFieldsFormSchema['fields'][0] = { const newField: TAddFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField), ...structuredClone(lastActiveField),
formId: nanoid(12), formId: nanoid(12),
recipientId: selectedSigner?.id ?? lastActiveField.recipientId,
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail, signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
pageX: lastActiveField.pageX + 3, pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3, pageY: lastActiveField.pageY + 3,
@@ -449,7 +453,7 @@ export const AddFieldsFormPartial = ({
append(newField); append(newField);
} }
}, },
[append, lastActiveField, selectedSigner?.email, toast], [append, lastActiveField, selectedSigner?.id, selectedSigner?.email, toast],
); );
const onFieldPaste = useCallback( const onFieldPaste = useCallback(
@@ -462,13 +466,15 @@ export const AddFieldsFormPartial = ({
append({ append({
...copiedField, ...copiedField,
formId: nanoid(12), formId: nanoid(12),
recipientId: selectedSigner?.id ?? copiedField.recipientId,
signerEmail: selectedSigner?.email ?? copiedField.signerEmail, signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
pageX: copiedField.pageX + 3, pageX: copiedField.pageX + 3,
pageY: copiedField.pageY + 3, pageY: copiedField.pageY + 3,
}); });
} }
}, },
[append, fieldClipboard, selectedSigner?.email], [append, fieldClipboard, selectedSigner?.id, selectedSigner?.email],
); );
useEffect(() => { useEffect(() => {
@@ -567,7 +573,7 @@ export const AddFieldsFormPartial = ({
localFields.some( localFields.some(
(field) => (field) =>
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) && (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
field.signerEmail === signer.email, field.recipientId === signer.id,
), ),
); );
@@ -637,7 +643,7 @@ export const AddFieldsFormPartial = ({
{isDocumentPdfLoaded && {isDocumentPdfLoaded &&
localFields.map((field, index) => { localFields.map((field, index) => {
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail); const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
const hasFieldError = const hasFieldError =
emptyCheckboxFields.find((f) => f.formId === field.formId) || emptyCheckboxFields.find((f) => f.formId === field.formId) ||
emptyRadioFields.find((f) => f.formId === field.formId) || emptyRadioFields.find((f) => f.formId === field.formId) ||
@@ -649,7 +655,7 @@ export const AddFieldsFormPartial = ({
recipientIndex={recipientIndex === -1 ? 0 : recipientIndex} recipientIndex={recipientIndex === -1 ? 0 : recipientIndex}
field={field} field={field}
disabled={ disabled={
selectedSigner?.email !== field.signerEmail || selectedSigner?.id !== field.recipientId ||
!canRecipientBeModified(selectedSigner, fields) !canRecipientBeModified(selectedSigner, fields)
} }
minHeight={MIN_HEIGHT_PX} minHeight={MIN_HEIGHT_PX}

View File

@@ -10,6 +10,7 @@ export const ZAddFieldsFormSchema = z.object({
nativeId: z.number().optional(), nativeId: z.number().optional(),
type: z.nativeEnum(FieldType), type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1), signerEmail: z.string().min(1),
recipientId: z.number(),
pageNumber: z.number().min(1), pageNumber: z.number().min(1),
pageX: z.number().min(0), pageX: z.number().min(0),
pageY: z.number().min(0), pageY: z.number().min(0),

View File

@@ -6,34 +6,22 @@ import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-a
import { ZMapNegativeOneToUndefinedSchema } from './add-settings.types'; import { ZMapNegativeOneToUndefinedSchema } from './add-settings.types';
import { DocumentSigningOrder, RecipientRole } from '.prisma/client'; import { DocumentSigningOrder, RecipientRole } from '.prisma/client';
export const ZAddSignersFormSchema = z export const ZAddSignersFormSchema = z.object({
.object({ signers: z.array(
signers: z.array( z.object({
z.object({ formId: z.string().min(1),
formId: z.string().min(1), nativeId: z.number().optional(),
nativeId: z.number().optional(), email: z
email: z .string()
.string() .email({ message: msg`Invalid email`.id })
.email({ message: msg`Invalid email`.id }) .min(1),
.min(1), name: z.string(),
name: z.string(), role: z.nativeEnum(RecipientRole),
role: z.nativeEnum(RecipientRole), signingOrder: z.number().optional(),
signingOrder: z.number().optional(), actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(ZRecipientActionAuthTypesSchema.optional()),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe( }),
ZRecipientActionAuthTypesSchema.optional(), ),
), signingOrder: z.nativeEnum(DocumentSigningOrder),
}), });
),
signingOrder: z.nativeEnum(DocumentSigningOrder),
})
.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 type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>; export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;

View File

@@ -8,19 +8,14 @@ import { FieldType } from '@documenso/prisma/client';
export const ZDocumentFlowFormSchema = z.object({ export const ZDocumentFlowFormSchema = z.object({
title: z.string().min(1), title: z.string().min(1),
signers: z signers: z.array(
.array( z.object({
z.object({ formId: z.string().min(1),
formId: z.string().min(1), nativeId: z.number().optional(),
nativeId: z.number().optional(), email: z.string().min(1).email(),
email: z.string().min(1).email(), name: z.string(),
name: z.string(), }),
}), ),
)
.refine((signers) => {
const emails = signers.map((signer) => signer.email);
return new Set(emails).size === emails.length;
}, 'Signers must have unique emails'),
fields: z.array( fields: z.array(
z.object({ z.object({

View File

@@ -60,6 +60,7 @@ import { getSignerColorStyles, useSignerColors } from '../../lib/signer-colors';
import { Checkbox } from '../checkbox'; import { Checkbox } from '../checkbox';
import type { FieldFormType } from '../document-flow/add-fields'; import type { FieldFormType } from '../document-flow/add-fields';
import { FieldAdvancedSettings } from '../document-flow/field-item-advanced-settings'; import { FieldAdvancedSettings } from '../document-flow/field-item-advanced-settings';
import { MissingSignatureFieldDialog } from '../document-flow/missing-signature-field-dialog';
import { Form, FormControl, FormField, FormItem, FormLabel } from '../form/form'; import { Form, FormControl, FormField, FormItem, FormLabel } from '../form/form';
import { useStep } from '../stepper'; import { useStep } from '../stepper';
import type { TAddTemplateFieldsFormSchema } from './add-template-fields.types'; import type { TAddTemplateFieldsFormSchema } from './add-template-fields.types';
@@ -110,6 +111,7 @@ export const AddTemplateFieldsFormPartial = ({
const [fieldClipboard, setFieldClipboard] = useState< const [fieldClipboard, setFieldClipboard] = useState<
TAddTemplateFieldsFormSchema['fields'][0] | null TAddTemplateFieldsFormSchema['fields'][0] | null
>(null); >(null);
const [isMissingSignatureDialogVisible, setIsMissingSignatureDialogVisible] = useState(false);
const form = useForm<TAddTemplateFieldsFormSchema>({ const form = useForm<TAddTemplateFieldsFormSchema>({
defaultValues: { defaultValues: {
@@ -122,6 +124,7 @@ export const AddTemplateFieldsFormPartial = ({
pageY: Number(field.positionY), pageY: Number(field.positionY),
pageWidth: Number(field.width), pageWidth: Number(field.width),
pageHeight: Number(field.height), pageHeight: Number(field.height),
recipientId: field.recipientId ?? -1,
signerId: field.recipientId ?? -1, signerId: field.recipientId ?? -1,
signerEmail: signerEmail:
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '', recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
@@ -177,6 +180,8 @@ export const AddTemplateFieldsFormPartial = ({
formId: nanoid(12), formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail, signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
signerId: selectedSigner?.id ?? lastActiveField.signerId, signerId: selectedSigner?.id ?? lastActiveField.signerId,
recipientId:
selectedSigner?.id || lastActiveField.recipientId || lastActiveField.signerId || 0,
signerToken: selectedSigner?.token ?? lastActiveField.signerToken, signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
pageX: lastActiveField.pageX + 3, pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3, pageY: lastActiveField.pageY + 3,
@@ -201,19 +206,29 @@ export const AddTemplateFieldsFormPartial = ({
event.preventDefault(); event.preventDefault();
const copiedField = structuredClone(fieldClipboard); const copiedField = structuredClone(fieldClipboard);
const signerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id);
append({ append({
...copiedField, ...copiedField,
formId: nanoid(12), formId: nanoid(12),
signerEmail: selectedSigner?.email ?? copiedField.signerEmail, signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
signerId: selectedSigner?.id ?? copiedField.signerId, signerId: selectedSigner?.id ?? copiedField.signerId,
recipientId: selectedSigner?.id || copiedField.recipientId || copiedField.signerId || 0,
signerToken: selectedSigner?.token ?? copiedField.signerToken, signerToken: selectedSigner?.token ?? copiedField.signerToken,
signerIndex: signerIndex >= 0 ? signerIndex : 0,
pageX: copiedField.pageX + 3, pageX: copiedField.pageX + 3,
pageY: copiedField.pageY + 3, pageY: copiedField.pageY + 3,
}); });
} }
}, },
[append, fieldClipboard, selectedSigner?.email, selectedSigner?.id, selectedSigner?.token], [
append,
fieldClipboard,
selectedSigner?.email,
selectedSigner?.id,
selectedSigner?.token,
recipients,
],
); );
useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt)); useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt));
@@ -319,6 +334,8 @@ export const AddTemplateFieldsFormPartial = ({
pageX -= fieldPageWidth / 2; pageX -= fieldPageWidth / 2;
pageY -= fieldPageHeight / 2; pageY -= fieldPageHeight / 2;
const signerIndex = recipients.findIndex((r) => r.id === selectedSigner.id);
append({ append({
formId: nanoid(12), formId: nanoid(12),
type: selectedField, type: selectedField,
@@ -329,14 +346,17 @@ export const AddTemplateFieldsFormPartial = ({
pageHeight: fieldPageHeight, pageHeight: fieldPageHeight,
signerEmail: selectedSigner.email, signerEmail: selectedSigner.email,
signerId: selectedSigner.id, signerId: selectedSigner.id,
recipientId:
selectedSigner.id || lastActiveField?.recipientId || lastActiveField?.signerId || 0,
signerToken: selectedSigner.token ?? '', signerToken: selectedSigner.token ?? '',
signerIndex: signerIndex >= 0 ? signerIndex : 0,
fieldMeta: undefined, fieldMeta: undefined,
}); });
setIsFieldWithinBounds(false); setIsFieldWithinBounds(false);
setSelectedField(null); setSelectedField(null);
}, },
[append, isWithinPageBounds, selectedField, selectedSigner, getPage], [append, isWithinPageBounds, selectedField, selectedSigner, getPage, recipients],
); );
const onFieldResize = useCallback( const onFieldResize = useCallback(
@@ -499,6 +519,23 @@ export const AddTemplateFieldsFormPartial = ({
form.setValue('typedSignatureEnabled', value, { shouldDirty: true }); form.setValue('typedSignatureEnabled', value, { shouldDirty: true });
}; };
const handleGoNextClick = () => {
const everySignerHasSignature = recipientsByRole.SIGNER.every((signer) =>
localFields.some(
(field) =>
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
field.recipientId === signer.id,
),
);
if (!everySignerHasSignature) {
setIsMissingSignatureDialogVisible(true);
return;
}
void onFormSubmit();
};
return ( return (
<> <>
{showAdvancedSettings && currentField ? ( {showAdvancedSettings && currentField ? (
@@ -546,14 +583,15 @@ export const AddTemplateFieldsFormPartial = ({
)} )}
{localFields.map((field, index) => { {localFields.map((field, index) => {
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail); const recipientIndex =
field.signerIndex ?? recipients.findIndex((r) => r.id === field.signerId);
return ( return (
<FieldItem <FieldItem
key={index} key={index}
recipientIndex={recipientIndex === -1 ? 0 : recipientIndex} recipientIndex={recipientIndex >= 0 ? recipientIndex : 0}
field={field} field={field}
disabled={selectedSigner?.email !== field.signerEmail} disabled={selectedSigner?.id !== field.signerId}
minHeight={MIN_HEIGHT_PX} minHeight={MIN_HEIGHT_PX}
minWidth={MIN_WIDTH_PX} minWidth={MIN_WIDTH_PX}
defaultHeight={DEFAULT_HEIGHT_PX} defaultHeight={DEFAULT_HEIGHT_PX}
@@ -993,9 +1031,14 @@ export const AddTemplateFieldsFormPartial = ({
previousStep(); previousStep();
remove(); remove();
}} }}
onGoNextClick={() => void onFormSubmit()} onGoNextClick={handleGoNextClick}
/> />
</DocumentFlowFormContainerFooter> </DocumentFlowFormContainerFooter>
<MissingSignatureFieldDialog
isOpen={isMissingSignatureDialogVisible}
onOpenChange={(value) => setIsMissingSignatureDialogVisible(value)}
/>
</div> </div>
</DocumentFlowFormContainerContent> </DocumentFlowFormContainerContent>
</> </>

View File

@@ -11,12 +11,14 @@ export const ZAddTemplateFieldsFormSchema = z.object({
type: z.nativeEnum(FieldType), type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1), signerEmail: z.string().min(1),
signerToken: z.string(), signerToken: z.string(),
signerId: z.number().optional(), signerId: z.number(),
recipientId: z.number(),
pageNumber: z.number().min(1), pageNumber: z.number().min(1),
pageX: z.number().min(0), pageX: z.number().min(0),
pageY: z.number().min(0), pageY: z.number().min(0),
pageWidth: z.number().min(0), pageWidth: z.number().min(0),
pageHeight: z.number().min(0), pageHeight: z.number().min(0),
signerIndex: z.number().min(0),
fieldMeta: ZFieldMetaSchema, fieldMeta: ZFieldMetaSchema,
}), }),
), ),

View File

@@ -92,6 +92,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
actionAuth: undefined, actionAuth: undefined,
...generateRecipientPlaceholder(1), ...generateRecipientPlaceholder(1),
signingOrder: 1, signingOrder: 1,
signerIndex: 0,
}, },
]; ];
} }
@@ -104,6 +105,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
role: recipient.role, role: recipient.role,
actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined, actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
signingOrder: recipient.signingOrder ?? index + 1, signingOrder: recipient.signingOrder ?? index + 1,
signerIndex: index,
})); }));
if (signingOrder === DocumentSigningOrder.SEQUENTIAL) { if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
@@ -174,21 +176,35 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
}); });
const onAddPlaceholderSelfRecipient = () => { const onAddPlaceholderSelfRecipient = () => {
const currentSigners = form.getValues('signers');
const nextSignerIndex = currentSigners.length;
appendSigner({ appendSigner({
formId: nanoid(12), formId: nanoid(12),
name: user?.name ?? '', name: user?.name ?? '',
email: user?.email ?? '', email: user?.email ?? '',
role: RecipientRole.SIGNER, role: RecipientRole.SIGNER,
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1, signingOrder:
currentSigners.length > 0
? (currentSigners[currentSigners.length - 1]?.signingOrder ?? 0) + 1
: 1,
signerIndex: nextSignerIndex,
}); });
}; };
const onAddPlaceholderRecipient = () => { const onAddPlaceholderRecipient = () => {
const currentSigners = form.getValues('signers');
const nextSignerIndex = currentSigners.length;
appendSigner({ appendSigner({
formId: nanoid(12), formId: nanoid(12),
role: RecipientRole.SIGNER, role: RecipientRole.SIGNER,
...generateRecipientPlaceholder(placeholderRecipientCount), ...generateRecipientPlaceholder(placeholderRecipientCount),
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1, signingOrder:
currentSigners.length > 0
? (currentSigners[currentSigners.length - 1]?.signingOrder ?? 0) + 1
: 1,
signerIndex: nextSignerIndex,
}); });
setPlaceholderRecipientCount((count) => count + 1); setPlaceholderRecipientCount((count) => count + 1);

View File

@@ -5,32 +5,21 @@ import { DocumentSigningOrder, RecipientRole } from '@documenso/prisma/client';
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types'; import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
export const ZAddTemplatePlacholderRecipientsFormSchema = z export const ZAddTemplatePlacholderRecipientsFormSchema = z.object({
.object({ signers: z.array(
signers: z.array( z.object({
z.object({ formId: z.string().min(1),
formId: z.string().min(1), nativeId: z.number().optional(),
nativeId: z.number().optional(), email: z.string().min(1).email(),
email: z.string().min(1).email(), name: z.string(),
name: z.string(), role: z.nativeEnum(RecipientRole),
role: z.nativeEnum(RecipientRole), signingOrder: z.number().optional(),
signingOrder: z.number().optional(), signerIndex: z.number().min(0),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe( actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(ZRecipientActionAuthTypesSchema.optional()),
ZRecipientActionAuthTypesSchema.optional(), }),
), ),
}), signingOrder: z.nativeEnum(DocumentSigningOrder),
), });
signingOrder: z.nativeEnum(DocumentSigningOrder),
})
.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: 'Signers must have unique emails', path: ['signers__root'] },
);
export type TAddTemplatePlacholderRecipientsFormSchema = z.infer< export type TAddTemplatePlacholderRecipientsFormSchema = z.infer<
typeof ZAddTemplatePlacholderRecipientsFormSchema typeof ZAddTemplatePlacholderRecipientsFormSchema