mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 18:04:55 +10:00
feat: add embedded envelopes (#2564)
## Description Add envelopes V2 embedded support --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,442 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, EnvelopeType, FieldType, RecipientRole } from '@prisma/client';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
|
||||
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
|
||||
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import {
|
||||
clickAddMyselfButton,
|
||||
clickEnvelopeEditorStep,
|
||||
getRecipientEmailInputs,
|
||||
openDocumentEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const V2_API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
|
||||
|
||||
/**
|
||||
* Place a field on the PDF canvas in the envelope editor.
|
||||
*/
|
||||
const placeFieldOnPdf = async (
|
||||
root: Page,
|
||||
fieldName: 'Signature' | 'Text',
|
||||
position: { x: number; y: number },
|
||||
) => {
|
||||
await root.getByRole('button', { name: fieldName, exact: true }).click();
|
||||
|
||||
const canvas = root.locator('.konva-container canvas').first();
|
||||
await expect(canvas).toBeVisible();
|
||||
await canvas.click({ position });
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a PENDING envelope via the V2 API with a single SIGNER recipient
|
||||
* and a SIGNATURE field, then distribute it.
|
||||
*
|
||||
* Returns the envelope ID, recipient email, team URL, and user info for navigation.
|
||||
*/
|
||||
const createPendingEnvelopeViaApi = async () => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'e2e-resend-test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const recipientEmail = `resend-${Date.now()}@test.documenso.com`;
|
||||
|
||||
// 1. Create envelope with a PDF.
|
||||
const payload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: `E2E Resend Test ${Date.now()}`,
|
||||
} satisfies TCreateEnvelopePayload;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append(
|
||||
'files',
|
||||
new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }),
|
||||
);
|
||||
|
||||
const createRes = await fetch(`${V2_API_BASE_URL}/envelope/create`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
expect(createRes.ok).toBeTruthy();
|
||||
const createResponse = (await createRes.json()) as TCreateEnvelopeResponse;
|
||||
|
||||
// 2. Create a SIGNER recipient.
|
||||
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
|
||||
envelopeId: createResponse.id,
|
||||
data: [
|
||||
{
|
||||
email: recipientEmail,
|
||||
name: 'Resend Test Signer',
|
||||
role: RecipientRole.SIGNER,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const recipientsRes = await fetch(`${V2_API_BASE_URL}/envelope/recipient/create-many`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(createRecipientsRequest),
|
||||
});
|
||||
|
||||
expect(recipientsRes.ok).toBeTruthy();
|
||||
const recipientsResponse = await recipientsRes.json();
|
||||
const recipients = recipientsResponse.data;
|
||||
|
||||
// 3. Get envelope to find the envelope item ID.
|
||||
const getRes = await fetch(`${V2_API_BASE_URL}/envelope/${createResponse.id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
const envelope = (await getRes.json()) as TGetEnvelopeResponse;
|
||||
const envelopeItem = envelope.envelopeItems[0];
|
||||
|
||||
// 4. Create a SIGNATURE field for the recipient.
|
||||
const createFieldsRequest = {
|
||||
envelopeId: createResponse.id,
|
||||
data: [
|
||||
{
|
||||
recipientId: recipients[0].id,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 1,
|
||||
positionX: 100,
|
||||
positionY: 100,
|
||||
width: 50,
|
||||
height: 50,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const fieldsRes = await fetch(`${V2_API_BASE_URL}/envelope/field/create-many`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(createFieldsRequest),
|
||||
});
|
||||
|
||||
expect(fieldsRes.ok).toBeTruthy();
|
||||
|
||||
// 5. Distribute the envelope.
|
||||
const distributeRes = await fetch(`${V2_API_BASE_URL}/envelope/distribute`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
envelopeId: createResponse.id,
|
||||
} satisfies TDistributeEnvelopeRequest),
|
||||
});
|
||||
|
||||
expect(distributeRes.ok).toBeTruthy();
|
||||
|
||||
return {
|
||||
user,
|
||||
team,
|
||||
envelopeId: createResponse.id,
|
||||
recipientEmail,
|
||||
};
|
||||
};
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('send document via email', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
|
||||
// Add the current user as a recipient via the UI.
|
||||
await clickAddMyselfButton(surface.root);
|
||||
await expect(getRecipientEmailInputs(surface.root).first()).toHaveValue(surface.userEmail);
|
||||
|
||||
// Navigate to the add fields step and place a signature field.
|
||||
await clickEnvelopeEditorStep(surface.root, 'addFields');
|
||||
await expect(surface.root.getByText('Selected Recipient')).toBeVisible();
|
||||
await expect(surface.root.locator('.konva-container canvas').first()).toBeVisible();
|
||||
|
||||
await placeFieldOnPdf(surface.root, 'Signature', { x: 120, y: 140 });
|
||||
await expect(surface.root.getByText('1 Field')).toBeVisible();
|
||||
|
||||
// Navigate back to the recipients step so the sidebar actions are available.
|
||||
await clickEnvelopeEditorStep(surface.root, 'upload');
|
||||
|
||||
// Click the "Send Document" sidebar action.
|
||||
await page.locator('button[title="Send Envelope"]').click();
|
||||
|
||||
// The distribute dialog should appear.
|
||||
await expect(page.getByRole('heading', { name: 'Send Document' })).toBeVisible();
|
||||
|
||||
// Click Send.
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
// Assert toast appears.
|
||||
await expectToastTextToBeVisible(page, 'Envelope distributed');
|
||||
|
||||
// Assert the document status was changed in the database.
|
||||
const updatedEnvelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: surface.envelopeId },
|
||||
});
|
||||
|
||||
expect(updatedEnvelope.status).toBe(DocumentStatus.PENDING);
|
||||
});
|
||||
|
||||
test('send document shows validation when signers lack signature fields', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
|
||||
// Add the current user as a SIGNER recipient via the UI — but do NOT place any fields.
|
||||
await clickAddMyselfButton(surface.root);
|
||||
await expect(getRecipientEmailInputs(surface.root).first()).toHaveValue(surface.userEmail);
|
||||
|
||||
// Click the "Send Document" sidebar action without placing signature fields.
|
||||
await page.locator('button[title="Send Envelope"]').click();
|
||||
|
||||
// The distribute dialog should appear.
|
||||
await expect(page.getByRole('heading', { name: 'Send Document' })).toBeVisible();
|
||||
|
||||
// The validation warning should be shown instead of the send form.
|
||||
await expect(
|
||||
page.getByText('The following signers are missing signature fields'),
|
||||
).toBeVisible();
|
||||
|
||||
// The "Send" button should not be visible since we're in validation mode.
|
||||
await expect(page.getByRole('button', { name: 'Send', exact: true })).not.toBeVisible();
|
||||
|
||||
// Close the dialog.
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
// The dialog should be closed.
|
||||
await expect(
|
||||
page.getByText('The following signers are missing signature fields'),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('resend document sends reminder', async ({ page }) => {
|
||||
const { user, team, envelopeId, recipientEmail } = await createPendingEnvelopeViaApi();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${envelopeId}/edit`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
// Click the "Resend Document" sidebar action.
|
||||
await page.locator('button[title="Resend Envelope"]').click();
|
||||
|
||||
// The redistribute dialog should appear.
|
||||
await expect(page.getByRole('heading', { name: 'Resend Document' })).toBeVisible();
|
||||
|
||||
// The unsigned recipient should be listed.
|
||||
await expect(page.getByText(recipientEmail)).toBeVisible();
|
||||
|
||||
// Select the recipient checkbox.
|
||||
await page.getByRole('checkbox').first().click();
|
||||
|
||||
// Click "Send reminder".
|
||||
await page.getByRole('button', { name: 'Send reminder' }).click();
|
||||
|
||||
// Assert toast appears.
|
||||
await expectToastTextToBeVisible(page, 'Envelope resent');
|
||||
|
||||
// Verify a resend audit log entry was created in the database.
|
||||
const auditLog = await prisma.documentAuditLog.findFirst({
|
||||
where: {
|
||||
envelopeId,
|
||||
type: 'EMAIL_SENT',
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
expect(auditLog).not.toBeNull();
|
||||
expect((auditLog!.data as Record<string, unknown>).isResending).toBe(true);
|
||||
expect((auditLog!.data as Record<string, unknown>).recipientEmail).toBe(recipientEmail);
|
||||
});
|
||||
|
||||
test('duplicate document', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
|
||||
// Click the "Duplicate Document" sidebar action.
|
||||
await page.locator('button[title="Duplicate Envelope"]').click();
|
||||
|
||||
// The duplicate dialog should appear.
|
||||
await expect(page.getByRole('heading', { name: 'Duplicate Document' })).toBeVisible();
|
||||
|
||||
// Click "Duplicate".
|
||||
await page.getByRole('button', { name: 'Duplicate' }).click();
|
||||
|
||||
// Assert toast appears.
|
||||
await expectToastTextToBeVisible(page, 'Envelope Duplicated');
|
||||
|
||||
// The page should have navigated to the new document's edit page.
|
||||
await expect(page).toHaveURL(/\/documents\/.*\/edit/);
|
||||
|
||||
// Verify a new envelope was created in the database.
|
||||
const envelopes = await prisma.envelope.findMany({
|
||||
where: {
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: 'DOCUMENT',
|
||||
},
|
||||
});
|
||||
|
||||
// Should have original + duplicate.
|
||||
expect(envelopes.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('download PDF dialog shows envelope items', async ({ page }) => {
|
||||
await openDocumentEnvelopeEditor(page);
|
||||
|
||||
// Click the "Download PDF" sidebar action.
|
||||
await page.locator('button[title="Download PDF"]').click();
|
||||
|
||||
// The download dialog should appear.
|
||||
await expect(page.getByRole('heading', { name: 'Download Files' })).toBeVisible();
|
||||
await expect(page.getByText('Select the files you would like to download.')).toBeVisible();
|
||||
|
||||
// At least one envelope item with an "Original" download button should be listed.
|
||||
await expect(page.getByRole('button', { name: 'Original' }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('delete draft document', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const documentId = surface.envelopeId;
|
||||
|
||||
// Click the "Delete Document" sidebar action.
|
||||
await page.locator('button[title="Delete Envelope"]').click();
|
||||
|
||||
// The delete dialog should appear.
|
||||
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
|
||||
|
||||
// For DRAFT documents no confirmation input is needed, just click "Delete".
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
// Assert toast appears.
|
||||
await expectToastTextToBeVisible(page, 'Document deleted');
|
||||
|
||||
// The page should navigate back to the documents list.
|
||||
await expect(page).toHaveURL(/\/documents$/);
|
||||
|
||||
// Verify the document was deleted from the database.
|
||||
const deletedDocument = await prisma.envelope.findUnique({
|
||||
where: { id: documentId },
|
||||
});
|
||||
|
||||
expect(deletedDocument).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('create and manage direct link', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// seedTemplate creates a template with a SIGNER recipient.
|
||||
const template = await seedTemplate({
|
||||
title: `E2E Direct Link Template ${Date.now()}`,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
// Click the "Direct Link" sidebar action.
|
||||
await page.locator('button[title="Direct Link"]').click();
|
||||
|
||||
// The onboarding dialog should appear.
|
||||
await expect(page.getByRole('heading', { name: 'Create Direct Signing Link' })).toBeVisible();
|
||||
|
||||
// Click "Enable direct link signing" to proceed.
|
||||
await page.getByRole('button', { name: 'Enable direct link signing' }).click();
|
||||
|
||||
// Select recipient step: click "Create one automatically".
|
||||
await page.getByRole('button', { name: 'Create one automatically' }).click();
|
||||
|
||||
// Manage step should appear.
|
||||
await expect(page.getByRole('heading', { name: 'Direct Link Signing' })).toBeVisible();
|
||||
|
||||
// The shareable link input should be present.
|
||||
await expect(page.locator('#copy-direct-link')).toBeVisible();
|
||||
|
||||
// Click "Save" to persist the toggle state.
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// Assert success toast.
|
||||
await expectToastTextToBeVisible(page, 'Success');
|
||||
|
||||
// Verify a TemplateDirectLink was created and enabled in the database.
|
||||
const directLink = await prisma.templateDirectLink.findFirst({
|
||||
where: { envelopeId: template.id },
|
||||
});
|
||||
|
||||
expect(directLink).not.toBeNull();
|
||||
expect(directLink!.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test('duplicate template', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
|
||||
// Click the "Duplicate Template" sidebar action.
|
||||
await page.locator('button[title="Duplicate Envelope"]').click();
|
||||
|
||||
// The duplicate dialog should appear.
|
||||
await expect(page.getByRole('heading', { name: 'Duplicate Template' })).toBeVisible();
|
||||
|
||||
// Click "Duplicate".
|
||||
await page.getByRole('button', { name: 'Duplicate' }).click();
|
||||
|
||||
// Assert toast appears.
|
||||
await expectToastTextToBeVisible(page, 'Envelope Duplicated');
|
||||
|
||||
// The page should have navigated to the new template's edit page.
|
||||
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
|
||||
|
||||
// Verify a new envelope was created in the database.
|
||||
const envelopes = await prisma.envelope.findMany({
|
||||
where: {
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: 'TEMPLATE',
|
||||
},
|
||||
});
|
||||
|
||||
// Should have original + duplicate.
|
||||
expect(envelopes.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import {
|
||||
type TEnvelopeEditorSurface,
|
||||
addEnvelopeItemPdf,
|
||||
getEnvelopeEditorSettingsTrigger,
|
||||
openDocumentEnvelopeEditor,
|
||||
openEmbeddedEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
persistEmbeddedEnvelope,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
const TEST_ATTACHMENTS = {
|
||||
first: {
|
||||
label: 'E2E First Attachment',
|
||||
url: 'https://example.com/first-attachment',
|
||||
},
|
||||
second: {
|
||||
label: 'E2E Second Attachment',
|
||||
url: 'https://example.com/second-attachment',
|
||||
},
|
||||
third: {
|
||||
label: 'E2E Third Attachment',
|
||||
url: 'https://example.com/third-attachment',
|
||||
},
|
||||
};
|
||||
|
||||
type AttachmentFlowResult = {
|
||||
externalId: string;
|
||||
expectedAttachments: Array<{ label: string; url: string }>;
|
||||
deletedAttachment: { label: string; url: string };
|
||||
};
|
||||
|
||||
const openSettingsDialog = async (root: Page) => {
|
||||
await getEnvelopeEditorSettingsTrigger(root).click();
|
||||
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
|
||||
};
|
||||
|
||||
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
await openSettingsDialog(surface.root);
|
||||
await surface.root.locator('input[name="externalId"]').fill(externalId);
|
||||
await surface.root.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
if (!surface.isEmbedded) {
|
||||
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
|
||||
}
|
||||
};
|
||||
|
||||
const openAttachmentsPopover = async (root: Page) => {
|
||||
await root.getByRole('button', { name: 'Attachments' }).click();
|
||||
|
||||
await expect(root.getByRole('heading', { name: 'Attachments', level: 4 })).toBeVisible();
|
||||
};
|
||||
|
||||
const addAttachment = async (root: Page, { label, url }: { label: string; url: string }) => {
|
||||
await root.getByRole('button', { name: 'Add Attachment' }).click();
|
||||
await root.getByPlaceholder('Label').fill(label);
|
||||
await root.getByPlaceholder('URL').fill(url);
|
||||
await root.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
};
|
||||
|
||||
const getAttachmentItems = (root: Page) => root.locator('.rounded-md.border.border-border.p-2');
|
||||
|
||||
const getAttachmentDeleteButtons = (root: Page) => getAttachmentItems(root).locator('button');
|
||||
|
||||
const assertAttachmentVisibleInPopover = async (
|
||||
root: Page,
|
||||
{ label, url }: { label: string; url: string },
|
||||
) => {
|
||||
const items = getAttachmentItems(root);
|
||||
const matchingItem = items.filter({ hasText: label });
|
||||
|
||||
await expect(matchingItem).toBeVisible();
|
||||
await expect(matchingItem.locator(`a[href="${url}"]`)).toBeVisible();
|
||||
};
|
||||
|
||||
const assertAttachmentNotVisibleInPopover = async (root: Page, label: string) => {
|
||||
await expect(getAttachmentItems(root).filter({ hasText: label })).toHaveCount(0);
|
||||
};
|
||||
|
||||
const assertAttachmentCount = async (root: Page, count: number) => {
|
||||
const button = root.getByRole('button', { name: 'Attachments' });
|
||||
|
||||
if (count > 0) {
|
||||
await expect(button).toContainText(`(${count})`);
|
||||
} else {
|
||||
await expect(button).not.toContainText('(');
|
||||
}
|
||||
};
|
||||
|
||||
const assertAttachmentsInDatabase = async ({
|
||||
externalId,
|
||||
surface,
|
||||
expectedAttachments,
|
||||
deletedLabel,
|
||||
}: {
|
||||
externalId: string;
|
||||
surface: TEnvelopeEditorSurface;
|
||||
expectedAttachments: Array<{ label: string; url: string }>;
|
||||
deletedLabel?: string;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
include: {
|
||||
envelopeAttachments: {
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope.envelopeAttachments).toHaveLength(expectedAttachments.length);
|
||||
|
||||
for (const expected of expectedAttachments) {
|
||||
const found = envelope.envelopeAttachments.find((a) => a.label === expected.label);
|
||||
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.data).toBe(expected.url);
|
||||
expect(found!.type).toBe('link');
|
||||
}
|
||||
|
||||
if (deletedLabel) {
|
||||
expect(envelope.envelopeAttachments.some((a) => a.label === deletedLabel)).toBe(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runAttachmentFlow = async (
|
||||
surface: TEnvelopeEditorSurface,
|
||||
): Promise<AttachmentFlowResult> => {
|
||||
const externalId = `e2e-attachments-${nanoid()}`;
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
await openAttachmentsPopover(surface.root);
|
||||
|
||||
// Create first attachment.
|
||||
await addAttachment(surface.root, TEST_ATTACHMENTS.first);
|
||||
await expectToastTextToBeVisible(surface.root, 'Attachment added successfully.');
|
||||
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.first);
|
||||
await assertAttachmentCount(surface.root, 1);
|
||||
|
||||
await assertAttachmentsInDatabase({
|
||||
externalId,
|
||||
surface,
|
||||
expectedAttachments: [TEST_ATTACHMENTS.first],
|
||||
});
|
||||
|
||||
// Create second attachment.
|
||||
await addAttachment(surface.root, TEST_ATTACHMENTS.second);
|
||||
await expectToastTextToBeVisible(surface.root, 'Attachment added successfully.');
|
||||
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.second);
|
||||
await assertAttachmentCount(surface.root, 2);
|
||||
|
||||
await assertAttachmentsInDatabase({
|
||||
externalId,
|
||||
surface,
|
||||
expectedAttachments: [TEST_ATTACHMENTS.first, TEST_ATTACHMENTS.second],
|
||||
});
|
||||
|
||||
// Create third attachment.
|
||||
await addAttachment(surface.root, TEST_ATTACHMENTS.third);
|
||||
await expectToastTextToBeVisible(surface.root, 'Attachment added successfully.');
|
||||
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.third);
|
||||
await assertAttachmentCount(surface.root, 3);
|
||||
|
||||
await assertAttachmentsInDatabase({
|
||||
externalId,
|
||||
surface,
|
||||
expectedAttachments: [TEST_ATTACHMENTS.first, TEST_ATTACHMENTS.second, TEST_ATTACHMENTS.third],
|
||||
});
|
||||
|
||||
// Delete first attachment.
|
||||
await getAttachmentDeleteButtons(surface.root).first().click();
|
||||
await expectToastTextToBeVisible(surface.root, 'Attachment removed successfully.');
|
||||
|
||||
await expect(getAttachmentItems(surface.root)).toHaveCount(2);
|
||||
await assertAttachmentNotVisibleInPopover(surface.root, TEST_ATTACHMENTS.first.label);
|
||||
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.second);
|
||||
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.third);
|
||||
await assertAttachmentCount(surface.root, 2);
|
||||
|
||||
await assertAttachmentsInDatabase({
|
||||
externalId,
|
||||
surface,
|
||||
expectedAttachments: [TEST_ATTACHMENTS.second, TEST_ATTACHMENTS.third],
|
||||
deletedLabel: TEST_ATTACHMENTS.first.label,
|
||||
});
|
||||
|
||||
return {
|
||||
externalId,
|
||||
expectedAttachments: [TEST_ATTACHMENTS.second, TEST_ATTACHMENTS.third],
|
||||
deletedAttachment: TEST_ATTACHMENTS.first,
|
||||
};
|
||||
};
|
||||
|
||||
const runEmbeddedAttachmentFlow = async (
|
||||
surface: TEnvelopeEditorSurface,
|
||||
): Promise<AttachmentFlowResult> => {
|
||||
const externalId = `e2e-attachments-${nanoid()}`;
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
await openAttachmentsPopover(surface.root);
|
||||
|
||||
// Create first attachment.
|
||||
await addAttachment(surface.root, TEST_ATTACHMENTS.first);
|
||||
await expectToastTextToBeVisible(surface.root, 'Attachment added successfully.');
|
||||
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.first);
|
||||
await assertAttachmentCount(surface.root, 1);
|
||||
|
||||
// Create second attachment.
|
||||
await addAttachment(surface.root, TEST_ATTACHMENTS.second);
|
||||
await expectToastTextToBeVisible(surface.root, 'Attachment added successfully.');
|
||||
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.second);
|
||||
await assertAttachmentCount(surface.root, 2);
|
||||
|
||||
// Create third attachment.
|
||||
await addAttachment(surface.root, TEST_ATTACHMENTS.third);
|
||||
await expectToastTextToBeVisible(surface.root, 'Attachment added successfully.');
|
||||
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.third);
|
||||
await assertAttachmentCount(surface.root, 3);
|
||||
|
||||
// Delete first attachment.
|
||||
await getAttachmentDeleteButtons(surface.root).first().click();
|
||||
await expectToastTextToBeVisible(surface.root, 'Attachment removed successfully.');
|
||||
|
||||
await expect(getAttachmentItems(surface.root)).toHaveCount(2);
|
||||
await assertAttachmentNotVisibleInPopover(surface.root, TEST_ATTACHMENTS.first.label);
|
||||
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.second);
|
||||
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.third);
|
||||
await assertAttachmentCount(surface.root, 2);
|
||||
|
||||
return {
|
||||
externalId,
|
||||
expectedAttachments: [TEST_ATTACHMENTS.second, TEST_ATTACHMENTS.third],
|
||||
deletedAttachment: TEST_ATTACHMENTS.first,
|
||||
};
|
||||
};
|
||||
|
||||
const assertAttachmentsPersistedInDatabase = async ({
|
||||
surface,
|
||||
externalId,
|
||||
expectedAttachments,
|
||||
deletedAttachment,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
externalId: string;
|
||||
expectedAttachments: AttachmentFlowResult['expectedAttachments'];
|
||||
deletedAttachment: AttachmentFlowResult['deletedAttachment'];
|
||||
}) => {
|
||||
await assertAttachmentsInDatabase({
|
||||
externalId,
|
||||
surface,
|
||||
expectedAttachments,
|
||||
deletedLabel: deletedAttachment.label,
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('add, verify and delete attachments', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runAttachmentFlow(surface);
|
||||
|
||||
await assertAttachmentsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('add, verify and delete attachments', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runAttachmentFlow(surface);
|
||||
|
||||
await assertAttachmentsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded create', () => {
|
||||
test('add, verify and delete attachments', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-attachments',
|
||||
});
|
||||
|
||||
await addEnvelopeItemPdf(surface.root, 'embedded-document-attachments.pdf');
|
||||
|
||||
const result = await runEmbeddedAttachmentFlow(surface);
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertAttachmentsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded edit', () => {
|
||||
test('add, verify and delete attachments', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-attachments',
|
||||
});
|
||||
|
||||
const result = await runEmbeddedAttachmentFlow(surface);
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertAttachmentsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import {
|
||||
type TEnvelopeEditorSurface,
|
||||
openEmbeddedEnvelopeEditor,
|
||||
} from '../fixtures/envelope-editor';
|
||||
|
||||
const TEST_CSS_VARS = {
|
||||
background: '#ff0000',
|
||||
primary: '#00ff00',
|
||||
radius: '1rem',
|
||||
};
|
||||
|
||||
/**
|
||||
* A unique CSS selector used for asserting raw CSS injection.
|
||||
*/
|
||||
const TEST_RAW_CSS = '.e2e-css-test-marker { color: red; }';
|
||||
|
||||
/**
|
||||
* Expected HSL values after conversion by `toNativeCssVars`:
|
||||
* - colord('#ff0000').toHsl() → { h: 0, s: 100, l: 50 }
|
||||
* - colord('#00ff00').toHsl() → { h: 120, s: 100, l: 50 }
|
||||
*/
|
||||
const EXPECTED_CSS_VARS = {
|
||||
'--background': '0 100 50',
|
||||
'--primary': '120 100 50',
|
||||
'--radius': '1rem',
|
||||
};
|
||||
|
||||
const enableEmbedAuthoringWhiteLabel = async (userId: number) => {
|
||||
const organisation = await prisma.organisation.findFirstOrThrow({
|
||||
where: { ownerUserId: userId },
|
||||
include: { organisationClaim: true },
|
||||
});
|
||||
|
||||
await prisma.organisationClaim.update({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
data: {
|
||||
flags: {
|
||||
allowLegacyEnvelopes: true,
|
||||
embedAuthoringWhiteLabel: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* The default background color from the theme before any CSS injection.
|
||||
*
|
||||
* The theme default `--background: 0 0% 100%` resolves to hsl(0, 0%, 100%) which is white.
|
||||
*/
|
||||
const DEFAULT_BODY_BG_COLOR = 'rgb(255, 255, 255)';
|
||||
|
||||
/**
|
||||
* When `--background` is set to `0 100 50` (hsl(0, 100%, 50%)) the body background
|
||||
* resolves to pure red via the Tailwind `bg-background` → `hsl(var(--background))` chain.
|
||||
*/
|
||||
const INJECTED_BODY_BG_COLOR = 'rgb(255, 0, 0)';
|
||||
|
||||
const assertCssNotInjected = async (surface: TEnvelopeEditorSurface) => {
|
||||
const { root: page } = surface;
|
||||
|
||||
const cssState = await page.evaluate(() => {
|
||||
const rootStyle = document.documentElement.style;
|
||||
const bodyBgColor = window.getComputedStyle(document.body).backgroundColor;
|
||||
|
||||
return {
|
||||
background: rootStyle.getPropertyValue('--background'),
|
||||
primary: rootStyle.getPropertyValue('--primary'),
|
||||
radius: rootStyle.getPropertyValue('--radius'),
|
||||
bodyBgColor,
|
||||
hasInjectedStyle: Array.from(document.head.querySelectorAll('style')).some((el) =>
|
||||
el.innerHTML.includes('.e2e-css-test-marker'),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// CSS custom properties should not be set on the inline style.
|
||||
expect(cssState.background).toBe('');
|
||||
expect(cssState.primary).toBe('');
|
||||
expect(cssState.radius).toBe('');
|
||||
|
||||
// No raw CSS style tag should be injected.
|
||||
expect(cssState.hasInjectedStyle).toBe(false);
|
||||
|
||||
// The body should still use the default theme background color.
|
||||
expect(cssState.bodyBgColor).toBe(DEFAULT_BODY_BG_COLOR);
|
||||
};
|
||||
|
||||
const assertCssInjected = async (surface: TEnvelopeEditorSurface) => {
|
||||
const { root: page } = surface;
|
||||
|
||||
const cssState = await page.evaluate(() => {
|
||||
const rootStyle = document.documentElement.style;
|
||||
const bodyBgColor = window.getComputedStyle(document.body).backgroundColor;
|
||||
|
||||
return {
|
||||
background: rootStyle.getPropertyValue('--background'),
|
||||
primary: rootStyle.getPropertyValue('--primary'),
|
||||
radius: rootStyle.getPropertyValue('--radius'),
|
||||
bodyBgColor,
|
||||
hasInjectedStyle: Array.from(document.head.querySelectorAll('style')).some((el) =>
|
||||
el.innerHTML.includes('.e2e-css-test-marker'),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// CSS custom properties should be set to the expected HSL values.
|
||||
expect(cssState.background).toBe(EXPECTED_CSS_VARS['--background']);
|
||||
expect(cssState.primary).toBe(EXPECTED_CSS_VARS['--primary']);
|
||||
expect(cssState.radius).toBe(EXPECTED_CSS_VARS['--radius']);
|
||||
|
||||
// Raw CSS style tag should be injected.
|
||||
expect(cssState.hasInjectedStyle).toBe(true);
|
||||
|
||||
// The body background should reflect the injected --background value (red).
|
||||
expect(cssState.bodyBgColor).toBe(INJECTED_BODY_BG_COLOR);
|
||||
};
|
||||
|
||||
const assertDarkModeDisabled = async (surface: TEnvelopeEditorSurface) => {
|
||||
const { root: page } = surface;
|
||||
|
||||
const hasDarkModeDisabled = await page.evaluate(() =>
|
||||
document.documentElement.classList.contains('dark-mode-disabled'),
|
||||
);
|
||||
|
||||
expect(hasDarkModeDisabled).toBe(true);
|
||||
};
|
||||
|
||||
test.describe('embedded create', () => {
|
||||
test('cssVars and css respect embedAuthoringWhiteLabel flag', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
mode: 'create',
|
||||
tokenNamePrefix: 'e2e-embed-css',
|
||||
css: TEST_RAW_CSS,
|
||||
cssVars: TEST_CSS_VARS,
|
||||
darkModeDisabled: true,
|
||||
});
|
||||
|
||||
// darkModeDisabled is applied regardless of the flag.
|
||||
await assertDarkModeDisabled(surface);
|
||||
|
||||
// Flag is disabled by default so CSS should NOT be injected.
|
||||
await assertCssNotInjected(surface);
|
||||
|
||||
// Enable the embedAuthoringWhiteLabel flag on the organisation claim.
|
||||
await enableEmbedAuthoringWhiteLabel(surface.userId);
|
||||
|
||||
// Reload the page to re-run the layout loader with the updated claim.
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
// CSS should now be injected.
|
||||
await assertCssInjected(surface);
|
||||
|
||||
// darkModeDisabled should still be applied after reload.
|
||||
await assertDarkModeDisabled(surface);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded edit', () => {
|
||||
test('cssVars and css respect embedAuthoringWhiteLabel flag', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-css',
|
||||
css: TEST_RAW_CSS,
|
||||
cssVars: TEST_CSS_VARS,
|
||||
darkModeDisabled: true,
|
||||
});
|
||||
|
||||
// darkModeDisabled is applied regardless of the flag.
|
||||
await assertDarkModeDisabled(surface);
|
||||
|
||||
// Flag is disabled by default so CSS should NOT be injected.
|
||||
await assertCssNotInjected(surface);
|
||||
|
||||
// Enable the embedAuthoringWhiteLabel flag on the organisation claim.
|
||||
await enableEmbedAuthoringWhiteLabel(surface.userId);
|
||||
|
||||
// Reload the page to re-run the layout loader with the updated claim.
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
// CSS should now be injected.
|
||||
await assertCssInjected(surface);
|
||||
|
||||
// darkModeDisabled should still be applied after reload.
|
||||
await assertDarkModeDisabled(surface);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,852 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
import { FieldType } from '@prisma/client';
|
||||
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import {
|
||||
type TEnvelopeEditorSurface,
|
||||
addEnvelopeItemPdf,
|
||||
clickAddMyselfButton,
|
||||
clickAddSignerButton,
|
||||
clickEnvelopeEditorStep,
|
||||
getEnvelopeEditorSettingsTrigger,
|
||||
getRecipientEmailInputs,
|
||||
getRecipientRemoveButtons,
|
||||
openDocumentEnvelopeEditor,
|
||||
openEmbeddedEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
persistEmbeddedEnvelope,
|
||||
setRecipientEmail,
|
||||
setRecipientName,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
import { getKonvaElementCountForPage } from '../fixtures/konva';
|
||||
|
||||
type TFieldFlowResult = {
|
||||
externalId: string;
|
||||
recipientEmail: string;
|
||||
};
|
||||
|
||||
const TEST_FIELD_VALUES = {
|
||||
embeddedRecipient: {
|
||||
email: 'embedded-field-recipient@documenso.com',
|
||||
name: 'Embedded Field Recipient',
|
||||
},
|
||||
};
|
||||
|
||||
const openSettingsDialog = async (root: Page) => {
|
||||
await getEnvelopeEditorSettingsTrigger(root).click();
|
||||
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
|
||||
};
|
||||
|
||||
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
await openSettingsDialog(surface.root);
|
||||
await surface.root.locator('input[name="externalId"]').fill(externalId);
|
||||
await surface.root.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
if (!surface.isEmbedded) {
|
||||
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
|
||||
}
|
||||
};
|
||||
|
||||
const setupRecipientsForFieldPlacement = async (surface: TEnvelopeEditorSurface) => {
|
||||
if (surface.isEmbedded) {
|
||||
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toHaveCount(0);
|
||||
await setRecipientEmail(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.email);
|
||||
await setRecipientName(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.name);
|
||||
|
||||
return TEST_FIELD_VALUES.embeddedRecipient.email;
|
||||
}
|
||||
|
||||
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toBeVisible();
|
||||
await clickAddMyselfButton(surface.root);
|
||||
await expect(getRecipientEmailInputs(surface.root).first()).toHaveValue(surface.userEmail);
|
||||
|
||||
return surface.userEmail;
|
||||
};
|
||||
|
||||
type FieldButtonName =
|
||||
| 'Signature'
|
||||
| 'Email'
|
||||
| 'Name'
|
||||
| 'Initials'
|
||||
| 'Date'
|
||||
| 'Text'
|
||||
| 'Number'
|
||||
| 'Radio'
|
||||
| 'Checkbox'
|
||||
| 'Dropdown';
|
||||
|
||||
const placeFieldOnPdf = async (
|
||||
root: Page,
|
||||
fieldName: FieldButtonName,
|
||||
position: { x: number; y: number },
|
||||
) => {
|
||||
await root.getByRole('button', { name: fieldName, exact: true }).click();
|
||||
|
||||
const canvas = root.locator('.konva-container canvas').first();
|
||||
await expect(canvas).toBeVisible();
|
||||
await canvas.click({ position });
|
||||
};
|
||||
|
||||
const selectRecipientInFieldsStep = async (root: Page, recipientIdentifier: string) => {
|
||||
await root.locator('button[role="combobox"]').click();
|
||||
await root.getByText(recipientIdentifier).click();
|
||||
};
|
||||
|
||||
const selectFieldOnCanvas = async (root: Page, position: { x: number; y: number }) => {
|
||||
const canvas = root.locator('.konva-container canvas').first();
|
||||
await expect(canvas).toBeVisible();
|
||||
await root.waitForTimeout(300);
|
||||
// Use force:true to bypass any floating action toolbar buttons that may intercept clicks.
|
||||
await canvas.click({ position, force: true });
|
||||
};
|
||||
|
||||
const runAddAndPersistSignatureTextFields = async (
|
||||
surface: TEnvelopeEditorSurface,
|
||||
): Promise<TFieldFlowResult> => {
|
||||
const externalId = `e2e-fields-${nanoid()}`;
|
||||
|
||||
if (surface.isEmbedded && !surface.envelopeId) {
|
||||
await addEnvelopeItemPdf(surface.root, 'embedded-fields.pdf');
|
||||
}
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
const recipientEmail = await setupRecipientsForFieldPlacement(surface);
|
||||
|
||||
await clickEnvelopeEditorStep(surface.root, 'addFields');
|
||||
await expect(surface.root.getByText('Selected Recipient')).toBeVisible();
|
||||
await expect(surface.root.locator('.konva-container canvas').first()).toBeVisible();
|
||||
|
||||
await placeFieldOnPdf(surface.root, 'Signature', { x: 120, y: 140 });
|
||||
let fieldCount = await getKonvaElementCountForPage(surface.root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(1);
|
||||
|
||||
await placeFieldOnPdf(surface.root, 'Text', { x: 220, y: 240 });
|
||||
fieldCount = await getKonvaElementCountForPage(surface.root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
await clickEnvelopeEditorStep(surface.root, 'upload');
|
||||
await expect(surface.root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||
|
||||
await clickEnvelopeEditorStep(surface.root, 'addFields');
|
||||
await surface.root.locator('.konva-container canvas').first().waitFor({ state: 'visible' });
|
||||
await expect(surface.root.getByText('Selected Recipient')).toBeVisible();
|
||||
fieldCount = await getKonvaElementCountForPage(surface.root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
return {
|
||||
externalId,
|
||||
recipientEmail,
|
||||
};
|
||||
};
|
||||
|
||||
const getFieldMetaType = (fieldMeta: unknown) => {
|
||||
if (!isRecord(fieldMeta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return typeof fieldMeta.type === 'string' ? fieldMeta.type : null;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const assertFieldsPersistedInDatabase = async ({
|
||||
surface,
|
||||
externalId,
|
||||
recipientEmail,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
externalId: string;
|
||||
recipientEmail: string;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
include: {
|
||||
fields: true,
|
||||
recipients: true,
|
||||
},
|
||||
});
|
||||
|
||||
const recipient = envelope.recipients.find(
|
||||
(currentRecipient) => currentRecipient.email === recipientEmail,
|
||||
);
|
||||
|
||||
expect(recipient).toBeDefined();
|
||||
|
||||
const fieldTypes = envelope.fields.map((field) => field.type).sort();
|
||||
const expectedFieldTypes = [FieldType.SIGNATURE, FieldType.TEXT].sort();
|
||||
|
||||
expect(envelope.fields).toHaveLength(2);
|
||||
expect(fieldTypes).toEqual(expectedFieldTypes);
|
||||
expect(new Set(envelope.fields.map((field) => field.envelopeItemId)).size).toBe(1);
|
||||
expect(envelope.fields.every((field) => field.recipientId === recipient?.id)).toBe(true);
|
||||
|
||||
const signatureField = envelope.fields.find((field) => field.type === FieldType.SIGNATURE);
|
||||
const textField = envelope.fields.find((field) => field.type === FieldType.TEXT);
|
||||
|
||||
expect(getFieldMetaType(signatureField?.fieldMeta)).toBe('signature');
|
||||
expect(getFieldMetaType(textField?.fieldMeta)).toBe('text');
|
||||
};
|
||||
|
||||
// --- Multi-recipient field flow ---
|
||||
|
||||
type TMultiRecipientFlowResult = {
|
||||
externalId: string;
|
||||
firstRecipientEmail: string;
|
||||
secondRecipientEmail: string;
|
||||
};
|
||||
|
||||
const MULTI_RECIPIENT_VALUES = {
|
||||
secondSigner: {
|
||||
email: 'second-signer@test.documenso.com',
|
||||
name: 'Second Signer',
|
||||
},
|
||||
};
|
||||
|
||||
const runMultiRecipientFieldFlow = async (
|
||||
surface: TEnvelopeEditorSurface,
|
||||
): Promise<TMultiRecipientFlowResult> => {
|
||||
const externalId = `e2e-multi-recip-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
if (surface.isEmbedded && !surface.envelopeId) {
|
||||
await addEnvelopeItemPdf(root, 'embedded-fields.pdf');
|
||||
}
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
|
||||
// Add two recipients.
|
||||
let firstRecipientEmail: string;
|
||||
|
||||
if (surface.isEmbedded) {
|
||||
await setRecipientEmail(root, 0, TEST_FIELD_VALUES.embeddedRecipient.email);
|
||||
await setRecipientName(root, 0, TEST_FIELD_VALUES.embeddedRecipient.name);
|
||||
firstRecipientEmail = TEST_FIELD_VALUES.embeddedRecipient.email;
|
||||
} else {
|
||||
await clickAddMyselfButton(root);
|
||||
firstRecipientEmail = surface.userEmail;
|
||||
}
|
||||
|
||||
await clickAddSignerButton(root);
|
||||
await setRecipientEmail(root, 1, MULTI_RECIPIENT_VALUES.secondSigner.email);
|
||||
await setRecipientName(root, 1, MULTI_RECIPIENT_VALUES.secondSigner.name);
|
||||
|
||||
// Navigate to fields step.
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect(root.getByText('Selected Recipient')).toBeVisible();
|
||||
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
|
||||
|
||||
let fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(0);
|
||||
|
||||
// Place Signature for recipient #1 (auto-selected).
|
||||
await placeFieldOnPdf(root, 'Signature', { x: 120, y: 140 });
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(1);
|
||||
|
||||
// Switch recipient and place text field for recipient #2.
|
||||
await selectRecipientInFieldsStep(root, MULTI_RECIPIENT_VALUES.secondSigner.email);
|
||||
await placeFieldOnPdf(root, 'Text', { x: 220, y: 240 });
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
// Navigate away and back to ensure fields are persisted in the UI.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
// Phase 2: cascade deletion — go back to recipients and remove the second one.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await expect(getRecipientEmailInputs(root)).toHaveCount(2);
|
||||
|
||||
await getRecipientRemoveButtons(root).nth(1).click();
|
||||
await expect(getRecipientEmailInputs(root)).toHaveCount(1);
|
||||
|
||||
// Go back to fields and verify cascade removal.
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(1);
|
||||
|
||||
return {
|
||||
externalId,
|
||||
firstRecipientEmail,
|
||||
secondRecipientEmail: MULTI_RECIPIENT_VALUES.secondSigner.email,
|
||||
};
|
||||
};
|
||||
|
||||
const assertMultiRecipientCascadePersistedInDatabase = async ({
|
||||
surface,
|
||||
externalId,
|
||||
firstRecipientEmail,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
externalId: string;
|
||||
firstRecipientEmail: string;
|
||||
secondRecipientEmail: string;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { fields: true, recipients: true },
|
||||
});
|
||||
|
||||
// After cascade deletion, only one recipient and one field should remain.
|
||||
expect(envelope.recipients).toHaveLength(1);
|
||||
expect(envelope.recipients[0].email).toBe(firstRecipientEmail);
|
||||
|
||||
expect(envelope.fields).toHaveLength(1);
|
||||
expect(envelope.fields[0].type).toBe(FieldType.SIGNATURE);
|
||||
expect(envelope.fields[0].recipientId).toBe(envelope.recipients[0].id);
|
||||
};
|
||||
|
||||
// --- All 10 field types flow ---
|
||||
|
||||
type TAllFieldTypesFlowResult = {
|
||||
externalId: string;
|
||||
};
|
||||
|
||||
const runAllFieldTypesFlow = async (
|
||||
surface: TEnvelopeEditorSurface,
|
||||
): Promise<TAllFieldTypesFlowResult> => {
|
||||
const externalId = `e2e-all-fields-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
if (surface.isEmbedded && !surface.envelopeId) {
|
||||
await addEnvelopeItemPdf(root, 'embedded-fields.pdf');
|
||||
}
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
await setupRecipientsForFieldPlacement(surface);
|
||||
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
|
||||
|
||||
// Place and configure each field type immediately after placement.
|
||||
// After placeFieldOnPdf, the sidebar shows the field's config form (field is selected in React state).
|
||||
|
||||
// 1. Signature: place and set fontSize to 24.
|
||||
await placeFieldOnPdf(root, 'Signature', { x: 120, y: 50 });
|
||||
await root.locator('[data-testid="field-form-fontSize"]').fill('24');
|
||||
|
||||
// 2. Email: place and set textAlign to center.
|
||||
await placeFieldOnPdf(root, 'Email', { x: 120, y: 100 });
|
||||
await root.locator('[data-testid="field-form-textAlign"]').click();
|
||||
await root.getByRole('option', { name: 'Center' }).click();
|
||||
|
||||
// 3. Name: place and set textAlign to right.
|
||||
await placeFieldOnPdf(root, 'Name', { x: 120, y: 150 });
|
||||
await root.locator('[data-testid="field-form-textAlign"]').click();
|
||||
await root.getByRole('option', { name: 'Right' }).click();
|
||||
|
||||
// 4. Initials: place and set fontSize to 16.
|
||||
await placeFieldOnPdf(root, 'Initials', { x: 120, y: 200 });
|
||||
await root.locator('[data-testid="field-form-fontSize"]').fill('16');
|
||||
|
||||
// 5. Date: place and set textAlign to center.
|
||||
await placeFieldOnPdf(root, 'Date', { x: 120, y: 250 });
|
||||
await root.locator('[data-testid="field-form-textAlign"]').click();
|
||||
await root.getByRole('option', { name: 'Center' }).click();
|
||||
|
||||
// 6. Text: place and configure label, placeholder, text, characterLimit, required.
|
||||
await placeFieldOnPdf(root, 'Text', { x: 120, y: 300 });
|
||||
await root.locator('[data-testid="field-form-label"]').fill('Test Label');
|
||||
await root.locator('[data-testid="field-form-placeholder"]').fill('Enter text here');
|
||||
await root.locator('[data-testid="field-form-text"]').fill('Default text value');
|
||||
await root.locator('[data-testid="field-form-characterLimit"]').fill('100');
|
||||
await root.locator('[data-testid="field-form-required"]').click();
|
||||
|
||||
// 7. Number: place and configure label, placeholder, numberFormat, minValue, maxValue, required.
|
||||
await placeFieldOnPdf(root, 'Number', { x: 120, y: 350 });
|
||||
await root.locator('[data-testid="field-form-label"]').fill('Amount');
|
||||
await root.locator('[data-testid="field-form-placeholder"]').fill('0.00');
|
||||
await root.locator('[data-testid="field-form-numberFormat"]').click();
|
||||
await root.getByRole('option', { name: '123,456,789.00' }).click();
|
||||
await root.locator('[data-testid="field-form-minValue"]').fill('0');
|
||||
await root.locator('[data-testid="field-form-maxValue"]').fill('1000');
|
||||
await root.locator('[data-testid="field-form-required"]').click();
|
||||
|
||||
// 8. Radio: place and configure two options, pre-select first, set direction to horizontal.
|
||||
await placeFieldOnPdf(root, 'Radio', { x: 120, y: 400 });
|
||||
|
||||
// The first option already exists with default value "Default value". Fill it.
|
||||
await root.locator('[data-testid="field-form-values-0-value"]').fill('Option A');
|
||||
|
||||
// Add a second option.
|
||||
await root.locator('[data-testid="field-form-values-add"]').click();
|
||||
await root.locator('[data-testid="field-form-values-1-value"]').fill('Option B');
|
||||
|
||||
// Pre-select the first option (click its checkbox).
|
||||
await root.locator('[data-testid="field-form-values-0-checked"]').click();
|
||||
|
||||
// Set direction to horizontal.
|
||||
await root.locator('[data-testid="field-form-direction"]').click();
|
||||
await root.getByRole('option', { name: 'Horizontal' }).click();
|
||||
|
||||
// 9. Checkbox: place and configure two options, check both, set validation rule.
|
||||
await placeFieldOnPdf(root, 'Checkbox', { x: 120, y: 450 });
|
||||
|
||||
// Fill first option value.
|
||||
await root.locator('[data-testid="field-form-values-0-value"]').fill('Check A');
|
||||
|
||||
// Add a second option.
|
||||
await root.locator('[data-testid="field-form-values-add"]').click();
|
||||
await root.locator('[data-testid="field-form-values-1-value"]').fill('Check B');
|
||||
|
||||
// Check both options (click their checkboxes).
|
||||
await root.locator('[data-testid="field-form-values-0-checked"]').click();
|
||||
await root.locator('[data-testid="field-form-values-1-checked"]').click();
|
||||
|
||||
// Set validation: "Select at least" 1.
|
||||
await root.locator('[data-testid="field-form-validationRule"]').click();
|
||||
await root.getByRole('option', { name: 'Select at least' }).click();
|
||||
|
||||
// Set validation length to 1.
|
||||
await root.locator('[data-testid="field-form-validationLength"]').click();
|
||||
await root.getByRole('option', { name: '1', exact: true }).click();
|
||||
|
||||
// 10. Dropdown: place and configure two options, set default value.
|
||||
await placeFieldOnPdf(root, 'Dropdown', { x: 120, y: 500 });
|
||||
|
||||
// First option already has "Option 1". Change it to "Red".
|
||||
await root.locator('[data-testid="field-form-values-0-value"]').fill('Red');
|
||||
|
||||
// Add a second option.
|
||||
await root.locator('[data-testid="field-form-values-add"]').click();
|
||||
await root.locator('[data-testid="field-form-values-1-value"]').clear();
|
||||
await root.locator('[data-testid="field-form-values-1-value"]').fill('Blue');
|
||||
|
||||
// Set default value to "Red".
|
||||
await root.locator('[data-testid="field-form-defaultValue"]').click();
|
||||
await root.getByRole('option', { name: 'Red' }).click();
|
||||
|
||||
let fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(10);
|
||||
|
||||
// Wait briefly for auto-save to fire on the last configured field.
|
||||
await root.waitForTimeout(500);
|
||||
|
||||
// Navigate away and back to verify persistence.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(10);
|
||||
|
||||
return { externalId };
|
||||
};
|
||||
|
||||
const assertAllFieldTypesPersistedInDatabase = async ({
|
||||
surface,
|
||||
externalId,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
externalId: string;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { fields: true },
|
||||
});
|
||||
|
||||
expect(envelope.fields).toHaveLength(10);
|
||||
|
||||
const fieldsByType = new Map(envelope.fields.map((f) => [f.type, f]));
|
||||
|
||||
// Helper to safely access fieldMeta as a record.
|
||||
const meta = (type: FieldType): Record<string, unknown> => {
|
||||
const field = fieldsByType.get(type);
|
||||
expect(field).toBeDefined();
|
||||
const fieldMeta = field!.fieldMeta;
|
||||
expect(typeof fieldMeta).toBe('object');
|
||||
expect(fieldMeta).not.toBeNull();
|
||||
return fieldMeta as Record<string, unknown>;
|
||||
};
|
||||
|
||||
// SIGNATURE
|
||||
expect(meta(FieldType.SIGNATURE).type).toBe('signature');
|
||||
expect(meta(FieldType.SIGNATURE).fontSize).toBe(24);
|
||||
|
||||
// EMAIL
|
||||
expect(meta(FieldType.EMAIL).type).toBe('email');
|
||||
expect(meta(FieldType.EMAIL).textAlign).toBe('center');
|
||||
|
||||
// NAME
|
||||
expect(meta(FieldType.NAME).type).toBe('name');
|
||||
expect(meta(FieldType.NAME).textAlign).toBe('right');
|
||||
|
||||
// INITIALS
|
||||
expect(meta(FieldType.INITIALS).type).toBe('initials');
|
||||
expect(meta(FieldType.INITIALS).fontSize).toBe(16);
|
||||
|
||||
// DATE
|
||||
expect(meta(FieldType.DATE).type).toBe('date');
|
||||
expect(meta(FieldType.DATE).textAlign).toBe('center');
|
||||
|
||||
// TEXT
|
||||
expect(meta(FieldType.TEXT).type).toBe('text');
|
||||
expect(meta(FieldType.TEXT).label).toBe('Test Label');
|
||||
expect(meta(FieldType.TEXT).placeholder).toBe('Enter text here');
|
||||
expect(meta(FieldType.TEXT).text).toBe('Default text value');
|
||||
expect(meta(FieldType.TEXT).characterLimit).toBe(100);
|
||||
expect(meta(FieldType.TEXT).required).toBe(true);
|
||||
|
||||
// NUMBER
|
||||
expect(meta(FieldType.NUMBER).type).toBe('number');
|
||||
expect(meta(FieldType.NUMBER).label).toBe('Amount');
|
||||
expect(meta(FieldType.NUMBER).placeholder).toBe('0.00');
|
||||
expect(meta(FieldType.NUMBER).numberFormat).toBe('123,456,789.00');
|
||||
expect(meta(FieldType.NUMBER).minValue).toBe(0);
|
||||
expect(meta(FieldType.NUMBER).maxValue).toBe(1000);
|
||||
expect(meta(FieldType.NUMBER).required).toBe(true);
|
||||
|
||||
// RADIO
|
||||
expect(meta(FieldType.RADIO).type).toBe('radio');
|
||||
expect(meta(FieldType.RADIO).direction).toBe('horizontal');
|
||||
const radioValues = meta(FieldType.RADIO).values as Array<{
|
||||
value: string;
|
||||
checked: boolean;
|
||||
}>;
|
||||
expect(radioValues).toHaveLength(2);
|
||||
expect(radioValues[0].value).toBe('Option A');
|
||||
expect(radioValues[0].checked).toBe(true);
|
||||
expect(radioValues[1].value).toBe('Option B');
|
||||
expect(radioValues[1].checked).toBe(false);
|
||||
|
||||
// CHECKBOX
|
||||
expect(meta(FieldType.CHECKBOX).type).toBe('checkbox');
|
||||
expect(meta(FieldType.CHECKBOX).validationRule).toBe('Select at least');
|
||||
expect(meta(FieldType.CHECKBOX).validationLength).toBe(1);
|
||||
const checkboxValues = meta(FieldType.CHECKBOX).values as Array<{
|
||||
value: string;
|
||||
checked: boolean;
|
||||
}>;
|
||||
expect(checkboxValues).toHaveLength(2);
|
||||
expect(checkboxValues[0].value).toBe('Check A');
|
||||
expect(checkboxValues[0].checked).toBe(true);
|
||||
expect(checkboxValues[1].value).toBe('Check B');
|
||||
expect(checkboxValues[1].checked).toBe(true);
|
||||
|
||||
// DROPDOWN
|
||||
expect(meta(FieldType.DROPDOWN).type).toBe('dropdown');
|
||||
expect(meta(FieldType.DROPDOWN).defaultValue).toBe('Red');
|
||||
const dropdownValues = meta(FieldType.DROPDOWN).values as Array<{ value: string }>;
|
||||
expect(dropdownValues).toHaveLength(2);
|
||||
expect(dropdownValues[0].value).toBe('Red');
|
||||
expect(dropdownValues[1].value).toBe('Blue');
|
||||
};
|
||||
|
||||
// --- Duplicate and delete fields flow ---
|
||||
|
||||
type TDuplicateDeleteFlowResult = {
|
||||
externalId: string;
|
||||
};
|
||||
|
||||
const runDuplicateDeleteFieldFlow = async (
|
||||
surface: TEnvelopeEditorSurface,
|
||||
): Promise<TDuplicateDeleteFlowResult> => {
|
||||
const externalId = `e2e-dup-del-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
if (surface.isEmbedded && !surface.envelopeId) {
|
||||
await addEnvelopeItemPdf(root, 'embedded-fields.pdf');
|
||||
}
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
await setupRecipientsForFieldPlacement(surface);
|
||||
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
|
||||
|
||||
// Place a Signature field.
|
||||
await placeFieldOnPdf(root, 'Signature', { x: 150, y: 150 });
|
||||
let fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(1);
|
||||
|
||||
// Select the field on canvas to show the action toolbar.
|
||||
await selectFieldOnCanvas(root, { x: 150, y: 150 });
|
||||
await expect(root.locator('button[title="Duplicate"]')).toBeVisible();
|
||||
|
||||
// Duplicate the field.
|
||||
await root.locator('button[title="Duplicate"]').click();
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
// Navigate away and back to persist changes.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
// Select a field and delete it via the Remove button.
|
||||
await selectFieldOnCanvas(root, { x: 150, y: 150 });
|
||||
await expect(root.locator('button[title="Remove"]')).toBeVisible();
|
||||
await root.locator('button[title="Remove"]').click();
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(1);
|
||||
|
||||
// Navigate away and back to verify persistence.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(1);
|
||||
|
||||
return { externalId };
|
||||
};
|
||||
|
||||
const assertDuplicateDeleteFieldPersistedInDatabase = async ({
|
||||
surface,
|
||||
externalId,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
externalId: string;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { fields: true },
|
||||
});
|
||||
|
||||
// After duplicating (2 fields) then deleting one, exactly 1 SIGNATURE field should remain.
|
||||
expect(envelope.fields).toHaveLength(1);
|
||||
expect(envelope.fields[0].type).toBe(FieldType.SIGNATURE);
|
||||
};
|
||||
|
||||
// --- Test describe blocks ---
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runAddAndPersistSignatureTextFields(surface);
|
||||
|
||||
await assertFieldsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('multi-recipient field placement, switching, and cascade deletion', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runMultiRecipientFieldFlow(surface);
|
||||
|
||||
await assertMultiRecipientCascadePersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('duplicate and delete fields via canvas action toolbar', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runDuplicateDeleteFieldFlow(surface);
|
||||
|
||||
await assertDuplicateDeleteFieldPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('place and configure all 10 field types', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runAllFieldTypesFlow(surface);
|
||||
|
||||
await assertAllFieldTypesPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runAddAndPersistSignatureTextFields(surface);
|
||||
|
||||
await assertFieldsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('multi-recipient field placement, switching, and cascade deletion', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runMultiRecipientFieldFlow(surface);
|
||||
|
||||
await assertMultiRecipientCascadePersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('duplicate and delete fields via canvas action toolbar', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runDuplicateDeleteFieldFlow(surface);
|
||||
|
||||
await assertDuplicateDeleteFieldPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('place and configure all 10 field types', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runAllFieldTypesFlow(surface);
|
||||
|
||||
await assertAllFieldTypesPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded create', () => {
|
||||
test('add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-fields',
|
||||
});
|
||||
const result = await runAddAndPersistSignatureTextFields(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertFieldsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('multi-recipient field placement, switching, and cascade deletion', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-multi-recip',
|
||||
});
|
||||
const result = await runMultiRecipientFieldFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertMultiRecipientCascadePersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('duplicate and delete fields via canvas action toolbar', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-dup-del',
|
||||
});
|
||||
const result = await runDuplicateDeleteFieldFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertDuplicateDeleteFieldPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('place and configure all 10 field types', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-all-fields',
|
||||
});
|
||||
const result = await runAllFieldTypesFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertAllFieldTypesPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded edit', () => {
|
||||
test('add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-fields',
|
||||
});
|
||||
const result = await runAddAndPersistSignatureTextFields(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertFieldsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('multi-recipient field placement, switching, and cascade deletion', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-multi-recip',
|
||||
});
|
||||
const result = await runMultiRecipientFieldFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertMultiRecipientCascadePersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('duplicate and delete fields via canvas action toolbar', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-dup-del',
|
||||
});
|
||||
const result = await runDuplicateDeleteFieldFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertDuplicateDeleteFieldPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('place and configure all 10 field types', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-all-fields',
|
||||
});
|
||||
const result = await runAllFieldTypesFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertAllFieldTypesPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import {
|
||||
type TEnvelopeEditorSurface,
|
||||
clickEnvelopeEditorStep,
|
||||
getEnvelopeEditorSettingsTrigger,
|
||||
getEnvelopeItemDragHandles,
|
||||
getEnvelopeItemDropzoneInput,
|
||||
getEnvelopeItemRemoveButtons,
|
||||
getEnvelopeItemTitleInputs,
|
||||
openDocumentEnvelopeEditor,
|
||||
openEmbeddedEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
persistEmbeddedEnvelope,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
test.use({
|
||||
storageState: {
|
||||
cookies: [],
|
||||
origins: [],
|
||||
},
|
||||
});
|
||||
|
||||
type TestFilePayload = {
|
||||
name: string;
|
||||
mimeType: string;
|
||||
buffer: Buffer;
|
||||
};
|
||||
|
||||
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
|
||||
|
||||
const createPdfPayload = (name: string): TestFilePayload => ({
|
||||
name,
|
||||
mimeType: 'application/pdf',
|
||||
buffer: examplePdfBuffer,
|
||||
});
|
||||
|
||||
const getCurrentTitles = async (root: Page) => {
|
||||
const titleInputs = getEnvelopeItemTitleInputs(root);
|
||||
const count = await titleInputs.count();
|
||||
|
||||
return await Promise.all(
|
||||
Array.from({ length: count }, async (_, index) => await titleInputs.nth(index).inputValue()),
|
||||
);
|
||||
};
|
||||
|
||||
const openSettingsDialog = async (root: Page) => {
|
||||
await getEnvelopeEditorSettingsTrigger(root).click();
|
||||
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
|
||||
};
|
||||
|
||||
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
await openSettingsDialog(surface.root);
|
||||
await surface.root.locator('input[name="externalId"]').fill(externalId);
|
||||
await surface.root.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
if (!surface.isEmbedded) {
|
||||
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToAddFieldsAndBack = async (root: Page) => {
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect(root.getByText('Selected Recipient')).toBeVisible();
|
||||
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await expect(root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||
};
|
||||
|
||||
const getEnvelopeItemsFromDatabase = async (
|
||||
surface: TEnvelopeEditorSurface,
|
||||
externalId: string,
|
||||
) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
include: {
|
||||
envelopeItems: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return envelope.envelopeItems;
|
||||
};
|
||||
|
||||
type ItemFlowResult = {
|
||||
externalId: string;
|
||||
};
|
||||
|
||||
const uploadFiles = async (root: Page, files: TestFilePayload[]) => {
|
||||
const input = getEnvelopeItemDropzoneInput(root);
|
||||
|
||||
await input.setInputFiles(files);
|
||||
};
|
||||
|
||||
const dragEnvelopeItemByHandle = async ({
|
||||
root,
|
||||
sourceIndex,
|
||||
targetIndex,
|
||||
}: {
|
||||
root: Page;
|
||||
sourceIndex: number;
|
||||
targetIndex: number;
|
||||
}) => {
|
||||
const sourceHandle = getEnvelopeItemDragHandles(root).nth(sourceIndex);
|
||||
const targetHandle = getEnvelopeItemDragHandles(root).nth(targetIndex);
|
||||
|
||||
await expect(sourceHandle).toBeVisible();
|
||||
await expect(targetHandle).toBeVisible();
|
||||
|
||||
const sourceBox = await sourceHandle.boundingBox();
|
||||
const targetBox = await targetHandle.boundingBox();
|
||||
|
||||
if (!sourceBox || !targetBox) {
|
||||
throw new Error('Could not resolve drag handle bounding boxes');
|
||||
}
|
||||
|
||||
const sourceX = sourceBox.x + sourceBox.width / 2;
|
||||
const sourceY = sourceBox.y + sourceBox.height / 2;
|
||||
const targetX = targetBox.x + targetBox.width / 2;
|
||||
const targetY = targetBox.y + targetBox.height / 2;
|
||||
|
||||
await root.mouse.move(sourceX, sourceY);
|
||||
await root.mouse.down();
|
||||
await root.mouse.move(targetX, targetY, { steps: 20 });
|
||||
await root.mouse.up();
|
||||
};
|
||||
|
||||
const runEnvelopeItemCrudFlow = async ({
|
||||
surface,
|
||||
initialCount,
|
||||
filesToUpload,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
initialCount: number;
|
||||
filesToUpload: TestFilePayload[];
|
||||
}): Promise<ItemFlowResult> => {
|
||||
const { root, isEmbedded } = surface;
|
||||
const externalId = `e2e-items-${nanoid()}`;
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
|
||||
await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(initialCount);
|
||||
|
||||
// Upload files.
|
||||
await uploadFiles(root, filesToUpload);
|
||||
|
||||
const expectedCountAfterUpload = initialCount + filesToUpload.length;
|
||||
|
||||
await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(expectedCountAfterUpload);
|
||||
|
||||
if (!isEmbedded) {
|
||||
await navigateToAddFieldsAndBack(root);
|
||||
|
||||
const itemsAfterUpload = await getEnvelopeItemsFromDatabase(surface, externalId);
|
||||
expect(itemsAfterUpload).toHaveLength(expectedCountAfterUpload);
|
||||
}
|
||||
|
||||
// Rename items.
|
||||
await getEnvelopeItemTitleInputs(root).nth(0).fill('Envelope Item A');
|
||||
await getEnvelopeItemTitleInputs(root).nth(1).fill('Envelope Item B');
|
||||
|
||||
await expect(getEnvelopeItemTitleInputs(root).nth(0)).toHaveValue('Envelope Item A');
|
||||
await expect(getEnvelopeItemTitleInputs(root).nth(1)).toHaveValue('Envelope Item B');
|
||||
|
||||
if (!isEmbedded) {
|
||||
await navigateToAddFieldsAndBack(root);
|
||||
|
||||
const itemsAfterRename = await getEnvelopeItemsFromDatabase(surface, externalId);
|
||||
expect(itemsAfterRename[0].title).toBe('Envelope Item A');
|
||||
expect(itemsAfterRename[1].title).toBe('Envelope Item B');
|
||||
}
|
||||
|
||||
// Reorder items.
|
||||
await dragEnvelopeItemByHandle({
|
||||
root,
|
||||
sourceIndex: 0,
|
||||
targetIndex: 1,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(async () => await getCurrentTitles(root))
|
||||
.toEqual(['Envelope Item B', 'Envelope Item A']);
|
||||
|
||||
if (!isEmbedded) {
|
||||
await navigateToAddFieldsAndBack(root);
|
||||
|
||||
const itemsAfterReorder = await getEnvelopeItemsFromDatabase(surface, externalId);
|
||||
expect(itemsAfterReorder[0].title).toBe('Envelope Item B');
|
||||
expect(itemsAfterReorder[1].title).toBe('Envelope Item A');
|
||||
}
|
||||
|
||||
// Remove first item.
|
||||
await getEnvelopeItemRemoveButtons(root).first().click();
|
||||
|
||||
if (!isEmbedded) {
|
||||
await root.getByRole('button', { name: 'Delete' }).click();
|
||||
}
|
||||
|
||||
await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(expectedCountAfterUpload - 1);
|
||||
|
||||
if (!isEmbedded) {
|
||||
await navigateToAddFieldsAndBack(root);
|
||||
|
||||
const itemsAfterRemove = await getEnvelopeItemsFromDatabase(surface, externalId);
|
||||
expect(itemsAfterRemove).toHaveLength(expectedCountAfterUpload - 1);
|
||||
}
|
||||
|
||||
return {
|
||||
externalId,
|
||||
};
|
||||
};
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('add, remove, reorder and retitle items', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
|
||||
const result = await runEnvelopeItemCrudFlow({
|
||||
surface,
|
||||
initialCount: 1,
|
||||
filesToUpload: [createPdfPayload('document-item-added.pdf')],
|
||||
});
|
||||
|
||||
const items = await getEnvelopeItemsFromDatabase(surface, result.externalId);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].title).toBe('Envelope Item A');
|
||||
expect(items[0].order).toBe(2); // Expect order 2 because deleting items does not drop the order of sequential items.
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('add, remove, reorder and retitle items', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
|
||||
const result = await runEnvelopeItemCrudFlow({
|
||||
surface,
|
||||
initialCount: 1,
|
||||
filesToUpload: [createPdfPayload('template-item-added.pdf')],
|
||||
});
|
||||
|
||||
const items = await getEnvelopeItemsFromDatabase(surface, result.externalId);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].title).toBe('Envelope Item A');
|
||||
expect(items[0].order).toBe(2); // Expect order 2 because deleting items does not drop the order of sequential items.
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded create', () => {
|
||||
test('add, remove, reorder and retitle items', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-items',
|
||||
});
|
||||
|
||||
const result = await runEnvelopeItemCrudFlow({
|
||||
surface,
|
||||
initialCount: 0,
|
||||
filesToUpload: [
|
||||
createPdfPayload('embedded-document-item-a.pdf'),
|
||||
createPdfPayload('embedded-document-item-b.pdf'),
|
||||
],
|
||||
});
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
const items = await getEnvelopeItemsFromDatabase(surface, result.externalId);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].title).toBe('Envelope Item A');
|
||||
expect(items[0].order).toBe(1); // Expect order 1 because this is a one shot create via embedding. There are no incremental updates.
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded edit', () => {
|
||||
test('add, remove, reorder and retitle items', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-items',
|
||||
});
|
||||
|
||||
const result = await runEnvelopeItemCrudFlow({
|
||||
surface,
|
||||
initialCount: 1,
|
||||
filesToUpload: [createPdfPayload('embedded-template-item-updated.pdf')],
|
||||
});
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
const items = await getEnvelopeItemsFromDatabase(surface, result.externalId);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].title).toBe('Envelope Item A');
|
||||
expect(items[0].order).toBe(2); // Expect order 2 because deleting items does not drop the order of sequential items.
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
||||
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import {
|
||||
type TEnvelopeEditorSurface,
|
||||
addEnvelopeItemPdf,
|
||||
assertRecipientRole,
|
||||
clickAddMyselfButton,
|
||||
clickAddSignerButton,
|
||||
clickEnvelopeEditorStep,
|
||||
getEnvelopeEditorSettingsTrigger,
|
||||
getRecipientEmailInputs,
|
||||
getRecipientNameInputs,
|
||||
getRecipientRemoveButtons,
|
||||
getSigningOrderInputs,
|
||||
openDocumentEnvelopeEditor,
|
||||
openEmbeddedEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
persistEmbeddedEnvelope,
|
||||
setRecipientEmail,
|
||||
setRecipientName,
|
||||
setRecipientRole,
|
||||
setSigningOrderValue,
|
||||
toggleAllowDictateSigners,
|
||||
toggleSigningOrder,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
type RecipientFlowResult = {
|
||||
externalId: string;
|
||||
expectedRecipientsBySigningOrder: Array<{
|
||||
email: string;
|
||||
name: string;
|
||||
role: RecipientRole;
|
||||
signingOrder: number;
|
||||
}>;
|
||||
removedRecipientEmail: string;
|
||||
};
|
||||
|
||||
const TEST_RECIPIENT_VALUES = {
|
||||
secondRecipient: {
|
||||
email: 'recipient-two@example.com',
|
||||
name: 'Recipient Two',
|
||||
},
|
||||
thirdRecipient: {
|
||||
email: 'recipient-three@example.com',
|
||||
name: 'Recipient Three',
|
||||
},
|
||||
embeddedPrimaryRecipient: {
|
||||
email: 'embedded-primary@example.com',
|
||||
name: 'Embedded Primary',
|
||||
},
|
||||
};
|
||||
|
||||
const openSettingsDialog = async (root: Page) => {
|
||||
await getEnvelopeEditorSettingsTrigger(root).click();
|
||||
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
|
||||
};
|
||||
|
||||
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
await openSettingsDialog(surface.root);
|
||||
await surface.root.locator('input[name="externalId"]').fill(externalId);
|
||||
await surface.root.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
if (!surface.isEmbedded) {
|
||||
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToAddFieldsAndBack = async (root: Page) => {
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect(root.getByText('Selected Recipient')).toBeVisible();
|
||||
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await expect(root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||
};
|
||||
|
||||
const runRecipientFlow = async (surface: TEnvelopeEditorSurface): Promise<RecipientFlowResult> => {
|
||||
const externalId = `e2e-recipients-${nanoid()}`;
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
|
||||
let primaryRecipient = TEST_RECIPIENT_VALUES.embeddedPrimaryRecipient;
|
||||
|
||||
if (surface.isEmbedded) {
|
||||
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toHaveCount(0);
|
||||
await setRecipientEmail(surface.root, 0, primaryRecipient.email);
|
||||
await setRecipientName(surface.root, 0, primaryRecipient.name);
|
||||
} else {
|
||||
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toBeVisible();
|
||||
await clickAddMyselfButton(surface.root);
|
||||
|
||||
primaryRecipient = {
|
||||
email: surface.userEmail,
|
||||
name: surface.userName,
|
||||
};
|
||||
|
||||
await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(surface.userEmail);
|
||||
}
|
||||
|
||||
await clickAddSignerButton(surface.root);
|
||||
await clickAddSignerButton(surface.root);
|
||||
|
||||
await setRecipientEmail(surface.root, 1, TEST_RECIPIENT_VALUES.secondRecipient.email);
|
||||
await setRecipientName(surface.root, 1, TEST_RECIPIENT_VALUES.secondRecipient.name);
|
||||
|
||||
await setRecipientEmail(surface.root, 2, TEST_RECIPIENT_VALUES.thirdRecipient.email);
|
||||
await setRecipientName(surface.root, 2, TEST_RECIPIENT_VALUES.thirdRecipient.name);
|
||||
|
||||
await setRecipientRole(surface.root, 1, 'Needs to approve');
|
||||
await setRecipientRole(surface.root, 2, 'Receives copy');
|
||||
|
||||
await getRecipientRemoveButtons(surface.root).nth(2).click();
|
||||
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
|
||||
|
||||
await toggleSigningOrder(surface.root, true);
|
||||
await expect(getSigningOrderInputs(surface.root)).toHaveCount(2);
|
||||
await setSigningOrderValue(surface.root, 0, 2);
|
||||
|
||||
await toggleAllowDictateSigners(surface.root, true);
|
||||
|
||||
await navigateToAddFieldsAndBack(surface.root);
|
||||
|
||||
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
|
||||
await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(
|
||||
TEST_RECIPIENT_VALUES.secondRecipient.email,
|
||||
);
|
||||
await expect(getRecipientEmailInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.email);
|
||||
|
||||
await expect(getRecipientNameInputs(surface.root).nth(0)).toHaveValue(
|
||||
TEST_RECIPIENT_VALUES.secondRecipient.name,
|
||||
);
|
||||
await expect(getRecipientNameInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.name);
|
||||
|
||||
await assertRecipientRole(surface.root, 0, 'Needs to approve');
|
||||
await assertRecipientRole(surface.root, 1, 'Needs to sign');
|
||||
|
||||
await expect(surface.root.locator('#signingOrder')).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(surface.root.locator('#allowDictateNextSigner')).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true',
|
||||
);
|
||||
await expect(getSigningOrderInputs(surface.root).nth(0)).toHaveValue('1');
|
||||
await expect(getSigningOrderInputs(surface.root).nth(1)).toHaveValue('2');
|
||||
|
||||
return {
|
||||
externalId,
|
||||
removedRecipientEmail: TEST_RECIPIENT_VALUES.thirdRecipient.email,
|
||||
expectedRecipientsBySigningOrder: [
|
||||
{
|
||||
email: TEST_RECIPIENT_VALUES.secondRecipient.email,
|
||||
name: TEST_RECIPIENT_VALUES.secondRecipient.name,
|
||||
role: RecipientRole.APPROVER,
|
||||
signingOrder: 1,
|
||||
},
|
||||
{
|
||||
email: primaryRecipient.email,
|
||||
name: primaryRecipient.name,
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const assertRecipientsPersistedInDatabase = async ({
|
||||
surface,
|
||||
externalId,
|
||||
expectedRecipientsBySigningOrder,
|
||||
removedRecipientEmail,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
externalId: string;
|
||||
expectedRecipientsBySigningOrder: RecipientFlowResult['expectedRecipientsBySigningOrder'];
|
||||
removedRecipientEmail: string;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
recipients: {
|
||||
orderBy: {
|
||||
signingOrder: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope.recipients).toHaveLength(expectedRecipientsBySigningOrder.length);
|
||||
expect(envelope.documentMeta.signingOrder).toBe(DocumentSigningOrder.SEQUENTIAL);
|
||||
expect(envelope.documentMeta.allowDictateNextSigner).toBe(true);
|
||||
|
||||
expectedRecipientsBySigningOrder.forEach((expectedRecipient, index) => {
|
||||
const recipient = envelope.recipients[index];
|
||||
|
||||
expect(recipient.email).toBe(expectedRecipient.email);
|
||||
expect(recipient.name).toBe(expectedRecipient.name);
|
||||
expect(recipient.role).toBe(expectedRecipient.role);
|
||||
expect(recipient.signingOrder).toBe(expectedRecipient.signingOrder);
|
||||
});
|
||||
|
||||
expect(envelope.recipients.some((recipient) => recipient.email === removedRecipientEmail)).toBe(
|
||||
false,
|
||||
);
|
||||
};
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('add myself, CRUD, roles, signing order and dictate signers', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runRecipientFlow(surface);
|
||||
|
||||
await assertRecipientsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('add myself, CRUD, roles, signing order and dictate signers', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runRecipientFlow(surface);
|
||||
|
||||
await assertRecipientsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded create', () => {
|
||||
test('CRUD, roles, signing order and dictate signers', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-recipients',
|
||||
});
|
||||
|
||||
await addEnvelopeItemPdf(surface.root, 'embedded-document-recipients.pdf');
|
||||
|
||||
const result = await runRecipientFlow(surface);
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertRecipientsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded edit', () => {
|
||||
test('CRUD, roles, signing order and dictate signers', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-recipients',
|
||||
});
|
||||
|
||||
const result = await runRecipientFlow(surface);
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertRecipientsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,411 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
import { DocumentDistributionMethod, DocumentVisibility } from '@prisma/client';
|
||||
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import {
|
||||
type TEnvelopeEditorSurface,
|
||||
getEnvelopeEditorSettingsTrigger,
|
||||
openDocumentEnvelopeEditor,
|
||||
openEmbeddedEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
persistEmbeddedEnvelope,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
type SettingsFlowData = {
|
||||
externalId: string;
|
||||
isEmbedded: boolean;
|
||||
};
|
||||
|
||||
const TEST_SETTINGS_VALUES = {
|
||||
replyTo: 'e2e-settings@example.com',
|
||||
redirectUrl: 'https://example.com/e2e-settings-complete',
|
||||
subject: 'E2E settings subject',
|
||||
message: 'E2E settings message',
|
||||
language: 'French',
|
||||
dateFormat: 'DD/MM/YYYY',
|
||||
timezone: 'Europe/London',
|
||||
distributionMethod: 'None',
|
||||
expirationMode: 'Custom duration',
|
||||
expirationAmount: 5,
|
||||
expirationUnit: 'Weeks',
|
||||
accessAuth: 'Require account',
|
||||
actionAuth: 'Require password',
|
||||
visibility: 'Managers and above',
|
||||
};
|
||||
|
||||
const DB_EXPECTED_VALUES = {
|
||||
language: 'fr',
|
||||
dateFormat: 'dd/MM/yyyy',
|
||||
timezone: 'Europe/London',
|
||||
distributionMethod: DocumentDistributionMethod.NONE,
|
||||
envelopeExpirationPeriod: { unit: 'week', amount: 5 },
|
||||
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
globalAccessAuth: ['ACCOUNT'],
|
||||
globalActionAuth: ['PASSWORD'],
|
||||
emailSettings: {
|
||||
recipientSigned: false,
|
||||
recipientSigningRequest: false,
|
||||
recipientRemoved: false,
|
||||
documentPending: false,
|
||||
documentCompleted: false,
|
||||
documentDeleted: false,
|
||||
ownerDocumentCompleted: false,
|
||||
ownerRecipientExpired: false,
|
||||
},
|
||||
};
|
||||
|
||||
const openSettingsDialog = async (root: Page) => {
|
||||
await getEnvelopeEditorSettingsTrigger(root).click();
|
||||
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
|
||||
};
|
||||
|
||||
const clickSettingsDialogHeader = async (root: Page) => {
|
||||
await root.locator('[data-testid="envelope-editor-settings-dialog-header"]').click();
|
||||
};
|
||||
|
||||
const getComboboxByLabel = (root: Page, label: string) =>
|
||||
root
|
||||
.locator(`label:has-text("${label}")`)
|
||||
.locator('xpath=..')
|
||||
.locator('[role="combobox"]')
|
||||
.first();
|
||||
|
||||
const selectMultiSelectOption = async (
|
||||
root: Page,
|
||||
dataTestId: 'documentAccessSelectValue' | 'documentActionSelectValue',
|
||||
optionLabel: string,
|
||||
) => {
|
||||
const select = root.locator(`[data-testid="${dataTestId}"]`);
|
||||
|
||||
await select.click();
|
||||
await root.locator('[cmdk-item]').filter({ hasText: optionLabel }).first().click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
};
|
||||
|
||||
const runSettingsFlow = async (
|
||||
{ root }: TEnvelopeEditorSurface,
|
||||
{ externalId, isEmbedded }: SettingsFlowData,
|
||||
) => {
|
||||
await openSettingsDialog(root);
|
||||
|
||||
await getComboboxByLabel(root, 'Language').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.language }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
const signatureTypesCombobox = getComboboxByLabel(root, 'Allowed Signature Types');
|
||||
|
||||
await signatureTypesCombobox.click();
|
||||
await root.getByRole('option', { name: 'Upload' }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await getComboboxByLabel(root, 'Date Format').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.dateFormat, exact: true }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await getComboboxByLabel(root, 'Time Zone').click();
|
||||
await root.locator('[cmdk-input]').last().fill(TEST_SETTINGS_VALUES.timezone);
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.timezone }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await root.locator('input[name="externalId"]').fill(externalId);
|
||||
await root.locator('input[name="meta.redirectUrl"]').fill(TEST_SETTINGS_VALUES.redirectUrl);
|
||||
|
||||
await root.locator('[data-testid="documentDistributionMethodSelectValue"]').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.distributionMethod }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await getComboboxByLabel(root, 'Expiration').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.expirationMode }).click();
|
||||
await root.getByRole('spinbutton').clear();
|
||||
await root.getByRole('spinbutton').fill(String(TEST_SETTINGS_VALUES.expirationAmount));
|
||||
const expirationUnitTrigger = root
|
||||
.locator('button[role="combobox"]')
|
||||
.filter({ hasText: /Months|Days|Weeks|Years/ })
|
||||
.first();
|
||||
await expirationUnitTrigger.click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.expirationUnit }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await root.getByRole('button', { name: 'Email' }).click();
|
||||
await root.locator('#recipientSigned').click();
|
||||
await root.locator('#recipientSigningRequest').click();
|
||||
await root.locator('#recipientRemoved').click();
|
||||
await root.locator('#documentPending').click();
|
||||
await root.locator('#documentCompleted').click();
|
||||
await root.locator('#documentDeleted').click();
|
||||
await root.locator('#ownerDocumentCompleted').click();
|
||||
await root.locator('#ownerRecipientExpired').click();
|
||||
await root.locator('input[name="meta.emailReplyTo"]').fill(TEST_SETTINGS_VALUES.replyTo);
|
||||
await root.locator('input[name="meta.subject"]').fill(TEST_SETTINGS_VALUES.subject);
|
||||
await root.locator('textarea[name="meta.message"]').fill(TEST_SETTINGS_VALUES.message);
|
||||
|
||||
await root.getByRole('button', { name: 'Security' }).click();
|
||||
await selectMultiSelectOption(root, 'documentAccessSelectValue', TEST_SETTINGS_VALUES.accessAuth);
|
||||
|
||||
const actionAuthSelect = root.locator('[data-testid="documentActionSelectValue"]');
|
||||
const hasActionAuthSelect = (await actionAuthSelect.count()) > 0;
|
||||
|
||||
if (hasActionAuthSelect) {
|
||||
await selectMultiSelectOption(
|
||||
root,
|
||||
'documentActionSelectValue',
|
||||
TEST_SETTINGS_VALUES.actionAuth,
|
||||
);
|
||||
}
|
||||
|
||||
if (isEmbedded) {
|
||||
await expect(root.locator('[data-testid="documentVisibilitySelectValue"]')).toHaveCount(0);
|
||||
} else {
|
||||
await root.locator('[data-testid="documentVisibilitySelectValue"]').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.visibility }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
}
|
||||
|
||||
await root.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
if (!isEmbedded) {
|
||||
await expectToastTextToBeVisible(root, 'Envelope updated');
|
||||
}
|
||||
|
||||
await openSettingsDialog(root);
|
||||
|
||||
await expect(root.locator('input[name="externalId"]')).toHaveValue(externalId);
|
||||
await expect(root.locator('input[name="meta.redirectUrl"]')).toHaveValue(
|
||||
TEST_SETTINGS_VALUES.redirectUrl,
|
||||
);
|
||||
await expect(getComboboxByLabel(root, 'Language')).toContainText(TEST_SETTINGS_VALUES.language);
|
||||
await expect(getComboboxByLabel(root, 'Allowed Signature Types')).not.toContainText('Upload');
|
||||
await expect(getComboboxByLabel(root, 'Date Format')).toContainText(
|
||||
TEST_SETTINGS_VALUES.dateFormat,
|
||||
);
|
||||
await expect(getComboboxByLabel(root, 'Time Zone')).toContainText(TEST_SETTINGS_VALUES.timezone);
|
||||
await expect(root.locator('[data-testid="documentDistributionMethodSelectValue"]')).toContainText(
|
||||
TEST_SETTINGS_VALUES.distributionMethod,
|
||||
);
|
||||
await expect(getComboboxByLabel(root, 'Expiration')).toContainText(
|
||||
TEST_SETTINGS_VALUES.expirationMode,
|
||||
);
|
||||
await expect(root.getByRole('spinbutton')).toHaveValue(
|
||||
String(TEST_SETTINGS_VALUES.expirationAmount),
|
||||
);
|
||||
await expect(
|
||||
root
|
||||
.locator('button[role="combobox"]')
|
||||
.filter({ hasText: TEST_SETTINGS_VALUES.expirationUnit })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
await root.getByRole('button', { name: 'Email' }).click();
|
||||
await expect(root.locator('#recipientSigned')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#recipientSigningRequest')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#recipientRemoved')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#documentPending')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#documentCompleted')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#documentDeleted')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#ownerDocumentCompleted')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#ownerRecipientExpired')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('input[name="meta.emailReplyTo"]')).toHaveValue(
|
||||
TEST_SETTINGS_VALUES.replyTo,
|
||||
);
|
||||
await expect(root.locator('input[name="meta.subject"]')).toHaveValue(
|
||||
TEST_SETTINGS_VALUES.subject,
|
||||
);
|
||||
await expect(root.locator('textarea[name="meta.message"]')).toHaveValue(
|
||||
TEST_SETTINGS_VALUES.message,
|
||||
);
|
||||
|
||||
await root.getByRole('button', { name: 'Security' }).click();
|
||||
await expect(root.locator('[data-testid="documentAccessSelectValue"]')).toContainText(
|
||||
TEST_SETTINGS_VALUES.accessAuth,
|
||||
);
|
||||
|
||||
if (hasActionAuthSelect) {
|
||||
await expect(root.locator('[data-testid="documentActionSelectValue"]')).toContainText(
|
||||
TEST_SETTINGS_VALUES.actionAuth,
|
||||
);
|
||||
}
|
||||
|
||||
if (isEmbedded) {
|
||||
await expect(root.locator('[data-testid="documentVisibilitySelectValue"]')).toHaveCount(0);
|
||||
} else {
|
||||
await expect(root.locator('[data-testid="documentVisibilitySelectValue"]')).toContainText(
|
||||
TEST_SETTINGS_VALUES.visibility,
|
||||
);
|
||||
}
|
||||
|
||||
await root.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
if (!isEmbedded) {
|
||||
await expectToastTextToBeVisible(root, 'Envelope updated');
|
||||
}
|
||||
|
||||
return {
|
||||
hasActionAuthSelect,
|
||||
};
|
||||
};
|
||||
|
||||
const assertEnvelopeSettingsPersistedInDatabase = async ({
|
||||
externalId,
|
||||
surface,
|
||||
hasActionAuthSelect,
|
||||
shouldAssertVisibility,
|
||||
}: {
|
||||
externalId: string;
|
||||
surface: TEnvelopeEditorSurface;
|
||||
hasActionAuthSelect: boolean;
|
||||
shouldAssertVisibility: boolean;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope.externalId).toBe(externalId);
|
||||
if (shouldAssertVisibility) {
|
||||
expect(envelope.visibility).toBe(DB_EXPECTED_VALUES.visibility);
|
||||
}
|
||||
expect(envelope.documentMeta.language).toBe(DB_EXPECTED_VALUES.language);
|
||||
expect(envelope.documentMeta.dateFormat).toBe(DB_EXPECTED_VALUES.dateFormat);
|
||||
expect(envelope.documentMeta.timezone).toBe(DB_EXPECTED_VALUES.timezone);
|
||||
expect(envelope.documentMeta.distributionMethod).toBe(DB_EXPECTED_VALUES.distributionMethod);
|
||||
expect(envelope.documentMeta.envelopeExpirationPeriod).toEqual(
|
||||
DB_EXPECTED_VALUES.envelopeExpirationPeriod,
|
||||
);
|
||||
expect(envelope.documentMeta.redirectUrl).toBe(TEST_SETTINGS_VALUES.redirectUrl);
|
||||
expect(envelope.documentMeta.emailReplyTo).toBe(TEST_SETTINGS_VALUES.replyTo);
|
||||
expect(envelope.documentMeta.subject).toBe(TEST_SETTINGS_VALUES.subject);
|
||||
expect(envelope.documentMeta.message).toBe(TEST_SETTINGS_VALUES.message);
|
||||
expect(envelope.documentMeta.drawSignatureEnabled).toBe(true);
|
||||
expect(envelope.documentMeta.typedSignatureEnabled).toBe(true);
|
||||
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(false);
|
||||
expect(envelope.documentMeta.emailSettings).toMatchObject(DB_EXPECTED_VALUES.emailSettings);
|
||||
|
||||
const authOptions = parseAuthOptions(envelope.authOptions);
|
||||
|
||||
expect(authOptions.globalAccessAuth ?? []).toEqual(DB_EXPECTED_VALUES.globalAccessAuth);
|
||||
|
||||
if (hasActionAuthSelect) {
|
||||
expect(authOptions.globalActionAuth ?? []).toEqual(DB_EXPECTED_VALUES.globalActionAuth);
|
||||
}
|
||||
};
|
||||
|
||||
const parseAuthOptions = (
|
||||
authOptions: unknown,
|
||||
): { globalAccessAuth: string[]; globalActionAuth: string[] } => {
|
||||
if (!isRecord(authOptions)) {
|
||||
return {
|
||||
globalAccessAuth: [],
|
||||
globalActionAuth: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
globalAccessAuth: Array.isArray(authOptions.globalAccessAuth)
|
||||
? authOptions.globalAccessAuth.filter((entry): entry is string => typeof entry === 'string')
|
||||
: [],
|
||||
globalActionAuth: Array.isArray(authOptions.globalActionAuth)
|
||||
? authOptions.globalActionAuth.filter((entry): entry is string => typeof entry === 'string')
|
||||
: [],
|
||||
};
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('update and persist settings', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const externalId = `e2e-settings-${nanoid()}`;
|
||||
|
||||
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
|
||||
externalId,
|
||||
isEmbedded: false,
|
||||
});
|
||||
|
||||
await assertEnvelopeSettingsPersistedInDatabase({
|
||||
externalId,
|
||||
surface,
|
||||
hasActionAuthSelect,
|
||||
shouldAssertVisibility: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('update and persist settings', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const externalId = `e2e-settings-${nanoid()}`;
|
||||
|
||||
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
|
||||
externalId,
|
||||
isEmbedded: false,
|
||||
});
|
||||
|
||||
await assertEnvelopeSettingsPersistedInDatabase({
|
||||
externalId,
|
||||
surface,
|
||||
hasActionAuthSelect,
|
||||
shouldAssertVisibility: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded create', () => {
|
||||
test('update and persist settings', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-settings',
|
||||
});
|
||||
const externalId = `e2e-settings-${nanoid()}`;
|
||||
|
||||
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
|
||||
externalId,
|
||||
isEmbedded: true,
|
||||
});
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertEnvelopeSettingsPersistedInDatabase({
|
||||
externalId,
|
||||
surface,
|
||||
hasActionAuthSelect,
|
||||
shouldAssertVisibility: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded edit', () => {
|
||||
test('update and persist settings', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-settings',
|
||||
});
|
||||
const externalId = `e2e-settings-${nanoid()}`;
|
||||
|
||||
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
|
||||
externalId,
|
||||
isEmbedded: true,
|
||||
});
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertEnvelopeSettingsPersistedInDatabase({
|
||||
externalId,
|
||||
surface,
|
||||
hasActionAuthSelect,
|
||||
shouldAssertVisibility: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,429 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { DEFAULT_EMBEDDED_EDITOR_CONFIG } from '@documenso/lib/types/envelope-editor';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from './authentication';
|
||||
|
||||
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
|
||||
|
||||
export type TEnvelopeEditorSurface = {
|
||||
root: Page;
|
||||
isEmbedded: boolean;
|
||||
envelopeId?: string;
|
||||
envelopeType: TEnvelopeEditorType;
|
||||
userId: number;
|
||||
userEmail: string;
|
||||
userName: string;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export type TEnvelopeEditorType = 'DOCUMENT' | 'TEMPLATE';
|
||||
|
||||
type TEmbeddedHashCommonOptions = {
|
||||
externalId?: string;
|
||||
features?: typeof DEFAULT_EMBEDDED_EDITOR_CONFIG;
|
||||
css?: string;
|
||||
cssVars?: Record<string, string>;
|
||||
darkModeDisabled?: boolean;
|
||||
};
|
||||
|
||||
const encodeEmbeddedOptions = (options: Record<string, unknown>) => {
|
||||
const encodedPayload = encodeURIComponent(JSON.stringify(options));
|
||||
|
||||
if (typeof btoa === 'function') {
|
||||
return btoa(encodedPayload);
|
||||
}
|
||||
|
||||
return Buffer.from(encodedPayload, 'utf8').toString('base64');
|
||||
};
|
||||
|
||||
export const createEmbeddedEnvelopeCreateHash = ({
|
||||
envelopeType,
|
||||
externalId,
|
||||
features = DEFAULT_EMBEDDED_EDITOR_CONFIG,
|
||||
css,
|
||||
cssVars,
|
||||
darkModeDisabled,
|
||||
}: { envelopeType: TEnvelopeEditorType } & TEmbeddedHashCommonOptions) => {
|
||||
return encodeEmbeddedOptions({
|
||||
externalId,
|
||||
type: envelopeType,
|
||||
features,
|
||||
css,
|
||||
cssVars,
|
||||
darkModeDisabled,
|
||||
});
|
||||
};
|
||||
|
||||
export const createEmbeddedEnvelopeEditHash = ({
|
||||
externalId,
|
||||
features = DEFAULT_EMBEDDED_EDITOR_CONFIG,
|
||||
css,
|
||||
cssVars,
|
||||
darkModeDisabled,
|
||||
}: TEmbeddedHashCommonOptions) => {
|
||||
return encodeEmbeddedOptions({
|
||||
externalId,
|
||||
features,
|
||||
css,
|
||||
cssVars,
|
||||
darkModeDisabled,
|
||||
});
|
||||
};
|
||||
|
||||
export const openDocumentEnvelopeEditor = async (page: Page): Promise<TEnvelopeEditorSurface> => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const document = await seedBlankDocument(user, team.id, {
|
||||
internalVersion: 2,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`,
|
||||
});
|
||||
|
||||
return {
|
||||
root: page,
|
||||
isEmbedded: false,
|
||||
envelopeId: document.id,
|
||||
envelopeType: 'DOCUMENT',
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
userName: user.name ?? '',
|
||||
teamId: team.id,
|
||||
};
|
||||
};
|
||||
|
||||
export const openTemplateEnvelopeEditor = async (page: Page): Promise<TEnvelopeEditorSurface> => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedBlankTemplate(user, team.id, {
|
||||
createTemplateOptions: {
|
||||
title: `E2E Template ${Date.now()}`,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/templates/${template.id}/edit?step=uploadAndRecipients`,
|
||||
});
|
||||
|
||||
return {
|
||||
root: page,
|
||||
isEmbedded: false,
|
||||
envelopeId: template.id,
|
||||
envelopeType: 'TEMPLATE',
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
userName: user.name ?? '',
|
||||
teamId: team.id,
|
||||
};
|
||||
};
|
||||
|
||||
type OpenEmbeddedEnvelopeEditorOptions = {
|
||||
envelopeType: TEnvelopeEditorType;
|
||||
mode?: 'create' | 'edit';
|
||||
tokenNamePrefix?: string;
|
||||
externalId?: string;
|
||||
features?: typeof DEFAULT_EMBEDDED_EDITOR_CONFIG;
|
||||
css?: string;
|
||||
cssVars?: Record<string, string>;
|
||||
darkModeDisabled?: boolean;
|
||||
};
|
||||
|
||||
export const openEmbeddedEnvelopeEditor = async (
|
||||
page: Page,
|
||||
{
|
||||
envelopeType,
|
||||
mode = 'create',
|
||||
tokenNamePrefix = 'e2e-embed',
|
||||
externalId,
|
||||
features,
|
||||
css,
|
||||
cssVars,
|
||||
darkModeDisabled,
|
||||
}: OpenEmbeddedEnvelopeEditorOptions,
|
||||
): Promise<TEnvelopeEditorSurface> => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const envelopeToEdit =
|
||||
mode === 'edit'
|
||||
? envelopeType === 'DOCUMENT'
|
||||
? await seedBlankDocument(user, team.id, {
|
||||
internalVersion: 2,
|
||||
})
|
||||
: await seedBlankTemplate(user, team.id, {
|
||||
createTemplateOptions: {
|
||||
title: `E2E Template ${Date.now()}`,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: `${tokenNamePrefix}-${envelopeType.toLowerCase()}`,
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const embeddedToken = await resolveEmbeddingToken(
|
||||
page,
|
||||
token,
|
||||
envelopeToEdit ? `envelopeId:${envelopeToEdit.id}` : undefined,
|
||||
);
|
||||
|
||||
if (envelopeToEdit) {
|
||||
const hash = createEmbeddedEnvelopeEditHash({
|
||||
externalId,
|
||||
features: features ?? DEFAULT_EMBEDDED_EDITOR_CONFIG,
|
||||
css,
|
||||
cssVars,
|
||||
darkModeDisabled,
|
||||
});
|
||||
|
||||
await page.goto(
|
||||
`/embed/v2/authoring/envelope/edit/${envelopeToEdit.id}?token=${encodeURIComponent(embeddedToken)}#${hash}`,
|
||||
);
|
||||
} else {
|
||||
const hash = createEmbeddedEnvelopeCreateHash({
|
||||
envelopeType,
|
||||
externalId,
|
||||
features,
|
||||
css,
|
||||
cssVars,
|
||||
darkModeDisabled,
|
||||
});
|
||||
|
||||
await page.goto(
|
||||
`/embed/v2/authoring/envelope/create?token=${encodeURIComponent(embeddedToken)}#${hash}`,
|
||||
);
|
||||
}
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
return {
|
||||
root: page,
|
||||
isEmbedded: true,
|
||||
envelopeId: envelopeToEdit?.id,
|
||||
envelopeType,
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
userName: user.name ?? '',
|
||||
teamId: team.id,
|
||||
};
|
||||
};
|
||||
|
||||
export const getEnvelopeEditorSettingsTrigger = (root: Page) =>
|
||||
root.locator('button[title="Settings"]');
|
||||
|
||||
export const getEnvelopeItemTitleInputs = (root: Page) =>
|
||||
root.locator('[data-testid^="envelope-item-title-input-"]');
|
||||
|
||||
export const getEnvelopeItemDragHandles = (root: Page) =>
|
||||
root.locator('[data-testid^="envelope-item-drag-handle-"]');
|
||||
|
||||
export const getEnvelopeItemRemoveButtons = (root: Page) =>
|
||||
root.locator('[data-testid^="envelope-item-remove-button-"]');
|
||||
|
||||
export const getEnvelopeItemDropzoneInput = (root: Page) =>
|
||||
root.locator('[data-testid="envelope-item-dropzone"] input[type="file"]');
|
||||
|
||||
export const addEnvelopeItemPdf = async (root: Page, fileName = 'embedded-envelope-item.pdf') => {
|
||||
await getEnvelopeItemDropzoneInput(root).setInputFiles({
|
||||
name: fileName,
|
||||
mimeType: 'application/pdf',
|
||||
buffer: examplePdfBuffer,
|
||||
});
|
||||
};
|
||||
|
||||
export const getRecipientEmailInputs = (root: Page) =>
|
||||
root.locator('[data-testid="signer-email-input"]');
|
||||
|
||||
export const getRecipientNameInputs = (root: Page) =>
|
||||
root.locator('input[placeholder^="Recipient "]');
|
||||
|
||||
export const getRecipientRows = (root: Page) =>
|
||||
root.locator('[data-testid="signer-email-input"]').locator('xpath=ancestor::fieldset[1]');
|
||||
|
||||
export const getRecipientRemoveButtons = (root: Page) =>
|
||||
root.locator('[data-testid="remove-signer-button"]');
|
||||
|
||||
export const getSigningOrderInputs = (root: Page) =>
|
||||
root.locator('[data-testid="signing-order-input"]');
|
||||
|
||||
export const clickEnvelopeEditorStep = async (
|
||||
root: Page,
|
||||
stepId: 'upload' | 'addFields' | 'preview',
|
||||
) => {
|
||||
await root.waitForTimeout(200);
|
||||
await root.locator(`[data-testid="envelope-editor-step-${stepId}"]`).first().click();
|
||||
};
|
||||
|
||||
export const clickAddMyselfButton = async (root: Page) => {
|
||||
await root.getByRole('button', { name: 'Add Myself' }).click();
|
||||
};
|
||||
|
||||
export const clickAddSignerButton = async (root: Page) => {
|
||||
await root.getByRole('button', { name: 'Add Signer' }).click();
|
||||
};
|
||||
|
||||
export const setRecipientEmail = async (root: Page, index: number, email: string) => {
|
||||
await getRecipientEmailInputs(root).nth(index).fill(email);
|
||||
};
|
||||
|
||||
export const setRecipientName = async (root: Page, index: number, name: string) => {
|
||||
await getRecipientNameInputs(root).nth(index).fill(name);
|
||||
};
|
||||
|
||||
export const setRecipientRole = async (
|
||||
root: Page,
|
||||
index: number,
|
||||
roleLabel:
|
||||
| 'Needs to sign'
|
||||
| 'Needs to approve'
|
||||
| 'Needs to view'
|
||||
| 'Receives copy'
|
||||
| 'Can prepare',
|
||||
) => {
|
||||
const row = getRecipientRows(root).nth(index);
|
||||
|
||||
await row.locator('button[role="combobox"]').first().click();
|
||||
await root.getByRole('option', { name: roleLabel }).click();
|
||||
};
|
||||
|
||||
export const assertRecipientRole = async (
|
||||
root: Page,
|
||||
index: number,
|
||||
roleLabel:
|
||||
| 'Needs to sign'
|
||||
| 'Needs to approve'
|
||||
| 'Needs to view'
|
||||
| 'Receives copy'
|
||||
| 'Can prepare',
|
||||
) => {
|
||||
const row = getRecipientRows(root).nth(index);
|
||||
const roleValueByLabel: Record<typeof roleLabel, string> = {
|
||||
'Needs to sign': 'SIGNER',
|
||||
'Needs to approve': 'APPROVER',
|
||||
'Needs to view': 'VIEWER',
|
||||
'Receives copy': 'CC',
|
||||
'Can prepare': 'ASSISTANT',
|
||||
};
|
||||
|
||||
await expect(row.locator('button[role="combobox"]').first()).toHaveAttribute(
|
||||
'title',
|
||||
roleValueByLabel[roleLabel],
|
||||
);
|
||||
};
|
||||
|
||||
export const toggleSigningOrder = async (root: Page, enabled: boolean) => {
|
||||
const checkbox = root.locator('#signingOrder');
|
||||
const currentState = await checkbox.getAttribute('aria-checked');
|
||||
const isEnabled = currentState === 'true';
|
||||
|
||||
if (isEnabled !== enabled) {
|
||||
await checkbox.click();
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleAllowDictateSigners = async (root: Page, enabled: boolean) => {
|
||||
const checkbox = root.locator('#allowDictateNextSigner');
|
||||
const currentState = await checkbox.getAttribute('aria-checked');
|
||||
const isEnabled = currentState === 'true';
|
||||
|
||||
if (isEnabled !== enabled) {
|
||||
await checkbox.click();
|
||||
}
|
||||
};
|
||||
|
||||
export const setSigningOrderValue = async (root: Page, index: number, value: number) => {
|
||||
const input = getSigningOrderInputs(root).nth(index);
|
||||
await input.fill(value.toString());
|
||||
await input.blur();
|
||||
};
|
||||
|
||||
export const persistEmbeddedEnvelope = async (surface: TEnvelopeEditorSurface) => {
|
||||
if (!surface.isEmbedded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isUpdateFlow =
|
||||
(await surface.root.getByRole('button', { name: 'Update Document' }).count()) > 0 ||
|
||||
(await surface.root.getByRole('button', { name: 'Update Template' }).count()) > 0;
|
||||
|
||||
const actionButtonName = isUpdateFlow
|
||||
? surface.envelopeType === 'DOCUMENT'
|
||||
? 'Update Document'
|
||||
: 'Update Template'
|
||||
: surface.envelopeType === 'DOCUMENT'
|
||||
? 'Create Document'
|
||||
: 'Create Template';
|
||||
|
||||
await surface.root.getByRole('button', { name: actionButtonName }).click();
|
||||
|
||||
const completionHeading = isUpdateFlow
|
||||
? surface.envelopeType === 'DOCUMENT'
|
||||
? 'Document Updated'
|
||||
: 'Template Updated'
|
||||
: surface.envelopeType === 'DOCUMENT'
|
||||
? 'Document Created'
|
||||
: 'Template Created';
|
||||
|
||||
await expect(surface.root.getByRole('heading', { name: completionHeading })).toBeVisible();
|
||||
};
|
||||
|
||||
const resolveEmbeddingToken = async (
|
||||
page: Page,
|
||||
inputToken: string,
|
||||
scope?: string,
|
||||
): Promise<string> => {
|
||||
if (!inputToken.startsWith('api_')) {
|
||||
return inputToken;
|
||||
}
|
||||
|
||||
const response = await page
|
||||
.context()
|
||||
.request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2/embedding/create-presign-token`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${inputToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: scope ? { scope } : {},
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Failed to exchange API token (${response.status()}): ${text}`);
|
||||
}
|
||||
|
||||
const data: unknown = await response.json();
|
||||
|
||||
if (typeof data !== 'object' || data === null || !('token' in data)) {
|
||||
throw new Error(`Unexpected response shape: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
const token = data.token;
|
||||
|
||||
if (typeof token !== 'string' || token.length === 0) {
|
||||
throw new Error(`Unexpected response shape: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
return token;
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import type Konva from 'konva';
|
||||
|
||||
export const getKonvaElementCountForPage = async (
|
||||
page: Page,
|
||||
pageNumber: number,
|
||||
elementSelector: string,
|
||||
) => {
|
||||
await page.locator('.konva-container canvas').first().waitFor({ state: 'visible' });
|
||||
|
||||
return await page.evaluate(
|
||||
({ pageNumber, elementSelector }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva;
|
||||
|
||||
const pageOne = konva.stages.find((stage) => stage.attrs.id === `page-${pageNumber}`);
|
||||
|
||||
return pageOne?.find(elementSelector).length || 0;
|
||||
},
|
||||
{ pageNumber, elementSelector },
|
||||
);
|
||||
};
|
||||
@@ -22,6 +22,12 @@ export const useLimits = () => {
|
||||
|
||||
export type LimitsProviderProps = {
|
||||
initialValue?: TLimitsResponseSchema;
|
||||
|
||||
/**
|
||||
* Bypass limits for embed authoring. This is just client side bypass since
|
||||
* all embeds should be paid plans.
|
||||
*/
|
||||
disableLimitsFetch?: boolean;
|
||||
teamId: number;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
@@ -32,12 +38,17 @@ export const LimitsProvider = ({
|
||||
remaining: FREE_PLAN_LIMITS,
|
||||
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
||||
},
|
||||
disableLimitsFetch,
|
||||
teamId,
|
||||
children,
|
||||
}: LimitsProviderProps) => {
|
||||
const [limits, setLimits] = useState(() => initialValue);
|
||||
|
||||
const refreshLimits = useCallback(async () => {
|
||||
if (disableLimitsFetch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newLimits = await getLimits({ teamId });
|
||||
|
||||
setLimits((oldLimits) => {
|
||||
@@ -54,6 +65,10 @@ export const LimitsProvider = ({
|
||||
}, [refreshLimits]);
|
||||
|
||||
useEffect(() => {
|
||||
if (disableLimitsFetch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
void refreshLimits();
|
||||
};
|
||||
|
||||
@@ -7,11 +7,10 @@ import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
|
||||
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
|
||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
|
||||
import type { TEnvelope } from '../../types/envelope';
|
||||
|
||||
export const ZLocalFieldSchema = z.object({
|
||||
// This is the actual ID of the field if created.
|
||||
id: z.number().optional(),
|
||||
@@ -38,7 +37,7 @@ const ZEditorFieldsFormSchema = z.object({
|
||||
export type TEditorFieldsFormSchema = z.infer<typeof ZEditorFieldsFormSchema>;
|
||||
|
||||
type EditorFieldsProps = {
|
||||
envelope: TEnvelope;
|
||||
envelope: TEditorEnvelope;
|
||||
handleFieldsUpdate: (fields: TLocalField[]) => unknown;
|
||||
};
|
||||
|
||||
|
||||
@@ -11,10 +11,9 @@ import {
|
||||
ZRecipientActionAuthTypesSchema,
|
||||
ZRecipientAuthOptionsSchema,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
|
||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
|
||||
import type { TEnvelope } from '../../types/envelope';
|
||||
|
||||
const LocalRecipientSchema = z.object({
|
||||
formId: z.string().min(1),
|
||||
id: z.number().optional(),
|
||||
@@ -36,12 +35,12 @@ export const ZEditorRecipientsFormSchema = z.object({
|
||||
export type TEditorRecipientsFormSchema = z.infer<typeof ZEditorRecipientsFormSchema>;
|
||||
|
||||
type EditorRecipientsProps = {
|
||||
envelope: TEnvelope;
|
||||
envelope: TEditorEnvelope;
|
||||
};
|
||||
|
||||
type ResetFormOptions = {
|
||||
recipients?: Recipient[];
|
||||
documentMeta?: TEnvelope['documentMeta'];
|
||||
documentMeta?: TEditorEnvelope['documentMeta'];
|
||||
};
|
||||
|
||||
type UseEditorRecipientsResponse = {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { type PageRenderData } from '../providers/envelope-render-provider';
|
||||
type RenderFunction = (props: { stage: Konva.Stage; pageLayer: Konva.Layer }) => void;
|
||||
|
||||
export const usePageRenderer = (renderFunction: RenderFunction, pageData: PageRenderData) => {
|
||||
const { pageWidth, pageHeight, scale, imageLoadingState } = pageData;
|
||||
const { pageWidth, pageHeight, scale, imageLoadingState, pageNumber } = pageData;
|
||||
|
||||
const konvaContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -47,6 +47,7 @@ export const usePageRenderer = (renderFunction: RenderFunction, pageData: PageRe
|
||||
|
||||
stage.current = new Konva.Stage({
|
||||
container,
|
||||
id: `page-${pageNumber}`,
|
||||
width: scaledViewport.width,
|
||||
height: scaledViewport.height,
|
||||
scale: {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { EnvelopeType, Prisma, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import {
|
||||
DEFAULT_EDITOR_CONFIG,
|
||||
type EnvelopeEditorConfig,
|
||||
type TEditorEnvelope,
|
||||
} from '@documenso/lib/types/envelope-editor';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TSetEnvelopeFieldsResponse } from '@documenso/trpc/server/envelope-router/set-envelope-fields.types';
|
||||
import type { TSetEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
|
||||
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
|
||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
@@ -11,41 +19,26 @@ import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import type { TDocumentEmailSettings } from '../../types/document-email';
|
||||
import type { TEnvelope } from '../../types/envelope';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '../../utils/teams';
|
||||
import { useEditorFields } from '../hooks/use-editor-fields';
|
||||
import type { TLocalField } from '../hooks/use-editor-fields';
|
||||
import { useEditorRecipients } from '../hooks/use-editor-recipients';
|
||||
import { useEnvelopeAutosave } from '../hooks/use-envelope-autosave';
|
||||
|
||||
export const useDebounceFunction = <Args extends unknown[]>(
|
||||
callback: (...args: Args) => void,
|
||||
delay: number,
|
||||
) => {
|
||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
return useCallback(
|
||||
(...args: Args) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
callback(...args);
|
||||
}, delay);
|
||||
},
|
||||
[callback, delay],
|
||||
);
|
||||
};
|
||||
export type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
|
||||
|
||||
type UpdateEnvelopePayload = Pick<TUpdateEnvelopeRequest, 'data' | 'meta'>;
|
||||
|
||||
type EnvelopeEditorProviderValue = {
|
||||
envelope: TEnvelope;
|
||||
editorConfig: EnvelopeEditorConfig;
|
||||
|
||||
envelope: TEditorEnvelope;
|
||||
|
||||
isEmbedded: boolean;
|
||||
isDocument: boolean;
|
||||
isTemplate: boolean;
|
||||
setLocalEnvelope: (localEnvelope: Partial<TEnvelope>) => void;
|
||||
|
||||
setLocalEnvelope: (localEnvelope: Partial<TEditorEnvelope>) => void;
|
||||
updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
|
||||
updateEnvelopeAsync: (envelopeUpdates: UpdateEnvelopePayload) => Promise<void>;
|
||||
setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void;
|
||||
@@ -57,8 +50,9 @@ type EnvelopeEditorProviderValue = {
|
||||
editorRecipients: ReturnType<typeof useEditorRecipients>;
|
||||
|
||||
isAutosaving: boolean;
|
||||
flushAutosave: () => Promise<void>;
|
||||
flushAutosave: () => Promise<TEditorEnvelope>;
|
||||
autosaveError: boolean;
|
||||
resetForms: () => void;
|
||||
|
||||
relativePath: {
|
||||
basePath: string;
|
||||
@@ -68,12 +62,20 @@ type EnvelopeEditorProviderValue = {
|
||||
templateRootPath: string;
|
||||
};
|
||||
|
||||
navigateToStep: (step: EnvelopeEditorStep) => void;
|
||||
syncEnvelope: () => Promise<void>;
|
||||
|
||||
registerExternalFlush: (key: string, flush: () => Promise<void>) => () => void;
|
||||
registerPendingMutation: (promise: Promise<unknown>) => void;
|
||||
|
||||
organisationEmails?: { id: string; email: string }[];
|
||||
};
|
||||
|
||||
interface EnvelopeEditorProviderProps {
|
||||
children: React.ReactNode;
|
||||
initialEnvelope: TEnvelope;
|
||||
editorConfig?: EnvelopeEditorConfig;
|
||||
initialEnvelope: TEditorEnvelope;
|
||||
organisationEmails?: { id: string; email: string }[];
|
||||
}
|
||||
|
||||
const EnvelopeEditorContext = createContext<EnvelopeEditorProviderValue | null>(null);
|
||||
@@ -90,14 +92,49 @@ export const useCurrentEnvelopeEditor = () => {
|
||||
|
||||
export const EnvelopeEditorProvider = ({
|
||||
children,
|
||||
editorConfig = DEFAULT_EDITOR_CONFIG,
|
||||
initialEnvelope,
|
||||
organisationEmails,
|
||||
}: EnvelopeEditorProviderProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [envelope, setEnvelope] = useState(initialEnvelope);
|
||||
const [_searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [envelope, _setEnvelope] = useState(initialEnvelope);
|
||||
const [autosaveError, setAutosaveError] = useState<boolean>(false);
|
||||
|
||||
const envelopeRef = useRef(initialEnvelope);
|
||||
|
||||
const externalFlushCallbacksRef = useRef<Map<string, () => Promise<void>>>(new Map());
|
||||
const pendingMutationsRef = useRef<Set<Promise<unknown>>>(new Set());
|
||||
|
||||
const registerExternalFlush = useCallback((key: string, flush: () => Promise<void>) => {
|
||||
externalFlushCallbacksRef.current.set(key, flush);
|
||||
|
||||
return () => {
|
||||
externalFlushCallbacksRef.current.delete(key);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const registerPendingMutation = useCallback((promise: Promise<unknown>) => {
|
||||
pendingMutationsRef.current.add(promise);
|
||||
|
||||
void promise.finally(() => {
|
||||
pendingMutationsRef.current.delete(promise);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setEnvelope: typeof _setEnvelope = (action) => {
|
||||
_setEnvelope((prev) => {
|
||||
const next = typeof action === 'function' ? action(prev) : action;
|
||||
envelopeRef.current = next;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isEmbedded = editorConfig.embedded !== undefined;
|
||||
|
||||
const editorFields = useEditorFields({
|
||||
envelope,
|
||||
handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
|
||||
@@ -107,61 +144,35 @@ export const EnvelopeEditorProvider = ({
|
||||
envelope,
|
||||
});
|
||||
|
||||
const envelopeUpdateMutationQuery = trpc.envelope.update.useMutation({
|
||||
onSuccess: (response, input) => {
|
||||
setEnvelope({
|
||||
...envelope,
|
||||
...response,
|
||||
documentMeta: {
|
||||
...envelope.documentMeta,
|
||||
...input.meta,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
emailSettings: (input.meta?.emailSettings ||
|
||||
null) as unknown as TDocumentEmailSettings | null,
|
||||
},
|
||||
});
|
||||
const setRecipientsMutation = trpc.envelope.recipient.set.useMutation();
|
||||
const setFieldsMutation = trpc.envelope.field.set.useMutation();
|
||||
const updateEnvelopeMutation = trpc.envelope.update.useMutation();
|
||||
|
||||
setAutosaveError(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
/**
|
||||
* Handles debouncing the recipients updates to the server.
|
||||
*
|
||||
* Will set the local envelope recipients and fields after the update is complete.
|
||||
*/
|
||||
const {
|
||||
triggerSave: setRecipientsDebounced,
|
||||
flush: flushSetRecipients,
|
||||
isPending: isRecipientsMutationPending,
|
||||
} = useEnvelopeAutosave(async (localRecipients: TSetEnvelopeRecipientsRequest['recipients']) => {
|
||||
try {
|
||||
let recipients: TEditorEnvelope['recipients'] = [];
|
||||
|
||||
setAutosaveError(true);
|
||||
if (!isEmbedded) {
|
||||
const response = await setRecipientsMutation.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
envelopeType: envelope.type,
|
||||
recipients: localRecipients,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Save failed`,
|
||||
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
},
|
||||
});
|
||||
recipients = response.data;
|
||||
} else {
|
||||
recipients = mapLocalRecipientsToRecipients({ envelope, localRecipients });
|
||||
}
|
||||
|
||||
const envelopeFieldSetMutationQuery = trpc.envelope.field.set.useMutation({
|
||||
onSuccess: ({ data: fields }) => {
|
||||
setEnvelope((prev) => ({
|
||||
...prev,
|
||||
fields,
|
||||
}));
|
||||
|
||||
setAutosaveError(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
|
||||
setAutosaveError(true);
|
||||
|
||||
toast({
|
||||
title: t`Save failed`,
|
||||
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
|
||||
onSuccess: ({ data: recipients }) => {
|
||||
setEnvelope((prev) => ({
|
||||
...prev,
|
||||
recipients,
|
||||
@@ -178,8 +189,7 @@ export const EnvelopeEditorProvider = ({
|
||||
);
|
||||
|
||||
setAutosaveError(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
setAutosaveError(true);
|
||||
@@ -190,58 +200,137 @@ export const EnvelopeEditorProvider = ({
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
triggerSave: setRecipientsDebounced,
|
||||
flush: setRecipientsAsync,
|
||||
isPending: isRecipientsMutationPending,
|
||||
} = useEnvelopeAutosave(async (recipients: TSetEnvelopeRecipientsRequest['recipients']) => {
|
||||
await envelopeRecipientSetMutationQuery.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
envelopeType: envelope.type,
|
||||
recipients,
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
const setRecipientsAsync = async (
|
||||
localRecipients: TSetEnvelopeRecipientsRequest['recipients'],
|
||||
) => {
|
||||
setRecipientsDebounced(localRecipients);
|
||||
await flushSetRecipients();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles debouncing the fields updates to the server.
|
||||
*
|
||||
* Will set the local envelope fields after the update is complete.
|
||||
*/
|
||||
const {
|
||||
triggerSave: setFieldsDebounced,
|
||||
flush: setFieldsAsync,
|
||||
flush: flushSetFields,
|
||||
isPending: isFieldsMutationPending,
|
||||
} = useEnvelopeAutosave(async (localFields: TLocalField[]) => {
|
||||
const envelopeFields = await envelopeFieldSetMutationQuery.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
envelopeType: envelope.type,
|
||||
fields: localFields,
|
||||
});
|
||||
try {
|
||||
let fields: TSetEnvelopeFieldsResponse['data'] = [];
|
||||
|
||||
// Insert the IDs into the local fields.
|
||||
envelopeFields.data.forEach((field) => {
|
||||
const localField = localFields.find((localField) => localField.formId === field.formId);
|
||||
if (!isEmbedded) {
|
||||
const response = await setFieldsMutation.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
envelopeType: envelope.type,
|
||||
fields: localFields,
|
||||
});
|
||||
|
||||
if (localField && !localField.id) {
|
||||
localField.id = field.id;
|
||||
|
||||
editorFields.setFieldId(localField.formId, field.id);
|
||||
fields = response.data;
|
||||
} else {
|
||||
fields = mapLocalFieldsToFields({ envelope, localFields });
|
||||
}
|
||||
});
|
||||
|
||||
setEnvelope((prev) => ({
|
||||
...prev,
|
||||
fields,
|
||||
}));
|
||||
|
||||
setAutosaveError(false);
|
||||
|
||||
// Insert the IDs into the local fields.
|
||||
fields.forEach((field) => {
|
||||
const localField = localFields.find((localField) => localField.formId === field.formId);
|
||||
|
||||
if (localField && !localField.id) {
|
||||
localField.id = field.id;
|
||||
|
||||
editorFields.setFieldId(localField.formId, field.id);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
setAutosaveError(true);
|
||||
|
||||
toast({
|
||||
title: t`Save failed`,
|
||||
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
const setFieldsAsync = async (localFields: TLocalField[]) => {
|
||||
setFieldsDebounced(localFields);
|
||||
await flushSetFields();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles debouncing the envelope updates to the server.
|
||||
*
|
||||
* Will set the local envelope after the update is complete.
|
||||
*/
|
||||
const {
|
||||
triggerSave: setEnvelopeDebounced,
|
||||
flush: setEnvelopeAsync,
|
||||
triggerSave: updateEnvelopeDebounced,
|
||||
flush: flushUpdateEnvelope,
|
||||
isPending: isEnvelopeMutationPending,
|
||||
} = useEnvelopeAutosave(async (envelopeUpdates: UpdateEnvelopePayload) => {
|
||||
await envelopeUpdateMutationQuery.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
data: envelopeUpdates.data,
|
||||
meta: envelopeUpdates.meta,
|
||||
});
|
||||
} = useEnvelopeAutosave(async ({ data, meta }: UpdateEnvelopePayload) => {
|
||||
try {
|
||||
const response = !isEmbedded
|
||||
? await updateEnvelopeMutation.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
data,
|
||||
meta,
|
||||
})
|
||||
: {};
|
||||
|
||||
setEnvelope((prev) => ({
|
||||
...prev,
|
||||
...data,
|
||||
authOptions: {
|
||||
globalAccessAuth: data?.globalAccessAuth || [],
|
||||
globalActionAuth: data?.globalActionAuth || [],
|
||||
},
|
||||
...response,
|
||||
documentMeta: {
|
||||
...prev.documentMeta,
|
||||
...meta,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
emailSettings: (meta?.emailSettings || null) as unknown as TDocumentEmailSettings | null,
|
||||
},
|
||||
}));
|
||||
|
||||
setAutosaveError(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
setAutosaveError(true);
|
||||
|
||||
toast({
|
||||
title: t`Save failed`,
|
||||
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
const updateEnvelopeAsync = async (envelopeUpdates: UpdateEnvelopePayload) => {
|
||||
updateEnvelopeDebounced(envelopeUpdates);
|
||||
await flushUpdateEnvelope();
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the local envelope and debounces the update to the server.
|
||||
*
|
||||
* Use this when you want to update the local envelope immediately while debouncing
|
||||
* the actual update to the server.
|
||||
*/
|
||||
const updateEnvelope = (envelopeUpdates: UpdateEnvelopePayload) => {
|
||||
setEnvelope((prev) => ({
|
||||
@@ -253,14 +342,7 @@ export const EnvelopeEditorProvider = ({
|
||||
},
|
||||
}));
|
||||
|
||||
setEnvelopeDebounced(envelopeUpdates);
|
||||
};
|
||||
|
||||
const updateEnvelopeAsync = async (envelopeUpdates: UpdateEnvelopePayload) => {
|
||||
await envelopeUpdateMutationQuery.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
...envelopeUpdates,
|
||||
});
|
||||
updateEnvelopeDebounced(envelopeUpdates);
|
||||
};
|
||||
|
||||
const getRecipientColorKey = useCallback(
|
||||
@@ -276,12 +358,13 @@ export const EnvelopeEditorProvider = ({
|
||||
[envelope.recipients],
|
||||
);
|
||||
|
||||
const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery(
|
||||
const { refetch: reloadEnvelope } = trpc.envelope.editor.get.useQuery(
|
||||
{
|
||||
envelopeId: envelope.id,
|
||||
},
|
||||
{
|
||||
initialData: envelope,
|
||||
enabled: !isEmbedded,
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -293,6 +376,11 @@ export const EnvelopeEditorProvider = ({
|
||||
const syncEnvelope = async () => {
|
||||
await flushAutosave();
|
||||
|
||||
// Bypass syncing for embedded mode.
|
||||
if (isEmbedded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchedEnvelopeData = await reloadEnvelope();
|
||||
|
||||
if (fetchedEnvelopeData.data) {
|
||||
@@ -302,55 +390,95 @@ export const EnvelopeEditorProvider = ({
|
||||
recipients: fetchedEnvelopeData.data.recipients,
|
||||
documentMeta: fetchedEnvelopeData.data.documentMeta,
|
||||
});
|
||||
|
||||
editorFields.resetForm(fetchedEnvelopeData.data.fields);
|
||||
}
|
||||
};
|
||||
|
||||
const setLocalEnvelope = (localEnvelope: Partial<TEnvelope>) => {
|
||||
const setLocalEnvelope = (localEnvelope: Partial<TEditorEnvelope>) => {
|
||||
setEnvelope((prev) => ({ ...prev, ...localEnvelope }));
|
||||
};
|
||||
|
||||
const isAutosaving = useMemo(() => {
|
||||
return (
|
||||
envelopeFieldSetMutationQuery.isPending ||
|
||||
envelopeRecipientSetMutationQuery.isPending ||
|
||||
envelopeUpdateMutationQuery.isPending ||
|
||||
isFieldsMutationPending ||
|
||||
isRecipientsMutationPending ||
|
||||
isEnvelopeMutationPending
|
||||
);
|
||||
}, [
|
||||
envelopeFieldSetMutationQuery.isPending,
|
||||
envelopeRecipientSetMutationQuery.isPending,
|
||||
envelopeUpdateMutationQuery.isPending,
|
||||
isFieldsMutationPending,
|
||||
isRecipientsMutationPending,
|
||||
isEnvelopeMutationPending,
|
||||
]);
|
||||
return isFieldsMutationPending || isRecipientsMutationPending || isEnvelopeMutationPending;
|
||||
}, [isFieldsMutationPending, isRecipientsMutationPending, isEnvelopeMutationPending]);
|
||||
|
||||
const relativePath = useMemo(() => {
|
||||
const documentRootPath = formatDocumentsPath(envelope.team.url);
|
||||
const templateRootPath = formatTemplatesPath(envelope.team.url);
|
||||
let documentRootPath = formatDocumentsPath(envelope.team.url);
|
||||
let templateRootPath = formatTemplatesPath(envelope.team.url);
|
||||
|
||||
const basePath = envelope.type === EnvelopeType.DOCUMENT ? documentRootPath : templateRootPath;
|
||||
let envelopePath = `${basePath}/${envelope.id}`;
|
||||
let editorPath = `${basePath}/${envelope.id}/edit`;
|
||||
|
||||
if (editorConfig.embedded) {
|
||||
let embeddedEditorPath =
|
||||
editorConfig.embedded.mode === 'edit'
|
||||
? `/embed/v2/authoring/envelope/edit/${envelope.id}`
|
||||
: `/embed/v2/authoring/envelope/create`;
|
||||
|
||||
embeddedEditorPath += `?token=${editorConfig.embedded.presignToken}`;
|
||||
|
||||
envelopePath = embeddedEditorPath;
|
||||
editorPath = embeddedEditorPath;
|
||||
documentRootPath = embeddedEditorPath;
|
||||
templateRootPath = embeddedEditorPath;
|
||||
}
|
||||
|
||||
return {
|
||||
basePath,
|
||||
envelopePath: `${basePath}/${envelope.id}`,
|
||||
editorPath: `${basePath}/${envelope.id}/edit`,
|
||||
envelopePath,
|
||||
editorPath,
|
||||
documentRootPath,
|
||||
templateRootPath,
|
||||
};
|
||||
}, [envelope.type, envelope.id]);
|
||||
|
||||
const flushAutosave = async (): Promise<void> => {
|
||||
await Promise.all([setFieldsAsync(), setRecipientsAsync(), setEnvelopeAsync()]);
|
||||
const navigateToStep = (step: EnvelopeEditorStep) => {
|
||||
setSearchParams((prev) => {
|
||||
const newParams = new URLSearchParams(prev);
|
||||
|
||||
if (step === 'upload') {
|
||||
newParams.delete('step');
|
||||
} else {
|
||||
newParams.set('step', step);
|
||||
}
|
||||
|
||||
return newParams;
|
||||
});
|
||||
};
|
||||
|
||||
const resetForms = () => {
|
||||
editorRecipients.resetForm({
|
||||
recipients: envelopeRef.current.recipients,
|
||||
documentMeta: envelopeRef.current.documentMeta,
|
||||
});
|
||||
|
||||
editorFields.resetForm(envelopeRef.current.fields);
|
||||
};
|
||||
|
||||
const flushAutosave = async (): Promise<TEditorEnvelope> => {
|
||||
await Promise.all([flushSetFields(), flushSetRecipients(), flushUpdateEnvelope()]);
|
||||
|
||||
// Flush all registered external flushes (e.g., upload page's debounced item updates).
|
||||
const externalFlushes = Array.from(externalFlushCallbacksRef.current.values());
|
||||
await Promise.all(externalFlushes.map(async (flush) => flush()));
|
||||
|
||||
// Await all registered pending mutations (e.g., in-flight creates/deletes).
|
||||
// Use allSettled so a single failed mutation doesn't prevent awaiting the rest.
|
||||
if (pendingMutationsRef.current.size > 0) {
|
||||
await Promise.allSettled(Array.from(pendingMutationsRef.current));
|
||||
}
|
||||
|
||||
return envelopeRef.current;
|
||||
};
|
||||
|
||||
return (
|
||||
<EnvelopeEditorContext.Provider
|
||||
value={{
|
||||
editorConfig,
|
||||
envelope,
|
||||
isEmbedded,
|
||||
isDocument: envelope.type === EnvelopeType.DOCUMENT,
|
||||
isTemplate: envelope.type === EnvelopeType.TEMPLATE,
|
||||
setLocalEnvelope,
|
||||
@@ -366,9 +494,113 @@ export const EnvelopeEditorProvider = ({
|
||||
isAutosaving,
|
||||
relativePath,
|
||||
syncEnvelope,
|
||||
navigateToStep,
|
||||
resetForms,
|
||||
registerExternalFlush,
|
||||
registerPendingMutation,
|
||||
organisationEmails,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</EnvelopeEditorContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
type MapLocalRecipientsToRecipientsOptions = {
|
||||
envelope: TEditorEnvelope;
|
||||
localRecipients: TSetEnvelopeRecipientsRequest['recipients'];
|
||||
};
|
||||
|
||||
const mapLocalRecipientsToRecipients = ({
|
||||
envelope,
|
||||
localRecipients,
|
||||
}: MapLocalRecipientsToRecipientsOptions): TEditorEnvelope['recipients'] => {
|
||||
let smallestRecipientId = localRecipients.reduce((min, recipient) => {
|
||||
if (recipient.id && recipient.id < min) {
|
||||
return recipient.id;
|
||||
}
|
||||
|
||||
return min;
|
||||
}, -1);
|
||||
|
||||
return localRecipients.map((recipient) => {
|
||||
const foundRecipient = envelope.recipients.find((recipient) => recipient.id === recipient.id);
|
||||
|
||||
let recipientId = recipient.id;
|
||||
|
||||
if (recipientId === undefined) {
|
||||
recipientId = smallestRecipientId;
|
||||
smallestRecipientId--;
|
||||
}
|
||||
|
||||
return {
|
||||
id: recipientId,
|
||||
envelopeId: envelope.id,
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
token: foundRecipient?.token || '',
|
||||
documentDeletedAt: foundRecipient?.documentDeletedAt || null,
|
||||
expired: foundRecipient?.expired || null,
|
||||
signedAt: foundRecipient?.signedAt || null,
|
||||
authOptions:
|
||||
recipient.actionAuth.length > 0
|
||||
? { actionAuth: recipient.actionAuth, accessAuth: [] }
|
||||
: null,
|
||||
signingOrder: recipient.signingOrder ?? null,
|
||||
rejectionReason: foundRecipient?.rejectionReason || null,
|
||||
role: recipient.role,
|
||||
readStatus: foundRecipient?.readStatus || ReadStatus.NOT_OPENED,
|
||||
signingStatus: foundRecipient?.signingStatus || SigningStatus.NOT_SIGNED,
|
||||
sendStatus: foundRecipient?.sendStatus || SendStatus.NOT_SENT,
|
||||
expiresAt: foundRecipient?.expiresAt || null,
|
||||
expirationNotifiedAt: foundRecipient?.expirationNotifiedAt || null,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
type MapLocalFieldsToFieldsOptions = {
|
||||
localFields: TLocalField[];
|
||||
envelope: TEditorEnvelope;
|
||||
};
|
||||
|
||||
const mapLocalFieldsToFields = ({
|
||||
envelope,
|
||||
localFields,
|
||||
}: MapLocalFieldsToFieldsOptions): TSetEnvelopeFieldsResponse['data'] => {
|
||||
let smallestFieldId = localFields.reduce((min, field) => {
|
||||
if (field.id && field.id < min) {
|
||||
return field.id;
|
||||
}
|
||||
|
||||
return min;
|
||||
}, -1);
|
||||
|
||||
return localFields.map((field) => {
|
||||
const foundField = envelope.fields.find((envelopeField) => envelopeField.id === field.id);
|
||||
|
||||
let fieldId = field.id;
|
||||
|
||||
if (fieldId === undefined) {
|
||||
fieldId = smallestFieldId;
|
||||
smallestFieldId--;
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
formId: field.formId,
|
||||
id: fieldId,
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: field.envelopeItemId,
|
||||
type: field.type,
|
||||
recipientId: field.recipientId,
|
||||
positionX: new Prisma.Decimal(field.positionX),
|
||||
positionY: new Prisma.Decimal(field.positionY),
|
||||
width: new Prisma.Decimal(field.width),
|
||||
height: new Prisma.Decimal(field.height),
|
||||
secondaryId: foundField?.secondaryId || '',
|
||||
inserted: foundField?.inserted || false,
|
||||
customText: foundField?.customText || '',
|
||||
fieldMeta: field.fieldMeta || null,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -169,20 +169,22 @@ export const EnvelopeRenderProvider = ({
|
||||
[envelopeItemsFromProps, envelope.id, token, version, presignToken],
|
||||
);
|
||||
|
||||
const [currentItem, setCurrentItem] = useState<EnvelopeRenderItem | null>(
|
||||
envelopeItems[0] ?? null,
|
||||
);
|
||||
const [currentItemId, setCurrentItemId] = useState<string | null>(envelopeItems[0]?.id ?? null);
|
||||
|
||||
const currentItem = useMemo((): EnvelopeRenderItem | null => {
|
||||
return envelopeItems.find((item) => item.id === currentItemId) ?? null;
|
||||
}, [currentItemId, envelopeItems]);
|
||||
|
||||
const setCurrentEnvelopeItem = (envelopeItemId: string) => {
|
||||
const foundItem = envelopeItems.find((item) => item.id === envelopeItemId);
|
||||
|
||||
setCurrentItem(foundItem ?? null);
|
||||
setCurrentItemId(foundItem?.id ?? null);
|
||||
};
|
||||
|
||||
// Set the selected item to the first item if none is set.
|
||||
useEffect(() => {
|
||||
if (currentItem && !envelopeItems.some((item) => item.id === currentItem.id)) {
|
||||
setCurrentItem(null);
|
||||
setCurrentItemId(null);
|
||||
}
|
||||
|
||||
if (!currentItem && envelopeItems.length > 0) {
|
||||
|
||||
@@ -60,6 +60,15 @@ export const verifyEmbeddingPresignToken = async ({
|
||||
where: {
|
||||
id: tokenId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!apiToken) {
|
||||
@@ -69,7 +78,7 @@ export const verifyEmbeddingPresignToken = async ({
|
||||
}
|
||||
|
||||
// This should never happen but we need to narrow types
|
||||
if (!apiToken.userId) {
|
||||
if (!apiToken.userId || !apiToken.user) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid presign token: API token does not have a user attached',
|
||||
});
|
||||
@@ -119,5 +128,10 @@ export const verifyEmbeddingPresignToken = async ({
|
||||
return {
|
||||
...apiToken,
|
||||
userId,
|
||||
user: {
|
||||
id: apiToken.user.id,
|
||||
name: apiToken.user.name,
|
||||
email: apiToken.user.email,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { Envelope, EnvelopeItem, Recipient } from '@prisma/client';
|
||||
|
||||
import {
|
||||
convertPlaceholdersToFieldInputs,
|
||||
extractPdfPlaceholders,
|
||||
} from '@documenso/lib/server-only/pdf/auto-place-fields';
|
||||
import { findRecipientByPlaceholder } from '@documenso/lib/server-only/pdf/helpers';
|
||||
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
|
||||
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { prefixedId } from '@documenso/lib/universal/id';
|
||||
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
type UnsafeCreateEnvelopeItemsOptions = {
|
||||
files: {
|
||||
clientId?: string;
|
||||
file: File;
|
||||
orderOverride?: number;
|
||||
}[];
|
||||
envelope: Envelope & {
|
||||
envelopeItems: EnvelopeItem[];
|
||||
recipients: Recipient[];
|
||||
};
|
||||
user: {
|
||||
id: number;
|
||||
name: string | null;
|
||||
email: string;
|
||||
};
|
||||
apiRequestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create envelope items.
|
||||
*
|
||||
* It is assumed all prior validation has been completed.
|
||||
*/
|
||||
export const UNSAFE_createEnvelopeItems = async ({
|
||||
files,
|
||||
envelope,
|
||||
user,
|
||||
apiRequestMetadata,
|
||||
}: UnsafeCreateEnvelopeItemsOptions) => {
|
||||
const currentHighestOrderValue =
|
||||
envelope.envelopeItems[envelope.envelopeItems.length - 1]?.order ?? 1;
|
||||
|
||||
// For each file: normalize, extract & clean placeholders, then upload.
|
||||
const envelopeItemsToCreate = await Promise.all(
|
||||
files.map(async ({ file, orderOverride, clientId }, index) => {
|
||||
let buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
if (envelope.formValues) {
|
||||
buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues });
|
||||
}
|
||||
|
||||
const normalized = await normalizePdf(buffer, {
|
||||
flattenForm: envelope.type !== 'TEMPLATE',
|
||||
});
|
||||
|
||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
||||
|
||||
const { id: documentDataId } = await putPdfFileServerSide({
|
||||
name: file.name,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(cleanedPdf),
|
||||
});
|
||||
|
||||
return {
|
||||
id: prefixedId('envelope_item'),
|
||||
title: file.name,
|
||||
clientId,
|
||||
documentDataId,
|
||||
placeholders,
|
||||
order: orderOverride ?? currentHighestOrderValue + index + 1,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const createdItems = await tx.envelopeItem.createManyAndReturn({
|
||||
data: envelopeItemsToCreate.map((item) => ({
|
||||
id: item.id,
|
||||
envelopeId: envelope.id,
|
||||
title: item.title,
|
||||
documentDataId: item.documentDataId,
|
||||
order: item.order,
|
||||
})),
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.createMany({
|
||||
data: createdItems.map((item) =>
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_CREATED,
|
||||
envelopeId: envelope.id,
|
||||
data: {
|
||||
envelopeItemId: item.id,
|
||||
envelopeItemTitle: item.title,
|
||||
},
|
||||
user: {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
},
|
||||
requestMetadata: apiRequestMetadata.requestMetadata,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// Create fields from placeholders if the envelope already has recipients.
|
||||
if (envelope.recipients.length > 0) {
|
||||
const orderedRecipients = [...envelope.recipients].sort((a, b) => {
|
||||
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
|
||||
if (aOrder !== bOrder) {
|
||||
return aOrder - bOrder;
|
||||
}
|
||||
|
||||
return a.id - b.id;
|
||||
});
|
||||
|
||||
for (const uploadedItem of envelopeItemsToCreate) {
|
||||
if (!uploadedItem.placeholders || uploadedItem.placeholders.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdItem = createdItems.find(
|
||||
(ci) => ci.documentDataId === uploadedItem.documentDataId,
|
||||
);
|
||||
|
||||
if (!createdItem) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldsToCreate = convertPlaceholdersToFieldInputs(
|
||||
uploadedItem.placeholders,
|
||||
(recipientPlaceholder, placeholder) =>
|
||||
findRecipientByPlaceholder(
|
||||
recipientPlaceholder,
|
||||
placeholder,
|
||||
orderedRecipients,
|
||||
orderedRecipients,
|
||||
),
|
||||
createdItem.id,
|
||||
);
|
||||
|
||||
if (fieldsToCreate.length > 0) {
|
||||
await tx.field.createMany({
|
||||
data: fieldsToCreate.map((field) => ({
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: createdItem.id,
|
||||
recipientId: field.recipientId,
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta || undefined,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return createdItems.map((item) => {
|
||||
const clientId = envelopeItemsToCreate.find((file) => file.id === item.id)?.clientId;
|
||||
|
||||
return {
|
||||
...item,
|
||||
clientId,
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
type UnsafeDeleteEnvelopeItemOptions = {
|
||||
envelopeId: string;
|
||||
envelopeItemId: string;
|
||||
user: {
|
||||
id: number;
|
||||
name: string | null;
|
||||
email: string;
|
||||
};
|
||||
apiRequestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
export const UNSAFE_deleteEnvelopeItem = async ({
|
||||
envelopeId,
|
||||
envelopeItemId,
|
||||
user,
|
||||
apiRequestMetadata,
|
||||
}: UnsafeDeleteEnvelopeItemOptions) => {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const deletedEnvelopeItem = await tx.envelopeItem.delete({
|
||||
where: {
|
||||
id: envelopeItemId,
|
||||
envelopeId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
documentData: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_DELETED,
|
||||
envelopeId,
|
||||
data: {
|
||||
envelopeItemId: deletedEnvelopeItem.id,
|
||||
envelopeItemTitle: deletedEnvelopeItem.title,
|
||||
},
|
||||
user: {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
},
|
||||
requestMetadata: apiRequestMetadata.requestMetadata,
|
||||
}),
|
||||
});
|
||||
|
||||
return deletedEnvelopeItem;
|
||||
});
|
||||
|
||||
await prisma.documentData.delete({
|
||||
where: {
|
||||
id: result.documentData.id,
|
||||
envelopeItem: {
|
||||
is: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
type UnsafeUpdateEnvelopeItemsOptions = {
|
||||
envelopeId: string;
|
||||
data: {
|
||||
envelopeItemId: string;
|
||||
order?: number;
|
||||
title?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const UNSAFE_updateEnvelopeItems = async ({
|
||||
envelopeId,
|
||||
data,
|
||||
}: UnsafeUpdateEnvelopeItemsOptions) => {
|
||||
// Todo: Envelope [AUDIT_LOGS]
|
||||
|
||||
const updatedEnvelopeItems = await Promise.all(
|
||||
data.map(async ({ envelopeItemId, order, title }) =>
|
||||
prisma.envelopeItem.update({
|
||||
where: {
|
||||
envelopeId,
|
||||
id: envelopeItemId,
|
||||
},
|
||||
data: {
|
||||
order,
|
||||
title,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
order: true,
|
||||
title: true,
|
||||
envelopeId: true,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return updatedEnvelopeItems;
|
||||
};
|
||||
@@ -97,6 +97,14 @@ export type CreateEnvelopeOptions = {
|
||||
data: string;
|
||||
type?: TEnvelopeAttachmentType;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Whether to bypass adding default recipients.
|
||||
*
|
||||
* Defaults to false.
|
||||
*/
|
||||
bypassDefaultRecipients?: boolean;
|
||||
|
||||
meta?: Partial<Omit<DocumentMeta, 'id'>>;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
@@ -110,6 +118,7 @@ export const createEnvelope = async ({
|
||||
meta,
|
||||
requestMetadata,
|
||||
internalVersion,
|
||||
bypassDefaultRecipients = false,
|
||||
}: CreateEnvelopeOptions) => {
|
||||
const {
|
||||
type,
|
||||
@@ -355,9 +364,10 @@ export const createEnvelope = async ({
|
||||
|
||||
const firstEnvelopeItem = envelope.envelopeItems[0];
|
||||
|
||||
const defaultRecipients = settings.defaultRecipients
|
||||
? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
|
||||
: [];
|
||||
const defaultRecipients =
|
||||
settings.defaultRecipients && !bypassDefaultRecipients
|
||||
? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
|
||||
: [];
|
||||
|
||||
const mappedDefaultRecipients: CreateEnvelopeRecipientOptions[] = defaultRecipients.map(
|
||||
(recipient) => ({
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
|
||||
export type GetEditorEnvelopeByIdOptions = {
|
||||
id: EnvelopeIdOptions;
|
||||
|
||||
/**
|
||||
* The validated team ID.
|
||||
*/
|
||||
userId: number;
|
||||
|
||||
/**
|
||||
* The unvalidated team ID.
|
||||
*/
|
||||
teamId: number;
|
||||
|
||||
/**
|
||||
* The type of envelope to get.
|
||||
*
|
||||
* Set to null to bypass check.
|
||||
*/
|
||||
type: EnvelopeType | null;
|
||||
};
|
||||
|
||||
export const getEditorEnvelopeById = async ({
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
type,
|
||||
}: GetEditorEnvelopeByIdOptions) => {
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
type,
|
||||
});
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: envelopeWhereInput,
|
||||
include: {
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
folder: true,
|
||||
documentMeta: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
recipients: {
|
||||
orderBy: {
|
||||
id: 'asc',
|
||||
},
|
||||
},
|
||||
fields: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
organisationId: true,
|
||||
},
|
||||
},
|
||||
directLink: {
|
||||
select: {
|
||||
directTemplateRecipientId: true,
|
||||
enabled: true,
|
||||
id: true,
|
||||
token: true,
|
||||
},
|
||||
},
|
||||
envelopeAttachments: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
label: true,
|
||||
data: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope could not be found',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...envelope,
|
||||
attachments: envelope.envelopeAttachments,
|
||||
user: {
|
||||
id: envelope.user.id,
|
||||
name: envelope.user.name || '',
|
||||
email: envelope.user.email,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -21,14 +21,7 @@ export type SetTemplateRecipientsOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
id: EnvelopeIdOptions;
|
||||
recipients: {
|
||||
id?: number;
|
||||
email: string;
|
||||
name: string;
|
||||
role: RecipientRole;
|
||||
signingOrder?: number | null;
|
||||
actionAuth?: TRecipientActionAuthTypes[];
|
||||
}[];
|
||||
recipients: RecipientData[];
|
||||
};
|
||||
|
||||
export const setTemplateRecipients = async ({
|
||||
@@ -183,7 +176,10 @@ export const setTemplateRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
return upsertedRecipient;
|
||||
return {
|
||||
...upsertedRecipient,
|
||||
clientId: recipient.clientId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -199,7 +195,7 @@ export const setTemplateRecipients = async ({
|
||||
}
|
||||
|
||||
// Filter out recipients that have been removed or have been updated.
|
||||
const filteredRecipients: Recipient[] = existingRecipients.filter((recipient) => {
|
||||
const filteredRecipients: RecipientDataWithClientId[] = existingRecipients.filter((recipient) => {
|
||||
const isRemoved = removedRecipients.find(
|
||||
(removedRecipient) => removedRecipient.id === recipient.id,
|
||||
);
|
||||
@@ -218,3 +214,17 @@ export const setTemplateRecipients = async ({
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
type RecipientData = {
|
||||
id?: number;
|
||||
clientId?: string | null;
|
||||
email: string;
|
||||
name: string;
|
||||
role: RecipientRole;
|
||||
signingOrder?: number | null;
|
||||
actionAuth?: TRecipientActionAuthTypes[];
|
||||
};
|
||||
|
||||
type RecipientDataWithClientId = Recipient & {
|
||||
clientId?: string | null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZCssVarsSchema = z
|
||||
.object({
|
||||
background: z.string().optional().describe('Base background color'),
|
||||
foreground: z.string().optional().describe('Base text color'),
|
||||
muted: z.string().optional().describe('Muted/subtle background color'),
|
||||
mutedForeground: z.string().optional().describe('Muted/subtle text color'),
|
||||
popover: z.string().optional().describe('Popover/dropdown background color'),
|
||||
popoverForeground: z.string().optional().describe('Popover/dropdown text color'),
|
||||
card: z.string().optional().describe('Card background color'),
|
||||
cardBorder: z.string().optional().describe('Card border color'),
|
||||
cardBorderTint: z.string().optional().describe('Card border tint/highlight color'),
|
||||
cardForeground: z.string().optional().describe('Card text color'),
|
||||
fieldCard: z.string().optional().describe('Field card background color'),
|
||||
fieldCardBorder: z.string().optional().describe('Field card border color'),
|
||||
fieldCardForeground: z.string().optional().describe('Field card text color'),
|
||||
widget: z.string().optional().describe('Widget background color'),
|
||||
widgetForeground: z.string().optional().describe('Widget text color'),
|
||||
border: z.string().optional().describe('Default border color'),
|
||||
input: z.string().optional().describe('Input field border color'),
|
||||
primary: z.string().optional().describe('Primary action/button color'),
|
||||
primaryForeground: z.string().optional().describe('Primary action/button text color'),
|
||||
secondary: z.string().optional().describe('Secondary action/button color'),
|
||||
secondaryForeground: z.string().optional().describe('Secondary action/button text color'),
|
||||
accent: z.string().optional().describe('Accent/highlight color'),
|
||||
accentForeground: z.string().optional().describe('Accent/highlight text color'),
|
||||
destructive: z.string().optional().describe('Destructive/danger action color'),
|
||||
destructiveForeground: z.string().optional().describe('Destructive/danger text color'),
|
||||
ring: z.string().optional().describe('Focus ring color'),
|
||||
radius: z.string().optional().describe('Border radius size in REM units'),
|
||||
warning: z.string().optional().describe('Warning/alert color'),
|
||||
envelopeEditorBackground: z.string().optional().describe('Envelope editor background color'),
|
||||
})
|
||||
.describe('Custom CSS variables for theming');
|
||||
|
||||
export type TCssVarsSchema = z.infer<typeof ZCssVarsSchema>;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZBaseEmbedDataSchema } from './embed-base-schemas';
|
||||
|
||||
export const ZBaseEmbedAuthoringSchema = ZBaseEmbedDataSchema.extend({
|
||||
externalId: z.string().optional(),
|
||||
features: z
|
||||
.object({
|
||||
allowConfigureSignatureTypes: z.boolean().optional(),
|
||||
allowConfigureLanguage: z.boolean().optional(),
|
||||
allowConfigureDateFormat: z.boolean().optional(),
|
||||
allowConfigureTimezone: z.boolean().optional(),
|
||||
allowConfigureRedirectUrl: z.boolean().optional(),
|
||||
allowConfigureCommunication: z.boolean().optional(),
|
||||
})
|
||||
.optional()
|
||||
.default({}),
|
||||
});
|
||||
|
||||
export const ZBaseEmbedAuthoringEditSchema = ZBaseEmbedAuthoringSchema.extend({
|
||||
onlyEditFields: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
export type TBaseEmbedAuthoringSchema = z.infer<typeof ZBaseEmbedAuthoringSchema>;
|
||||
export type TBaseEmbedAuthoringEditSchema = z.infer<typeof ZBaseEmbedAuthoringEditSchema>;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZCssVarsSchema } from './css-vars';
|
||||
|
||||
export const ZBaseEmbedDataSchema = z.object({
|
||||
darkModeDisabled: z.boolean().optional().default(false),
|
||||
css: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value || undefined),
|
||||
cssVars: ZCssVarsSchema.optional().default({}),
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZBaseEmbedDataSchema } from './embed-base-schemas';
|
||||
|
||||
export const ZDirectTemplateEmbedDataSchema = ZBaseEmbedDataSchema.extend({
|
||||
email: z
|
||||
.union([z.literal(''), z.string().email()])
|
||||
.optional()
|
||||
.transform((value) => value || undefined),
|
||||
lockEmail: z.boolean().optional().default(false),
|
||||
name: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value || undefined),
|
||||
lockName: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
export type TDirectTemplateEmbedDataSchema = z.infer<typeof ZDirectTemplateEmbedDataSchema>;
|
||||
|
||||
export type TDirectTemplateEmbedDataInputSchema = z.input<typeof ZDirectTemplateEmbedDataSchema>;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZBaseEmbedDataSchema } from './embed-base-schemas';
|
||||
|
||||
export const ZSignDocumentEmbedDataSchema = ZBaseEmbedDataSchema.extend({
|
||||
email: z
|
||||
.union([z.literal(''), z.string().email()])
|
||||
.optional()
|
||||
.transform((value) => value || undefined),
|
||||
lockEmail: z.boolean().optional().default(false),
|
||||
name: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value || undefined),
|
||||
lockName: z.boolean().optional().default(false),
|
||||
allowDocumentRejection: z.boolean().optional(),
|
||||
showOtherRecipientsCompletedFields: z.boolean().optional(),
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZBaseEmbedDataSchema } from './embed-base-schemas';
|
||||
|
||||
export const ZEmbedMultiSignDocumentSchema = ZBaseEmbedDataSchema.extend({
|
||||
email: z
|
||||
.union([z.literal(''), z.string().email()])
|
||||
.optional()
|
||||
.transform((value) => value || undefined),
|
||||
lockEmail: z.boolean().optional().default(false),
|
||||
name: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value || undefined),
|
||||
lockName: z.boolean().optional().default(false),
|
||||
allowDocumentRejection: z.boolean().optional(),
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZBaseEmbedDataSchema } from '@documenso/lib/types/embed-base-schemas';
|
||||
import { ZEnvelopeFieldSchema } from '@documenso/lib/types/field';
|
||||
import { ZEnvelopeRecipientLiteSchema } from '@documenso/lib/types/recipient';
|
||||
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
|
||||
import { EnvelopeAttachmentSchema } from '@documenso/prisma/generated/zod/modelSchema/EnvelopeAttachmentSchema';
|
||||
import { EnvelopeItemSchema } from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
|
||||
import { EnvelopeSchema } from '@documenso/prisma/generated/zod/modelSchema/EnvelopeSchema';
|
||||
import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
|
||||
import { TemplateDirectLinkSchema } from '@documenso/prisma/generated/zod/modelSchema/TemplateDirectLinkSchema';
|
||||
|
||||
/**
|
||||
* DO NOT MAKE ANY BREAKING BACKWARD CHANGES HERE UNLESS YOU'RE SURE
|
||||
* IT WON'T BREAK EMBEDDINGS.
|
||||
*
|
||||
* Keep this in sync with the embedded repo (the types + schema)
|
||||
*/
|
||||
export const ZEnvelopeEditorSettingsSchema = z.object({
|
||||
/**
|
||||
* Generic editor related configurations.
|
||||
*/
|
||||
general: z.object({
|
||||
allowConfigureEnvelopeTitle: z.boolean(),
|
||||
allowUploadAndRecipientStep: z.boolean(),
|
||||
allowAddFieldsStep: z.boolean(),
|
||||
allowPreviewStep: z.boolean(),
|
||||
minimizeLeftSidebar: z.boolean(),
|
||||
}),
|
||||
|
||||
/**
|
||||
* Envelope meta/settings related configuration
|
||||
*
|
||||
* If null, the settings will not be available to be seen/updated.
|
||||
*/
|
||||
settings: z
|
||||
.object({
|
||||
allowConfigureSignatureTypes: z.boolean(),
|
||||
allowConfigureLanguage: z.boolean(),
|
||||
allowConfigureDateFormat: z.boolean(),
|
||||
allowConfigureTimezone: z.boolean(),
|
||||
allowConfigureRedirectUrl: z.boolean(),
|
||||
allowConfigureDistribution: z.boolean(),
|
||||
allowConfigureExpirationPeriod: z.boolean(),
|
||||
allowConfigureEmailSender: z.boolean(),
|
||||
allowConfigureEmailReplyTo: z.boolean(),
|
||||
})
|
||||
.nullable(),
|
||||
|
||||
/**
|
||||
* Action related configurations.
|
||||
*/
|
||||
actions: z.object({
|
||||
allowAttachments: z.boolean(),
|
||||
allowDistributing: z.boolean(),
|
||||
allowDirectLink: z.boolean(),
|
||||
allowDuplication: z.boolean(),
|
||||
allowDownloadPDF: z.boolean(),
|
||||
allowDeletion: z.boolean(),
|
||||
}),
|
||||
|
||||
/**
|
||||
* Envelope items related configurations.
|
||||
*
|
||||
* If null, no adjustments to envelope items will be allowed.
|
||||
*/
|
||||
envelopeItems: z
|
||||
.object({
|
||||
allowConfigureTitle: z.boolean(),
|
||||
allowConfigureOrder: z.boolean(),
|
||||
allowUpload: z.boolean(),
|
||||
allowDelete: z.boolean(),
|
||||
})
|
||||
.nullable(),
|
||||
|
||||
/**
|
||||
* Recipient related configurations.
|
||||
*
|
||||
* If null, recipients will not be configurable at all.
|
||||
*/
|
||||
recipients: z
|
||||
.object({
|
||||
allowAIDetection: z.boolean(),
|
||||
allowConfigureSigningOrder: z.boolean(),
|
||||
allowConfigureDictateNextSigner: z.boolean(),
|
||||
allowApproverRole: z.boolean(),
|
||||
allowViewerRole: z.boolean(),
|
||||
allowCCerRole: z.boolean(),
|
||||
allowAssistantRole: z.boolean(),
|
||||
})
|
||||
.nullable(),
|
||||
|
||||
/**
|
||||
* Fields related configurations.
|
||||
*/
|
||||
fields: z.object({
|
||||
allowAIDetection: z.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type TEnvelopeEditorSettings = z.infer<typeof ZEnvelopeEditorSettingsSchema>;
|
||||
|
||||
/**
|
||||
* The default editor configuration for normal flows.
|
||||
*/
|
||||
export const DEFAULT_EDITOR_CONFIG: EnvelopeEditorConfig = {
|
||||
general: {
|
||||
allowConfigureEnvelopeTitle: true,
|
||||
allowUploadAndRecipientStep: true,
|
||||
allowAddFieldsStep: true,
|
||||
allowPreviewStep: true,
|
||||
minimizeLeftSidebar: false,
|
||||
},
|
||||
settings: {
|
||||
allowConfigureSignatureTypes: true,
|
||||
allowConfigureLanguage: true,
|
||||
allowConfigureDateFormat: true,
|
||||
allowConfigureTimezone: true,
|
||||
allowConfigureRedirectUrl: true,
|
||||
allowConfigureDistribution: true,
|
||||
allowConfigureExpirationPeriod: true,
|
||||
allowConfigureEmailSender: true,
|
||||
allowConfigureEmailReplyTo: true,
|
||||
},
|
||||
actions: {
|
||||
allowAttachments: true,
|
||||
allowDistributing: true,
|
||||
allowDirectLink: true,
|
||||
allowDuplication: true,
|
||||
allowDownloadPDF: true,
|
||||
allowDeletion: true,
|
||||
},
|
||||
envelopeItems: {
|
||||
allowConfigureTitle: true,
|
||||
allowConfigureOrder: true,
|
||||
allowUpload: true,
|
||||
allowDelete: true,
|
||||
},
|
||||
recipients: {
|
||||
allowAIDetection: true,
|
||||
allowConfigureSigningOrder: true,
|
||||
allowConfigureDictateNextSigner: true,
|
||||
|
||||
allowApproverRole: true,
|
||||
allowViewerRole: true,
|
||||
allowCCerRole: true,
|
||||
allowAssistantRole: true,
|
||||
},
|
||||
fields: {
|
||||
allowAIDetection: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The default configuration for the embedded editor. This is merged with whatever is provided
|
||||
* by the embedded hash.
|
||||
*
|
||||
* This is duplicated in the embedded repo playground
|
||||
*
|
||||
* /playground/src/components/embedddings/envelope-feature.ts
|
||||
*/
|
||||
export const DEFAULT_EMBEDDED_EDITOR_CONFIG = {
|
||||
general: {
|
||||
allowConfigureEnvelopeTitle: true,
|
||||
allowUploadAndRecipientStep: true,
|
||||
allowAddFieldsStep: true,
|
||||
allowPreviewStep: true,
|
||||
minimizeLeftSidebar: true,
|
||||
},
|
||||
settings: {
|
||||
allowConfigureSignatureTypes: true,
|
||||
allowConfigureLanguage: true,
|
||||
allowConfigureDateFormat: true,
|
||||
allowConfigureTimezone: true,
|
||||
allowConfigureRedirectUrl: true,
|
||||
allowConfigureDistribution: true,
|
||||
allowConfigureExpirationPeriod: true,
|
||||
allowConfigureEmailSender: true,
|
||||
allowConfigureEmailReplyTo: true,
|
||||
},
|
||||
actions: {
|
||||
allowAttachments: true,
|
||||
allowDistributing: false, // These are not supported for embeds, and are directly excluded in the embedded repo.
|
||||
allowDirectLink: false, // These are not supported for embeds, and are directly excluded in the embedded repo.
|
||||
allowDuplication: false, // These are not supported for embeds, and are directly excluded in the embedded repo.
|
||||
allowDownloadPDF: false, // These are not supported for embeds, and are directly excluded in the embedded repo.
|
||||
allowDeletion: false, // These are not supported for embeds, and are directly excluded in the embedded repo.
|
||||
},
|
||||
envelopeItems: {
|
||||
allowConfigureTitle: true,
|
||||
allowConfigureOrder: true,
|
||||
allowUpload: true,
|
||||
allowDelete: true,
|
||||
},
|
||||
recipients: {
|
||||
allowAIDetection: false, // These are not supported for embeds, and are directly excluded in the embedded repo.
|
||||
allowConfigureSigningOrder: true,
|
||||
allowConfigureDictateNextSigner: true,
|
||||
allowApproverRole: true,
|
||||
allowViewerRole: true,
|
||||
allowCCerRole: true,
|
||||
allowAssistantRole: true,
|
||||
},
|
||||
fields: {
|
||||
allowAIDetection: false, // These are not supported for embeds, and are directly excluded in the embedded repo.
|
||||
},
|
||||
} as const satisfies EnvelopeEditorConfig;
|
||||
|
||||
export const ZEmbedCreateEnvelopeAuthoringSchema = ZBaseEmbedDataSchema.extend({
|
||||
externalId: z.string().optional(),
|
||||
type: z.nativeEnum(EnvelopeType),
|
||||
features: z.object({}).passthrough().optional().default(DEFAULT_EMBEDDED_EDITOR_CONFIG),
|
||||
});
|
||||
|
||||
export const ZEmbedEditEnvelopeAuthoringSchema = ZBaseEmbedDataSchema.extend({
|
||||
externalId: z.string().optional(),
|
||||
features: z.object({}).passthrough().optional().default(DEFAULT_EMBEDDED_EDITOR_CONFIG),
|
||||
});
|
||||
|
||||
export type TEmbedCreateEnvelopeAuthoring = z.infer<typeof ZEmbedCreateEnvelopeAuthoringSchema>;
|
||||
export type TEmbedEditEnvelopeAuthoring = z.infer<typeof ZEmbedEditEnvelopeAuthoringSchema>;
|
||||
|
||||
/**
|
||||
* A subset of the full envelope response schema used for the envelope editor.
|
||||
*
|
||||
* Internal usage only.
|
||||
*/
|
||||
export const ZEditorEnvelopeSchema = EnvelopeSchema.pick({
|
||||
internalVersion: true,
|
||||
type: true,
|
||||
status: true,
|
||||
source: true,
|
||||
visibility: true,
|
||||
templateType: true,
|
||||
id: true,
|
||||
secondaryId: true,
|
||||
externalId: true,
|
||||
completedAt: true,
|
||||
deletedAt: true,
|
||||
title: true,
|
||||
authOptions: true,
|
||||
publicTitle: true,
|
||||
publicDescription: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
folderId: true,
|
||||
}).extend({
|
||||
documentMeta: DocumentMetaSchema.pick({
|
||||
signingOrder: true,
|
||||
distributionMethod: true,
|
||||
id: true,
|
||||
subject: true,
|
||||
message: true,
|
||||
timezone: true,
|
||||
dateFormat: true,
|
||||
redirectUrl: true,
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
language: true,
|
||||
emailSettings: true,
|
||||
emailId: true,
|
||||
emailReplyTo: true,
|
||||
envelopeExpirationPeriod: true,
|
||||
}),
|
||||
recipients: ZEnvelopeRecipientLiteSchema.array(),
|
||||
fields: ZEnvelopeFieldSchema.array(),
|
||||
envelopeItems: EnvelopeItemSchema.pick({
|
||||
envelopeId: true,
|
||||
id: true,
|
||||
title: true,
|
||||
order: true,
|
||||
documentDataId: true,
|
||||
})
|
||||
.extend({
|
||||
// Only used for embedded.
|
||||
data: z.instanceof(Uint8Array).optional(),
|
||||
})
|
||||
.array(),
|
||||
directLink: TemplateDirectLinkSchema.pick({
|
||||
directTemplateRecipientId: true,
|
||||
enabled: true,
|
||||
id: true,
|
||||
token: true,
|
||||
}).nullable(),
|
||||
team: TeamSchema.pick({
|
||||
id: true,
|
||||
url: true,
|
||||
organisationId: true,
|
||||
}),
|
||||
user: z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
}),
|
||||
attachments: EnvelopeAttachmentSchema.pick({
|
||||
id: true,
|
||||
type: true,
|
||||
label: true,
|
||||
data: true,
|
||||
}).array(),
|
||||
});
|
||||
|
||||
export type TEditorEnvelope = z.infer<typeof ZEditorEnvelopeSchema>;
|
||||
|
||||
export type EnvelopeEditorConfig = TEnvelopeEditorSettings & {
|
||||
embedded?: {
|
||||
presignToken: string;
|
||||
mode: 'create' | 'edit';
|
||||
onCreate?: (envelope: Omit<TEditorEnvelope, 'id'>) => void;
|
||||
onUpdate?: (envelope: TEditorEnvelope) => void;
|
||||
customBrandingLogo?: boolean;
|
||||
};
|
||||
};
|
||||
@@ -419,4 +419,4 @@ export const ZEnvelopeFieldAndMetaSchema = z.discriminatedUnion('type', [
|
||||
}),
|
||||
]);
|
||||
|
||||
type TEnvelopeFieldAndMeta = z.infer<typeof ZEnvelopeFieldAndMetaSchema>;
|
||||
export type TEnvelopeFieldAndMeta = z.infer<typeof ZEnvelopeFieldAndMetaSchema>;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { RecipientSchema } from '@documenso/prisma/generated/zod/modelSchema/RecipientSchema';
|
||||
@@ -120,10 +119,5 @@ export const ZEnvelopeRecipientManySchema = ZRecipientManySchema.omit({
|
||||
|
||||
export const ZRecipientEmailSchema = z.union([
|
||||
z.literal(''),
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.email({ message: msg`Invalid email`.id })
|
||||
.max(254),
|
||||
z.string().trim().toLowerCase().email({ message: 'Invalid email' }).max(254),
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { EnvelopeEditorConfig } from '../types/envelope-editor';
|
||||
import { DEFAULT_EMBEDDED_EDITOR_CONFIG } from '../types/envelope-editor';
|
||||
|
||||
export const PRESIGNED_ENVELOPE_ITEM_ID_PREFIX = 'PRESIGNED_';
|
||||
|
||||
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
|
||||
|
||||
/**
|
||||
* Takes parsed `features` from the embedding hash and an `embedded` config,
|
||||
* and produces a complete `EnvelopeEditorConfig` with sensible embedded-mode defaults.
|
||||
*
|
||||
* Any explicitly provided feature flag overrides the embedded default.
|
||||
*/
|
||||
export function buildEmbeddedEditorOptions(
|
||||
features: DeepPartial<EnvelopeEditorConfig>,
|
||||
embedded: EnvelopeEditorConfig['embedded'],
|
||||
): EnvelopeEditorConfig {
|
||||
return {
|
||||
embedded,
|
||||
...buildEmbeddedFeatures(features),
|
||||
};
|
||||
}
|
||||
|
||||
export const buildEmbeddedFeatures = (
|
||||
features: DeepPartial<EnvelopeEditorConfig>,
|
||||
): EnvelopeEditorConfig => {
|
||||
return {
|
||||
general: {
|
||||
allowConfigureEnvelopeTitle:
|
||||
features.general?.allowConfigureEnvelopeTitle ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowConfigureEnvelopeTitle,
|
||||
allowUploadAndRecipientStep:
|
||||
features.general?.allowUploadAndRecipientStep ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowUploadAndRecipientStep,
|
||||
allowAddFieldsStep:
|
||||
features.general?.allowAddFieldsStep ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowAddFieldsStep,
|
||||
allowPreviewStep:
|
||||
features.general?.allowPreviewStep ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowPreviewStep,
|
||||
minimizeLeftSidebar:
|
||||
features.general?.minimizeLeftSidebar ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.minimizeLeftSidebar,
|
||||
},
|
||||
|
||||
settings:
|
||||
features.settings !== null
|
||||
? {
|
||||
allowConfigureSignatureTypes:
|
||||
features.settings?.allowConfigureSignatureTypes ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureSignatureTypes,
|
||||
allowConfigureLanguage:
|
||||
features.settings?.allowConfigureLanguage ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureLanguage,
|
||||
allowConfigureDateFormat:
|
||||
features.settings?.allowConfigureDateFormat ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureDateFormat,
|
||||
allowConfigureTimezone:
|
||||
features.settings?.allowConfigureTimezone ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureTimezone,
|
||||
allowConfigureRedirectUrl:
|
||||
features.settings?.allowConfigureRedirectUrl ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureRedirectUrl,
|
||||
allowConfigureDistribution:
|
||||
features.settings?.allowConfigureDistribution ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureDistribution,
|
||||
allowConfigureExpirationPeriod:
|
||||
features.settings?.allowConfigureExpirationPeriod ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureExpirationPeriod,
|
||||
allowConfigureEmailSender:
|
||||
features.settings?.allowConfigureEmailSender ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureEmailSender,
|
||||
allowConfigureEmailReplyTo:
|
||||
features.settings?.allowConfigureEmailReplyTo ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureEmailReplyTo,
|
||||
}
|
||||
: null,
|
||||
|
||||
actions: {
|
||||
allowAttachments:
|
||||
features.actions?.allowAttachments ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowAttachments,
|
||||
allowDistributing:
|
||||
features.actions?.allowDistributing ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDistributing,
|
||||
allowDirectLink:
|
||||
features.actions?.allowDirectLink ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDirectLink,
|
||||
allowDuplication:
|
||||
features.actions?.allowDuplication ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDuplication,
|
||||
allowDownloadPDF:
|
||||
features.actions?.allowDownloadPDF ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDownloadPDF,
|
||||
allowDeletion:
|
||||
features.actions?.allowDeletion ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDeletion,
|
||||
},
|
||||
|
||||
envelopeItems:
|
||||
features.envelopeItems !== null
|
||||
? {
|
||||
allowConfigureTitle:
|
||||
features.envelopeItems?.allowConfigureTitle ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowConfigureTitle,
|
||||
allowConfigureOrder:
|
||||
features.envelopeItems?.allowConfigureOrder ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowConfigureOrder,
|
||||
allowUpload:
|
||||
features.envelopeItems?.allowUpload ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowUpload,
|
||||
allowDelete:
|
||||
features.envelopeItems?.allowDelete ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowDelete,
|
||||
}
|
||||
: null,
|
||||
|
||||
recipients:
|
||||
features.recipients !== null
|
||||
? {
|
||||
allowAIDetection:
|
||||
features.recipients?.allowAIDetection ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowAIDetection,
|
||||
allowConfigureSigningOrder:
|
||||
features.recipients?.allowConfigureSigningOrder ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowConfigureSigningOrder,
|
||||
allowConfigureDictateNextSigner:
|
||||
features.recipients?.allowConfigureDictateNextSigner ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowConfigureDictateNextSigner,
|
||||
allowApproverRole:
|
||||
features.recipients?.allowApproverRole ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowApproverRole,
|
||||
allowViewerRole:
|
||||
features.recipients?.allowViewerRole ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowViewerRole,
|
||||
allowCCerRole:
|
||||
features.recipients?.allowCCerRole ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowCCerRole,
|
||||
allowAssistantRole:
|
||||
features.recipients?.allowAssistantRole ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowAssistantRole,
|
||||
}
|
||||
: null,
|
||||
|
||||
fields: {
|
||||
allowAIDetection:
|
||||
features.fields?.allowAIDetection ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.fields.allowAIDetection,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -72,3 +72,18 @@ export const getDocumentDataUrl = (options: DocumentDataUrlOptions) => {
|
||||
|
||||
return baseUrl;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a PDF url for the PDF viewer.
|
||||
*
|
||||
* Returns `null` if invalid.
|
||||
*/
|
||||
export const getDocumentDataUrlForPdfViewer = (options: DocumentDataUrlOptions): string | null => {
|
||||
const { envelopeId, envelopeItemId, documentDataId } = options;
|
||||
|
||||
if (!envelopeId || !envelopeItemId || !documentDataId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getDocumentDataUrl(options);
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ module.exports = {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
'envelope-editor-background': 'hsl(var(--envelope-editor-background))',
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { router } from '../trpc';
|
||||
import { createEmbeddingDocumentRoute } from './create-embedding-document';
|
||||
import { createEmbeddingEnvelopeRoute } from './create-embedding-envelope';
|
||||
import { createEmbeddingPresignTokenRoute } from './create-embedding-presign-token';
|
||||
import { createEmbeddingTemplateRoute } from './create-embedding-template';
|
||||
import { getMultiSignDocumentRoute } from './get-multi-sign-document';
|
||||
import { updateEmbeddingDocumentRoute } from './update-embedding-document';
|
||||
import { updateEmbeddingEnvelopeRoute } from './update-embedding-envelope';
|
||||
import { updateEmbeddingTemplateRoute } from './update-embedding-template';
|
||||
import { verifyEmbeddingPresignTokenRoute } from './verify-embedding-presign-token';
|
||||
|
||||
export const embeddingPresignRouter = router({
|
||||
createEmbeddingPresignToken: createEmbeddingPresignTokenRoute,
|
||||
verifyEmbeddingPresignToken: verifyEmbeddingPresignTokenRoute,
|
||||
createEmbeddingEnvelope: createEmbeddingEnvelopeRoute,
|
||||
createEmbeddingDocument: createEmbeddingDocumentRoute,
|
||||
createEmbeddingTemplate: createEmbeddingTemplateRoute,
|
||||
updateEmbeddingEnvelope: updateEmbeddingEnvelopeRoute,
|
||||
updateEmbeddingDocument: updateEmbeddingDocumentRoute,
|
||||
updateEmbeddingTemplate: updateEmbeddingTemplateRoute,
|
||||
// applyMultiSignSignature: applyMultiSignSignatureRoute,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
|
||||
import { createEnvelopeRouteCaller } from '../envelope-router/create-envelope';
|
||||
import { procedure } from '../trpc';
|
||||
import {
|
||||
ZCreateEmbeddingEnvelopeRequestSchema,
|
||||
ZCreateEmbeddingEnvelopeResponseSchema,
|
||||
} from './create-embedding-envelope.types';
|
||||
|
||||
export const createEmbeddingEnvelopeRoute = procedure
|
||||
.input(ZCreateEmbeddingEnvelopeRequestSchema)
|
||||
.output(ZCreateEmbeddingEnvelopeResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { req } = ctx;
|
||||
|
||||
const authorizationHeader = req.headers.get('authorization');
|
||||
|
||||
const [presignToken] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
|
||||
|
||||
if (!presignToken) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'No presign token provided',
|
||||
});
|
||||
}
|
||||
|
||||
const apiToken = await verifyEmbeddingPresignToken({ token: presignToken });
|
||||
|
||||
const { userId, teamId } = apiToken;
|
||||
|
||||
return await createEnvelopeRouteCaller({
|
||||
userId,
|
||||
teamId,
|
||||
input,
|
||||
options: {
|
||||
// Default recipients should be added on the frontend automatically for embeds.
|
||||
bypassDefaultRecipients: true,
|
||||
},
|
||||
apiRequestMetadata: ctx.metadata,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import {
|
||||
ZCreateEnvelopeRequestSchema,
|
||||
ZCreateEnvelopeResponseSchema,
|
||||
} from '../envelope-router/create-envelope.types';
|
||||
|
||||
export const ZCreateEmbeddingEnvelopeRequestSchema = ZCreateEnvelopeRequestSchema;
|
||||
|
||||
export const ZCreateEmbeddingEnvelopeResponseSchema = ZCreateEnvelopeResponseSchema;
|
||||
@@ -24,7 +24,9 @@ export const ZCreateEmbeddingPresignTokenRequestSchema = z.object({
|
||||
scope: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Resource restriction. Example: documentId:1, templateId:2'),
|
||||
.describe(
|
||||
'Resource restriction. V1 embeds only support documentId:1, templateId:2. V2 embeds only support envelopeId:envelope_123',
|
||||
),
|
||||
});
|
||||
|
||||
export const ZCreateEmbeddingPresignTokenResponseSchema = z.object({
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import pMap from 'p-map';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { UNSAFE_createEnvelopeItems } from '@documenso/lib/server-only/envelope-item/create-envelope-items';
|
||||
import { UNSAFE_deleteEnvelopeItem } from '@documenso/lib/server-only/envelope-item/delete-envelope-item';
|
||||
import { UNSAFE_updateEnvelopeItems } from '@documenso/lib/server-only/envelope-item/update-envelope-items';
|
||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { updateEnvelope } from '@documenso/lib/server-only/envelope/update-envelope';
|
||||
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
||||
import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template';
|
||||
import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients';
|
||||
import { setTemplateRecipients } from '@documenso/lib/server-only/recipient/set-template-recipients';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
|
||||
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { procedure } from '../trpc';
|
||||
import {
|
||||
ZUpdateEmbeddingEnvelopeRequestSchema,
|
||||
ZUpdateEmbeddingEnvelopeResponseSchema,
|
||||
} from './update-embedding-envelope.types';
|
||||
|
||||
export const updateEmbeddingEnvelopeRoute = procedure
|
||||
.input(ZUpdateEmbeddingEnvelopeRequestSchema)
|
||||
.output(ZUpdateEmbeddingEnvelopeResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { payload, files } = input;
|
||||
const { envelopeId, data, meta } = payload;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
envelopeId,
|
||||
},
|
||||
});
|
||||
|
||||
const authorizationHeader = ctx.req.headers.get('authorization');
|
||||
|
||||
const [presignToken] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
|
||||
|
||||
if (!presignToken) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'No presign token provided',
|
||||
});
|
||||
}
|
||||
|
||||
const apiToken = await verifyEmbeddingPresignToken({
|
||||
token: presignToken,
|
||||
scope: `envelopeId:${envelopeId}`,
|
||||
});
|
||||
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
type: null, // Allow updating both documents and templates.
|
||||
userId: apiToken.userId,
|
||||
teamId: apiToken.teamId,
|
||||
});
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: envelopeWhereInput,
|
||||
include: {
|
||||
envelopeItems: true,
|
||||
team: {
|
||||
select: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
recipients: true,
|
||||
envelopeAttachments: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.status === DocumentStatus.COMPLETED) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Cannot modify completed envelope',
|
||||
});
|
||||
}
|
||||
|
||||
// Step 1: Update the envelope items.
|
||||
const envelopeItemsToUpdate: EnvelopeItemUpdateOptions[] = [];
|
||||
const envelopeItemsToCreate: EnvelopeItemCreateOptions[] = [];
|
||||
|
||||
// Sort and group envelope items to update and create.
|
||||
data.envelopeItems.forEach((item) => {
|
||||
const isNewEnvelopeItem = item.id.startsWith(PRESIGNED_ENVELOPE_ITEM_ID_PREFIX);
|
||||
|
||||
// Handle existing envelope items.
|
||||
if (!isNewEnvelopeItem) {
|
||||
const envelopeItem = envelope.envelopeItems.find(
|
||||
(envelopeItem) => envelopeItem.id === item.id,
|
||||
);
|
||||
|
||||
if (!envelopeItem) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope item not found',
|
||||
});
|
||||
}
|
||||
|
||||
const hasEnvelopeItemChanged =
|
||||
envelopeItem.title !== item.title || envelopeItem.order !== item.order;
|
||||
|
||||
if (hasEnvelopeItemChanged) {
|
||||
envelopeItemsToUpdate.push({
|
||||
envelopeItemId: envelopeItem.id,
|
||||
title: item.title,
|
||||
order: item.order,
|
||||
});
|
||||
}
|
||||
|
||||
// Return to continue loop.
|
||||
return;
|
||||
}
|
||||
|
||||
const newEnvelopeItemFile = item.index !== undefined ? files[item.index] : undefined;
|
||||
|
||||
if (!newEnvelopeItemFile) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Invalid envelope item index',
|
||||
});
|
||||
}
|
||||
|
||||
// Handle not yet uploaded envelope items.
|
||||
envelopeItemsToCreate.push({
|
||||
embeddedEnvelopeItemId: item.id,
|
||||
title: item.title,
|
||||
order: item.order,
|
||||
file: newEnvelopeItemFile,
|
||||
});
|
||||
});
|
||||
|
||||
// Delete envelope items that have been removed from the payload.
|
||||
const envelopeItemIdsToDelete = envelope.envelopeItems
|
||||
.filter((item) => !data.envelopeItems.some((i) => i.id === item.id))
|
||||
.map((item) => item.id);
|
||||
|
||||
const willEnvelopeItemsBeModified =
|
||||
envelopeItemIdsToDelete.length > 0 ||
|
||||
envelopeItemsToCreate.length > 0 ||
|
||||
envelopeItemsToUpdate.length > 0;
|
||||
|
||||
const organisationClaim = envelope.team.organisation.organisationClaim;
|
||||
const resultingEnvelopeItemCount =
|
||||
envelope.envelopeItems.length - envelopeItemIdsToDelete.length + envelopeItemsToCreate.length;
|
||||
|
||||
if (resultingEnvelopeItemCount > organisationClaim.envelopeItemCount) {
|
||||
throw new AppError('ENVELOPE_ITEM_LIMIT_EXCEEDED', {
|
||||
message: `You cannot upload more than ${organisationClaim.envelopeItemCount} envelope items`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Should be safe to use stale envelope.recipients since only signed or sent
|
||||
// recipients affect the outcome.
|
||||
if (willEnvelopeItemsBeModified && !canEnvelopeItemsBeModified(envelope, envelope.recipients)) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Envelope item is not editable',
|
||||
});
|
||||
}
|
||||
|
||||
if (envelopeItemIdsToDelete.length > 0) {
|
||||
await pMap(
|
||||
envelopeItemIdsToDelete,
|
||||
async (envelopeItemId) => {
|
||||
await UNSAFE_deleteEnvelopeItem({
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId,
|
||||
user: apiToken.user,
|
||||
apiRequestMetadata: ctx.metadata,
|
||||
});
|
||||
},
|
||||
{ concurrency: 2 },
|
||||
);
|
||||
}
|
||||
|
||||
// Mapping for the client side embedded prefix envelope item IDs to the real envelope item IDs.
|
||||
const embeddedEnvelopeItemIdMapping: Record<string, string> = {};
|
||||
|
||||
// Create new envelope items.
|
||||
if (envelopeItemsToCreate.length > 0) {
|
||||
const createdEnvelopeItems = await UNSAFE_createEnvelopeItems({
|
||||
files: envelopeItemsToCreate.map((item) => ({
|
||||
clientId: item.embeddedEnvelopeItemId,
|
||||
file: item.file,
|
||||
orderOverride: item.order,
|
||||
})),
|
||||
envelope: {
|
||||
...envelope,
|
||||
// Purposefully putting empty recipients here since placeholders should automatically injected on the client side for
|
||||
// embedded purposes. Todo: Embeds - (Not implemeneted yet)
|
||||
recipients: [],
|
||||
},
|
||||
user: {
|
||||
id: apiToken.user.id,
|
||||
name: apiToken.user.name,
|
||||
email: apiToken.user.email,
|
||||
},
|
||||
apiRequestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
// Build the map from the envelope item order.
|
||||
createdEnvelopeItems.forEach((item) => {
|
||||
if (!item.clientId) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Client ID not found',
|
||||
});
|
||||
}
|
||||
|
||||
embeddedEnvelopeItemIdMapping[item.clientId] = item.id;
|
||||
});
|
||||
}
|
||||
|
||||
if (envelopeItemsToUpdate.length > 0) {
|
||||
await UNSAFE_updateEnvelopeItems({
|
||||
envelopeId: envelope.id,
|
||||
data: envelopeItemsToUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: Update the general envelope data and meta.
|
||||
await updateEnvelope({
|
||||
userId: apiToken.userId,
|
||||
teamId: apiToken.teamId,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelope.id,
|
||||
},
|
||||
data: {
|
||||
title: data.title,
|
||||
externalId: data.externalId,
|
||||
visibility: data.visibility,
|
||||
globalAccessAuth: data.globalAccessAuth,
|
||||
globalActionAuth: data.globalActionAuth,
|
||||
folderId: data.folderId,
|
||||
},
|
||||
meta,
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
// Step 3: Update the recipients
|
||||
const recipientsWithClientId = data.recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
clientId: nanoid(),
|
||||
}));
|
||||
|
||||
const { recipients: updatedRecipients } = await match(envelope.type)
|
||||
.with(EnvelopeType.DOCUMENT, async () =>
|
||||
setDocumentRecipients({
|
||||
userId: apiToken.userId,
|
||||
teamId: apiToken.teamId,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelope.id,
|
||||
},
|
||||
recipients: recipientsWithClientId.map((recipient) => ({
|
||||
id: recipient.id,
|
||||
clientId: recipient.clientId,
|
||||
email: recipient.email,
|
||||
name: recipient.name ?? '',
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
actionAuth: recipient.actionAuth,
|
||||
})),
|
||||
requestMetadata: ctx.metadata,
|
||||
}),
|
||||
)
|
||||
.with(EnvelopeType.TEMPLATE, async () =>
|
||||
setTemplateRecipients({
|
||||
userId: apiToken.userId,
|
||||
teamId: apiToken.teamId,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelope.id,
|
||||
},
|
||||
recipients: recipientsWithClientId.map((recipient) => ({
|
||||
id: recipient.id,
|
||||
clientId: recipient.clientId,
|
||||
email: recipient.email,
|
||||
name: recipient.name ?? '',
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
actionAuth: recipient.actionAuth,
|
||||
})),
|
||||
}),
|
||||
)
|
||||
.exhaustive();
|
||||
|
||||
// Step 4: Update the fields.
|
||||
const fields = recipientsWithClientId.flatMap((recipient) => {
|
||||
const recipientId = updatedRecipients.find((r) => r.clientId === recipient.clientId)?.id;
|
||||
|
||||
if (!recipientId) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Recipient not found',
|
||||
});
|
||||
}
|
||||
|
||||
return (recipient.fields ?? []).map((field) => {
|
||||
let envelopeItemId = field.envelopeItemId;
|
||||
|
||||
if (envelopeItemId.startsWith(PRESIGNED_ENVELOPE_ITEM_ID_PREFIX)) {
|
||||
envelopeItemId = embeddedEnvelopeItemIdMapping[envelopeItemId];
|
||||
}
|
||||
|
||||
if (!envelopeItemId) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope item not found',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
recipientId,
|
||||
envelopeItemId,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
await match(envelope.type)
|
||||
.with(EnvelopeType.DOCUMENT, async () =>
|
||||
setFieldsForDocument({
|
||||
userId: apiToken.userId,
|
||||
teamId: apiToken.teamId,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
fields: fields.map((field) => ({
|
||||
...field,
|
||||
pageNumber: field.page,
|
||||
pageX: field.positionX,
|
||||
pageY: field.positionY,
|
||||
pageWidth: field.width,
|
||||
pageHeight: field.height,
|
||||
})),
|
||||
requestMetadata: ctx.metadata,
|
||||
}),
|
||||
)
|
||||
.with(EnvelopeType.TEMPLATE, async () =>
|
||||
setFieldsForTemplate({
|
||||
userId: apiToken.userId,
|
||||
teamId: apiToken.teamId,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
fields: fields.map((field) => ({
|
||||
...field,
|
||||
pageNumber: field.page,
|
||||
pageX: field.positionX,
|
||||
pageY: field.positionY,
|
||||
pageWidth: field.width,
|
||||
pageHeight: field.height,
|
||||
})),
|
||||
}),
|
||||
)
|
||||
.exhaustive();
|
||||
|
||||
// Step 5: Handle attachments (set semantics: delete all existing, create new).
|
||||
let hasEnvelopeAttachmentsChanged =
|
||||
envelope.envelopeAttachments.length !== data.attachments.length;
|
||||
|
||||
data.attachments.forEach((attachment) => {
|
||||
const foundAttachment = envelope.envelopeAttachments.find((a) => a.id === attachment.id);
|
||||
|
||||
if (!foundAttachment) {
|
||||
hasEnvelopeAttachmentsChanged = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const hasAttachmentChanged =
|
||||
foundAttachment.label !== attachment.label ||
|
||||
foundAttachment.data !== attachment.data ||
|
||||
foundAttachment.type !== attachment.type;
|
||||
|
||||
if (hasAttachmentChanged) {
|
||||
hasEnvelopeAttachmentsChanged = true;
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasEnvelopeAttachmentsChanged) {
|
||||
await prisma.envelopeAttachment.deleteMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (data.attachments.length > 0) {
|
||||
await prisma.envelopeAttachment.createMany({
|
||||
data: data.attachments.map((attachment) => ({
|
||||
envelopeId: envelope.id,
|
||||
label: attachment.label,
|
||||
data: attachment.data,
|
||||
type: attachment.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type EnvelopeItemUpdateOptions = {
|
||||
envelopeItemId: string;
|
||||
title?: string;
|
||||
order?: number;
|
||||
};
|
||||
|
||||
type EnvelopeItemCreateOptions = {
|
||||
embeddedEnvelopeItemId: string;
|
||||
title: string;
|
||||
order: number;
|
||||
file: File;
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import { z } from 'zod';
|
||||
import { zfd } from 'zod-form-data';
|
||||
|
||||
import {
|
||||
ZDocumentAccessAuthTypesSchema,
|
||||
ZDocumentActionAuthTypesSchema,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { ZDocumentMetaUpdateSchema } from '@documenso/lib/types/document-meta';
|
||||
import {
|
||||
ZClampedFieldHeightSchema,
|
||||
ZClampedFieldPositionXSchema,
|
||||
ZClampedFieldPositionYSchema,
|
||||
ZClampedFieldWidthSchema,
|
||||
ZFieldPageNumberSchema,
|
||||
} from '@documenso/lib/types/field';
|
||||
import { ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { EnvelopeAttachmentSchema } from '@documenso/prisma/generated/zod/modelSchema/EnvelopeAttachmentSchema';
|
||||
import { ZSetEnvelopeRecipientSchema } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
|
||||
|
||||
import { zodFormData } from '../../utils/zod-form-data';
|
||||
import {
|
||||
ZDocumentExternalIdSchema,
|
||||
ZDocumentTitleSchema,
|
||||
ZDocumentVisibilitySchema,
|
||||
} from '../document-router/schema';
|
||||
|
||||
export const ZUpdateEmbeddingEnvelopePayloadSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
data: z.object({
|
||||
title: ZDocumentTitleSchema.optional(),
|
||||
externalId: ZDocumentExternalIdSchema.nullish(),
|
||||
visibility: ZDocumentVisibilitySchema.optional(),
|
||||
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
|
||||
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
|
||||
folderId: z.string().nullish(),
|
||||
|
||||
/**
|
||||
* The list of envelope items that are part of the envelope.
|
||||
*
|
||||
* Any missing IDs will be treated as deleting the envelope item.
|
||||
*/
|
||||
envelopeItems: z
|
||||
.object({
|
||||
/**
|
||||
* This is not necesssarily a real id, it can be a temporary id for the envelope item.
|
||||
*/
|
||||
id: z.string(),
|
||||
|
||||
/**
|
||||
* The title of the envelope item.
|
||||
*/
|
||||
title: z.string(),
|
||||
|
||||
/**
|
||||
* The order of the envelope item in the envelope.
|
||||
*/
|
||||
order: z.number().int().min(0),
|
||||
|
||||
/**
|
||||
* The file index for items that are not yet uploaded.
|
||||
*/
|
||||
index: z.number().int().min(0).optional(),
|
||||
})
|
||||
.array(),
|
||||
|
||||
/**
|
||||
* This is a set command.
|
||||
*/
|
||||
recipients: ZSetEnvelopeRecipientSchema.extend({
|
||||
fields: ZEnvelopeFieldAndMetaSchema.and(
|
||||
z.object({
|
||||
id: z.number().optional(),
|
||||
page: ZFieldPageNumberSchema,
|
||||
positionX: ZClampedFieldPositionXSchema,
|
||||
positionY: ZClampedFieldPositionYSchema,
|
||||
width: ZClampedFieldWidthSchema,
|
||||
height: ZClampedFieldHeightSchema,
|
||||
envelopeItemId: z.string(),
|
||||
}),
|
||||
).array(),
|
||||
}).array(),
|
||||
|
||||
/**
|
||||
* The list of attachments for the envelope.
|
||||
*
|
||||
* This is a set command: when provided, all existing attachments are deleted
|
||||
* and replaced with the provided list.
|
||||
*/
|
||||
attachments: EnvelopeAttachmentSchema.pick({
|
||||
type: true,
|
||||
label: true,
|
||||
data: true,
|
||||
})
|
||||
.extend({
|
||||
id: z.string().optional(),
|
||||
})
|
||||
.array(),
|
||||
}),
|
||||
|
||||
meta: ZDocumentMetaUpdateSchema.optional(),
|
||||
});
|
||||
|
||||
export const ZUpdateEmbeddingEnvelopeRequestSchema = zodFormData({
|
||||
payload: zfd.json(ZUpdateEmbeddingEnvelopePayloadSchema),
|
||||
files: zfd.repeatableOfType(zfd.file()),
|
||||
});
|
||||
|
||||
export const ZUpdateEmbeddingEnvelopeResponseSchema = z.void();
|
||||
|
||||
export type TUpdateEmbeddingEnvelopePayload = z.infer<typeof ZUpdateEmbeddingEnvelopePayloadSchema>;
|
||||
export type TUpdateEmbeddingEnvelopeRequest = z.infer<typeof ZUpdateEmbeddingEnvelopeRequestSchema>;
|
||||
@@ -1,16 +1,6 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { UNSAFE_createEnvelopeItems } from '@documenso/lib/server-only/envelope-item/create-envelope-items';
|
||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import {
|
||||
convertPlaceholdersToFieldInputs,
|
||||
extractPdfPlaceholders,
|
||||
} from '@documenso/lib/server-only/pdf/auto-place-fields';
|
||||
import { findRecipientByPlaceholder } from '@documenso/lib/server-only/pdf/helpers';
|
||||
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
|
||||
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { prefixedId } from '@documenso/lib/universal/id';
|
||||
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
@@ -91,130 +81,17 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
// For each file: normalize, extract & clean placeholders, then upload.
|
||||
const envelopeItems = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
let buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
if (envelope.formValues) {
|
||||
buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues });
|
||||
}
|
||||
|
||||
const normalized = await normalizePdf(buffer, {
|
||||
flattenForm: envelope.type !== 'TEMPLATE',
|
||||
});
|
||||
|
||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
||||
|
||||
const { id: documentDataId } = await putPdfFileServerSide({
|
||||
name: file.name,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(cleanedPdf),
|
||||
});
|
||||
|
||||
return {
|
||||
title: file.name,
|
||||
documentDataId,
|
||||
placeholders,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const currentHighestOrderValue =
|
||||
envelope.envelopeItems[envelope.envelopeItems.length - 1]?.order ?? 1;
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const createdItems = await tx.envelopeItem.createManyAndReturn({
|
||||
data: envelopeItems.map((item) => ({
|
||||
id: prefixedId('envelope_item'),
|
||||
envelopeId,
|
||||
title: item.title,
|
||||
documentDataId: item.documentDataId,
|
||||
order: currentHighestOrderValue + 1,
|
||||
})),
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.createMany({
|
||||
data: createdItems.map((item) =>
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_CREATED,
|
||||
envelopeId: envelope.id,
|
||||
data: {
|
||||
envelopeItemId: item.id,
|
||||
envelopeItemTitle: item.title,
|
||||
},
|
||||
user: {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
},
|
||||
requestMetadata: metadata.requestMetadata,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// Create fields from placeholders if the envelope already has recipients.
|
||||
if (envelope.recipients.length > 0) {
|
||||
const orderedRecipients = [...envelope.recipients].sort((a, b) => {
|
||||
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
|
||||
if (aOrder !== bOrder) {
|
||||
return aOrder - bOrder;
|
||||
}
|
||||
|
||||
return a.id - b.id;
|
||||
});
|
||||
|
||||
for (const uploadedItem of envelopeItems) {
|
||||
if (!uploadedItem.placeholders || uploadedItem.placeholders.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdItem = createdItems.find(
|
||||
(ci) => ci.documentDataId === uploadedItem.documentDataId,
|
||||
);
|
||||
|
||||
if (!createdItem) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldsToCreate = convertPlaceholdersToFieldInputs(
|
||||
uploadedItem.placeholders,
|
||||
(recipientPlaceholder, placeholder) =>
|
||||
findRecipientByPlaceholder(
|
||||
recipientPlaceholder,
|
||||
placeholder,
|
||||
orderedRecipients,
|
||||
orderedRecipients,
|
||||
),
|
||||
createdItem.id,
|
||||
);
|
||||
|
||||
if (fieldsToCreate.length > 0) {
|
||||
await tx.field.createMany({
|
||||
data: fieldsToCreate.map((field) => ({
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: createdItem.id,
|
||||
recipientId: field.recipientId,
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta || undefined,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return createdItems;
|
||||
const result = await UNSAFE_createEnvelopeItems({
|
||||
files: files.map((file) => ({
|
||||
file,
|
||||
})),
|
||||
envelope,
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
},
|
||||
apiRequestMetadata: metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,10 +5,12 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
|
||||
import { extractPdfPlaceholders } from '@documenso/lib/server-only/pdf/auto-place-fields';
|
||||
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
|
||||
import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf';
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import type { TCreateEnvelopeRequest } from './create-envelope.types';
|
||||
import {
|
||||
ZCreateEnvelopeRequestSchema,
|
||||
ZCreateEnvelopeResponseSchema,
|
||||
@@ -20,150 +22,183 @@ export const createEnvelopeRoute = authenticatedProcedure
|
||||
.input(ZCreateEnvelopeRequestSchema)
|
||||
.output(ZCreateEnvelopeResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { user, teamId } = ctx;
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
folderId: input.payload.folderId,
|
||||
},
|
||||
});
|
||||
|
||||
const { payload, files } = input;
|
||||
return await createEnvelopeRouteCaller({
|
||||
userId: ctx.user.id,
|
||||
teamId: ctx.teamId,
|
||||
input,
|
||||
apiRequestMetadata: ctx.metadata,
|
||||
});
|
||||
});
|
||||
|
||||
const {
|
||||
title,
|
||||
type CreateEnvelopeRouteOptions = {
|
||||
/**
|
||||
* Verified user ID.
|
||||
*/
|
||||
userId: number;
|
||||
|
||||
/**
|
||||
* Unverified team ID.
|
||||
*/
|
||||
teamId: number;
|
||||
input: TCreateEnvelopeRequest;
|
||||
apiRequestMetadata: ApiRequestMetadata;
|
||||
|
||||
options?: {
|
||||
bypassDefaultRecipients?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const createEnvelopeRouteCaller = async ({
|
||||
userId,
|
||||
teamId,
|
||||
input,
|
||||
apiRequestMetadata,
|
||||
options = {},
|
||||
}: CreateEnvelopeRouteOptions) => {
|
||||
const { payload, files } = input;
|
||||
|
||||
const {
|
||||
title,
|
||||
type,
|
||||
externalId,
|
||||
visibility,
|
||||
globalAccessAuth,
|
||||
globalActionAuth,
|
||||
formValues,
|
||||
recipients,
|
||||
folderId,
|
||||
meta,
|
||||
attachments,
|
||||
delegatedDocumentOwner,
|
||||
} = payload;
|
||||
|
||||
const { remaining, maximumEnvelopeItemCount } = await getServerLimits({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
if (remaining.documents <= 0) {
|
||||
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
|
||||
message: 'You have reached your document limit for this month. Please upgrade your plan.',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (files.length > maximumEnvelopeItemCount) {
|
||||
throw new AppError('ENVELOPE_ITEM_LIMIT_EXCEEDED', {
|
||||
message: `You cannot upload more than ${maximumEnvelopeItemCount} envelope items per envelope`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (files.some((file) => !file.type.startsWith('application/pdf'))) {
|
||||
throw new AppError('INVALID_DOCUMENT_FILE', {
|
||||
message: 'You cannot upload non-PDF files',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// For each file: normalize, extract & clean placeholders, then upload.
|
||||
const envelopeItems = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
let pdf = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
if (formValues) {
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
pdf = await insertFormValuesInPdf({
|
||||
pdf,
|
||||
formValues,
|
||||
});
|
||||
}
|
||||
|
||||
const normalized = await normalizePdf(pdf, {
|
||||
flattenForm: type !== EnvelopeType.TEMPLATE,
|
||||
});
|
||||
|
||||
// Todo: Embeds - Might need to add this for client-side embeds in the future.
|
||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
||||
|
||||
const { id: documentDataId } = await putPdfFileServerSide({
|
||||
name: file.name,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(cleanedPdf),
|
||||
});
|
||||
|
||||
return {
|
||||
title: file.name,
|
||||
documentDataId,
|
||||
placeholders,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const recipientsToCreate = recipients?.map((recipient) => ({
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
accessAuth: recipient.accessAuth,
|
||||
actionAuth: recipient.actionAuth,
|
||||
fields: recipient.fields?.map((field) => {
|
||||
let documentDataId: string | undefined = undefined;
|
||||
|
||||
if (typeof field.identifier === 'string') {
|
||||
documentDataId = envelopeItems.find(
|
||||
(item) => item.title === field.identifier,
|
||||
)?.documentDataId;
|
||||
}
|
||||
|
||||
if (typeof field.identifier === 'number') {
|
||||
documentDataId = envelopeItems.at(field.identifier)?.documentDataId;
|
||||
}
|
||||
|
||||
if (field.identifier === undefined) {
|
||||
documentDataId = envelopeItems.at(0)?.documentDataId;
|
||||
}
|
||||
|
||||
if (!documentDataId) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document data not found',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
documentDataId,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
const envelope = await createEnvelope({
|
||||
userId,
|
||||
teamId,
|
||||
internalVersion: 2,
|
||||
data: {
|
||||
type,
|
||||
title,
|
||||
externalId,
|
||||
formValues,
|
||||
visibility,
|
||||
globalAccessAuth,
|
||||
globalActionAuth,
|
||||
formValues,
|
||||
recipients,
|
||||
recipients: recipientsToCreate,
|
||||
folderId,
|
||||
meta,
|
||||
attachments,
|
||||
envelopeItems,
|
||||
delegatedDocumentOwner,
|
||||
} = payload;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
folderId,
|
||||
},
|
||||
});
|
||||
|
||||
const { remaining, maximumEnvelopeItemCount } = await getServerLimits({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
});
|
||||
|
||||
if (remaining.documents <= 0) {
|
||||
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
|
||||
message: 'You have reached your document limit for this month. Please upgrade your plan.',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (files.length > maximumEnvelopeItemCount) {
|
||||
throw new AppError('ENVELOPE_ITEM_LIMIT_EXCEEDED', {
|
||||
message: `You cannot upload more than ${maximumEnvelopeItemCount} envelope items per envelope`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (files.some((file) => !file.type.startsWith('application/pdf'))) {
|
||||
throw new AppError('INVALID_DOCUMENT_FILE', {
|
||||
message: 'You cannot upload non-PDF files',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// For each file: normalize, extract & clean placeholders, then upload.
|
||||
const envelopeItems = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
let pdf = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
if (formValues) {
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
pdf = await insertFormValuesInPdf({
|
||||
pdf,
|
||||
formValues,
|
||||
});
|
||||
}
|
||||
|
||||
const normalized = await normalizePdf(pdf, {
|
||||
flattenForm: type !== EnvelopeType.TEMPLATE,
|
||||
});
|
||||
|
||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
||||
|
||||
const { id: documentDataId } = await putPdfFileServerSide({
|
||||
name: file.name,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(cleanedPdf),
|
||||
});
|
||||
|
||||
return {
|
||||
title: file.name,
|
||||
documentDataId,
|
||||
placeholders,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const recipientsToCreate = recipients?.map((recipient) => ({
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
accessAuth: recipient.accessAuth,
|
||||
actionAuth: recipient.actionAuth,
|
||||
fields: recipient.fields?.map((field) => {
|
||||
let documentDataId: string | undefined = undefined;
|
||||
|
||||
if (typeof field.identifier === 'string') {
|
||||
documentDataId = envelopeItems.find(
|
||||
(item) => item.title === field.identifier,
|
||||
)?.documentDataId;
|
||||
}
|
||||
|
||||
if (typeof field.identifier === 'number') {
|
||||
documentDataId = envelopeItems.at(field.identifier)?.documentDataId;
|
||||
}
|
||||
|
||||
if (field.identifier === undefined) {
|
||||
documentDataId = envelopeItems.at(0)?.documentDataId;
|
||||
}
|
||||
|
||||
if (!documentDataId) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document data not found',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
documentDataId,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
const envelope = await createEnvelope({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
internalVersion: 2,
|
||||
data: {
|
||||
type,
|
||||
title,
|
||||
externalId,
|
||||
formValues,
|
||||
visibility,
|
||||
globalAccessAuth,
|
||||
globalActionAuth,
|
||||
recipients: recipientsToCreate,
|
||||
folderId,
|
||||
envelopeItems,
|
||||
delegatedDocumentOwner,
|
||||
},
|
||||
attachments,
|
||||
meta,
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
id: envelope.id,
|
||||
};
|
||||
},
|
||||
attachments,
|
||||
meta,
|
||||
requestMetadata: apiRequestMetadata,
|
||||
bypassDefaultRecipients: options.bypassDefaultRecipients,
|
||||
});
|
||||
|
||||
return {
|
||||
id: envelope.id,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { UNSAFE_deleteEnvelopeItem } from '@documenso/lib/server-only/envelope-item/delete-envelope-item';
|
||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
@@ -57,49 +56,11 @@ export const deleteEnvelopeItemRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const deletedEnvelopeItem = await tx.envelopeItem.delete({
|
||||
where: {
|
||||
id: envelopeItemId,
|
||||
envelopeId: envelope.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
documentData: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_DELETED,
|
||||
envelopeId: envelope.id,
|
||||
data: {
|
||||
envelopeItemId: deletedEnvelopeItem.id,
|
||||
envelopeItemTitle: deletedEnvelopeItem.title,
|
||||
},
|
||||
user: {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
},
|
||||
requestMetadata: metadata.requestMetadata,
|
||||
}),
|
||||
});
|
||||
|
||||
return deletedEnvelopeItem;
|
||||
});
|
||||
|
||||
await prisma.documentData.delete({
|
||||
where: {
|
||||
id: result.documentData.id,
|
||||
envelopeItem: {
|
||||
is: null,
|
||||
},
|
||||
},
|
||||
await UNSAFE_deleteEnvelopeItem({
|
||||
envelopeId,
|
||||
envelopeItemId,
|
||||
user,
|
||||
apiRequestMetadata: metadata,
|
||||
});
|
||||
|
||||
return ZGenericSuccessResponse;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { getEditorEnvelopeById } from '@documenso/lib/server-only/envelope/get-editor-envelope-by-id';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZGetEditorEnvelopeRequestSchema,
|
||||
ZGetEditorEnvelopeResponseSchema,
|
||||
} from './get-editor-envelope.types';
|
||||
|
||||
export const getEditorEnvelopeRoute = authenticatedProcedure
|
||||
.input(ZGetEditorEnvelopeRequestSchema)
|
||||
.output(ZGetEditorEnvelopeResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { envelopeId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
envelopeId,
|
||||
},
|
||||
});
|
||||
|
||||
return await getEditorEnvelopeById({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
type: null,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZEditorEnvelopeSchema } from '@documenso/lib/types/envelope-editor';
|
||||
|
||||
export const ZGetEditorEnvelopeRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
});
|
||||
|
||||
export const ZGetEditorEnvelopeResponseSchema = ZEditorEnvelopeSchema;
|
||||
|
||||
export type TGetEditorEnvelopeRequest = z.infer<typeof ZGetEditorEnvelopeRequestSchema>;
|
||||
export type TGetEditorEnvelopeResponse = z.infer<typeof ZGetEditorEnvelopeResponseSchema>;
|
||||
@@ -22,6 +22,7 @@ import { getEnvelopeRecipientRoute } from './envelope-recipients/get-envelope-re
|
||||
import { updateEnvelopeRecipientsRoute } from './envelope-recipients/update-envelope-recipients';
|
||||
import { findEnvelopeAuditLogsRoute } from './find-envelope-audit-logs';
|
||||
import { findEnvelopesRoute } from './find-envelopes';
|
||||
import { getEditorEnvelopeRoute } from './get-editor-envelope';
|
||||
import { getEnvelopeRoute } from './get-envelope';
|
||||
import { getEnvelopeItemsRoute } from './get-envelope-items';
|
||||
import { getEnvelopeItemsByTokenRoute } from './get-envelope-items-by-token';
|
||||
@@ -78,6 +79,9 @@ export const envelopeRouter = router({
|
||||
move: bulkMoveEnvelopesRoute,
|
||||
delete: bulkDeleteEnvelopesRoute,
|
||||
},
|
||||
editor: {
|
||||
get: getEditorEnvelopeRoute,
|
||||
},
|
||||
get: getEnvelopeRoute,
|
||||
getMany: getEnvelopesByIdsRoute,
|
||||
create: createEnvelopeRoute,
|
||||
|
||||
@@ -4,19 +4,19 @@ import { z } from 'zod';
|
||||
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
||||
import { ZRecipientEmailSchema, ZRecipientLiteSchema } from '@documenso/lib/types/recipient';
|
||||
|
||||
export const ZSetEnvelopeRecipientSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
email: ZRecipientEmailSchema,
|
||||
name: z.string().max(255),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
||||
});
|
||||
|
||||
export const ZSetEnvelopeRecipientsRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
envelopeType: z.nativeEnum(EnvelopeType),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
id: z.number().optional(),
|
||||
email: ZRecipientEmailSchema,
|
||||
name: z.string().max(255),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
||||
}),
|
||||
),
|
||||
recipients: ZSetEnvelopeRecipientSchema.array(),
|
||||
});
|
||||
|
||||
export const ZSetEnvelopeRecipientsResponseSchema = z.object({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { UNSAFE_updateEnvelopeItems } from '@documenso/lib/server-only/envelope-item/update-envelope-items';
|
||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@@ -54,6 +55,9 @@ export const updateEnvelopeItemsRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
// Note: This logic is duplicated in many places. If we plan to allow changing title/order
|
||||
// even after the envelope has been sent, make sure to update it everywhere including
|
||||
// embedding routes.
|
||||
if (!canEnvelopeItemsBeModified(envelope, envelope.recipients)) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Envelope item is not editable',
|
||||
@@ -71,28 +75,10 @@ export const updateEnvelopeItemsRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
const updatedEnvelopeItems = await Promise.all(
|
||||
data.map(async ({ envelopeItemId, order, title }) =>
|
||||
prisma.envelopeItem.update({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
id: envelopeItemId,
|
||||
},
|
||||
data: {
|
||||
order,
|
||||
title,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
order: true,
|
||||
title: true,
|
||||
envelopeId: true,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Todo: Envelope [AUDIT_LOGS]
|
||||
const updatedEnvelopeItems = await UNSAFE_updateEnvelopeItems({
|
||||
envelopeId,
|
||||
data,
|
||||
});
|
||||
|
||||
return {
|
||||
data: updatedEnvelopeItems,
|
||||
|
||||
@@ -12,14 +12,28 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export type RecipientRoleSelectProps = SelectProps & {
|
||||
hideCCRecipients?: boolean;
|
||||
hideAssistantRole?: boolean;
|
||||
hideCCerRole?: boolean;
|
||||
hideViewerRole?: boolean;
|
||||
hideApproverRole?: boolean;
|
||||
|
||||
isAssistantEnabled?: boolean;
|
||||
};
|
||||
|
||||
export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSelectProps>(
|
||||
({ hideCCRecipients, isAssistantEnabled = true, ...props }, ref) => (
|
||||
(
|
||||
{
|
||||
hideAssistantRole,
|
||||
hideCCerRole,
|
||||
hideViewerRole,
|
||||
hideApproverRole,
|
||||
isAssistantEnabled = true,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<Select {...props}>
|
||||
<SelectTrigger ref={ref} className="bg-background w-[50px] p-2">
|
||||
<SelectTrigger ref={ref} className="w-[50px] bg-background p-2" title={props.value}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
|
||||
{ROLE_ICONS[props.value as RecipientRole]}
|
||||
</SelectTrigger>
|
||||
@@ -35,7 +49,7 @@ export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSe
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
||||
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
|
||||
<p>
|
||||
<Trans>
|
||||
The recipient is required to sign the document for it to be completed.
|
||||
@@ -46,49 +60,53 @@ export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSe
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value={RecipientRole.APPROVER}>
|
||||
<div className="flex items-center">
|
||||
<div className="flex w-[150px] items-center">
|
||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
|
||||
<Trans>Needs to approve</Trans>
|
||||
{!hideApproverRole && (
|
||||
<SelectItem value={RecipientRole.APPROVER}>
|
||||
<div className="flex items-center">
|
||||
<div className="flex w-[150px] items-center">
|
||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
|
||||
<Trans>Needs to approve</Trans>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
|
||||
<p>
|
||||
<Trans>
|
||||
The recipient is required to approve the document for it to be completed.
|
||||
</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
||||
<p>
|
||||
<Trans>
|
||||
The recipient is required to approve the document for it to be completed.
|
||||
</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectItem>
|
||||
)}
|
||||
|
||||
<SelectItem value={RecipientRole.VIEWER}>
|
||||
<div className="flex items-center">
|
||||
<div className="flex w-[150px] items-center">
|
||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
|
||||
<Trans>Needs to view</Trans>
|
||||
{!hideViewerRole && (
|
||||
<SelectItem value={RecipientRole.VIEWER}>
|
||||
<div className="flex items-center">
|
||||
<div className="flex w-[150px] items-center">
|
||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
|
||||
<Trans>Needs to view</Trans>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
|
||||
<p>
|
||||
<Trans>
|
||||
The recipient is required to view the document for it to be completed.
|
||||
</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
||||
<p>
|
||||
<Trans>
|
||||
The recipient is required to view the document for it to be completed.
|
||||
</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectItem>
|
||||
)}
|
||||
|
||||
{!hideCCRecipients && (
|
||||
{!hideCCerRole && (
|
||||
<SelectItem value={RecipientRole.CC}>
|
||||
<div className="flex items-center">
|
||||
<div className="flex w-[150px] items-center">
|
||||
@@ -99,7 +117,7 @@ export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSe
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
||||
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
|
||||
<p>
|
||||
<Trans>
|
||||
The recipient is not required to take any action and receives a copy of the
|
||||
@@ -112,41 +130,43 @@ export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSe
|
||||
</SelectItem>
|
||||
)}
|
||||
|
||||
<SelectItem
|
||||
value={RecipientRole.ASSISTANT}
|
||||
disabled={!isAssistantEnabled}
|
||||
className={cn(
|
||||
!isAssistantEnabled &&
|
||||
'cursor-not-allowed opacity-50 data-[disabled]:pointer-events-auto',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="flex w-[150px] items-center">
|
||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.ASSISTANT]}</span>
|
||||
<Trans>Can prepare</Trans>
|
||||
{!hideAssistantRole && (
|
||||
<SelectItem
|
||||
value={RecipientRole.ASSISTANT}
|
||||
disabled={!isAssistantEnabled}
|
||||
className={cn(
|
||||
!isAssistantEnabled &&
|
||||
'cursor-not-allowed opacity-50 data-[disabled]:pointer-events-auto',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="flex w-[150px] items-center">
|
||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.ASSISTANT]}</span>
|
||||
<Trans>Can prepare</Trans>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
|
||||
<p>
|
||||
{isAssistantEnabled ? (
|
||||
<Trans>
|
||||
The recipient can prepare the document for later signers by pre-filling
|
||||
suggest values.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Assistant role is only available when the document is in sequential signing
|
||||
mode.
|
||||
</Trans>
|
||||
)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
||||
<p>
|
||||
{isAssistantEnabled ? (
|
||||
<Trans>
|
||||
The recipient can prepare the document for later signers by pre-filling
|
||||
suggest values.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Assistant role is only available when the document is in sequential signing
|
||||
mode.
|
||||
</Trans>
|
||||
)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
),
|
||||
|
||||
@@ -533,7 +533,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-muted-foreground ml-1 cursor-help">
|
||||
<span className="ml-1 cursor-help text-muted-foreground">
|
||||
<HelpCircle className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
@@ -586,7 +586,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className={cn('py-1', {
|
||||
'bg-widget-foreground pointer-events-none rounded-md pt-2':
|
||||
'pointer-events-none rounded-md bg-widget-foreground pt-2':
|
||||
snapshot.isDragging,
|
||||
})}
|
||||
>
|
||||
@@ -754,7 +754,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
handleRoleChange(index, value as RecipientRole);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
hideCCRecipients={isSignerDirectRecipient(signer)}
|
||||
hideCCerRole={isSignerDirectRecipient(signer)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -768,11 +768,11 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
<TooltipTrigger className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80">
|
||||
<Link2Icon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
||||
<h3 className="text-foreground text-lg font-semibold">
|
||||
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
<Trans>Direct link receiver</Trans>
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
<Trans>
|
||||
This field cannot be modified or deleted. When you share
|
||||
this template's direct link or add it to your public
|
||||
@@ -829,7 +829,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 bg-black/5 hover:bg-black/10"
|
||||
className="bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
|
||||
variant="secondary"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
@@ -852,7 +852,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
/>
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 text-sm"
|
||||
className="ml-2 text-sm text-muted-foreground"
|
||||
htmlFor="showAdvancedRecipientSettings"
|
||||
>
|
||||
<Trans>Show advanced settings</Trans>
|
||||
|
||||
@@ -137,6 +137,8 @@
|
||||
/* Surface */
|
||||
--new-surface-black: 0, 0%, 0%;
|
||||
--new-surface-white: 0, 0%, 91%;
|
||||
|
||||
--envelope-editor-background: 210 40% 98.04%;
|
||||
}
|
||||
|
||||
.dark:not(.dark-mode-disabled) {
|
||||
@@ -181,6 +183,8 @@
|
||||
--warning: 54 96% 45%;
|
||||
|
||||
--gold: 47.9 95.8% 53.1%;
|
||||
|
||||
--envelope-editor-background: 0 0% 14.9%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user