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:
David Nguyen 2026-03-06 14:11:27 +11:00 committed by GitHub
parent 7e2cbe46c0
commit 7ea664214a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
96 changed files with 9887 additions and 1916 deletions

View file

@ -0,0 +1,371 @@
---
name: envelope-editor-v2-e2e
description: Writing and maintaining Playwright E2E tests for the Envelope Editor V2. Use when the user needs to create, modify, debug, or extend E2E tests in packages/app-tests/e2e/envelope-editor-v2/. Triggers include requests to "write an e2e test", "add a test for the envelope editor", "test envelope settings/recipients/fields/items/attachments", "fix a failing envelope test", or any task involving Playwright tests for the envelope editor feature.
---
# Envelope Editor V2 E2E Tests
## Overview
The Envelope Editor V2 E2E test suite lives in `packages/app-tests/e2e/envelope-editor-v2/`. Each test file covers a distinct feature area of the envelope editor and follows a strict architectural pattern that tests the **same flow** across four surfaces:
1. **Document** (`documents/<id>`) - Native document editor
2. **Template** (`templates/<id>`) - Native template editor
3. **Embedded Create** (`/embed/v2/authoring/envelope/create`) - Embedded editor creating a new envelope
4. **Embedded Edit** (`/embed/v2/authoring/envelope/edit/<id>`) - Embedded editor updating an existing envelope
## Project Structure
```
packages/app-tests/
e2e/
envelope-editor-v2/
envelope-attachments.spec.ts # Attachment CRUD
envelope-fields.spec.ts # Field placement on PDF canvas
envelope-items.spec.ts # PDF document item CRUD
envelope-recipients.spec.ts # Recipient management
envelope-settings.spec.ts # Settings dialog
fixtures/
authentication.ts # apiSignin, apiSignout
documents.ts # Document tab helpers
envelope-editor.ts # Core fixture: surface openers + locator/action helpers
generic.ts # Toast assertions, text visibility
signature.ts # Signature pad helpers
playwright.config.ts # Test configuration
```
## Core Abstraction: `TEnvelopeEditorSurface`
Every test revolves around the `TEnvelopeEditorSurface` type from `fixtures/envelope-editor.ts`. This is the central abstraction that normalizes differences between the four surfaces:
```typescript
type TEnvelopeEditorSurface = {
root: Page; // The Playwright page
isEmbedded: boolean; // true for embed surfaces
envelopeId?: string; // Set for document/template/embed-edit, undefined for embed-create
envelopeType: 'DOCUMENT' | 'TEMPLATE';
userId: number; // Seeded user ID
userEmail: string; // Seeded user email
userName: string; // Seeded user name
teamId: number; // Seeded team ID
};
```
### Surface Openers (from `fixtures/envelope-editor.ts`)
```typescript
// Native surfaces - seed user + document/template, sign in, navigate
const surface = await openDocumentEnvelopeEditor(page);
const surface = await openTemplateEnvelopeEditor(page);
// Embedded surfaces - seed user, create API token, get presign token, navigate
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT' | 'TEMPLATE',
mode?: 'create' | 'edit', // default: 'create'
tokenNamePrefix?: string, // for unique API token names
externalId?: string, // optional external ID in hash
features?: EmbeddedEditorConfig, // feature flags
});
```
## Test Architecture Pattern
Every test file follows this structure, with four `test.describe` blocks grouping tests by editor surface:
### 1. Imports
```typescript
import { type Page, expect, test } from '@playwright/test';
// Prisma enums if needed for DB assertions
import { SomePrismaEnum } from '@prisma/client';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface, // Import needed helpers from the fixture
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope, // ... other helpers
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
```
### 2. Type definitions and constants
```typescript
type FlowResult = {
externalId: string;
// ... other data needed for DB assertions
};
const TEST_VALUES = {
// Centralized test data constants
};
```
### 3. Local helper functions
```typescript
// Common: open settings and set external ID for DB lookup
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');
}
};
```
### 4. The flow function
A single `runXxxFlow` function that works across ALL surfaces. It handles embedded vs non-embedded differences internally:
```typescript
const runMyFeatureFlow = async (surface: TEnvelopeEditorSurface): Promise<FlowResult> => {
const externalId = `e2e-feature-${nanoid()}`;
// For embedded create, may need to add a PDF first
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(surface.root, 'embedded-feature.pdf');
}
await updateExternalId(surface, externalId);
// Handle embedded vs native differences
if (surface.isEmbedded) {
// No "Add Myself" button in embedded mode
await setRecipientEmail(surface.root, 0, 'embedded@example.com');
} else {
await clickAddMyselfButton(surface.root);
}
// ... perform feature-specific actions ...
// Navigate away and back to verify UI persistence
await clickEnvelopeEditorStep(surface.root, 'addFields');
await clickEnvelopeEditorStep(surface.root, 'upload');
// ... assert UI state after navigation ...
return { externalId /* ... */ };
};
```
### 5. Database assertion function
Uses Prisma directly to verify data was persisted correctly:
```typescript
const assertFeaturePersistedInDatabase = async ({
surface,
externalId,
// ... expected values
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
// ...
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
include: {
// Include related data as needed
documentMeta: true,
recipients: true,
fields: true,
envelopeAttachments: true,
},
orderBy: { createdAt: 'desc' },
});
// Assert expected values
expect(envelope.someField).toBe(expectedValue);
};
```
### 6. The four `test.describe` blocks
Tests are organized into four `test.describe` blocks, one per editor surface. Each describe block contains the tests relevant to that surface. This structure allows adding multiple tests per surface while keeping them grouped:
```typescript
test.describe('document editor', () => {
test('description of what is tested', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runMyFeatureFlow(surface);
await assertFeaturePersistedInDatabase({
surface,
...result,
});
});
// Additional document-editor-specific tests here...
});
test.describe('template editor', () => {
test('description of what is tested', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runMyFeatureFlow(surface);
await assertFeaturePersistedInDatabase({
surface,
...result,
});
});
// Additional template-editor-specific tests here...
});
test.describe('embedded create', () => {
test('description of what is tested', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-feature',
});
const result = await runMyFeatureFlow(surface);
// IMPORTANT: Must persist before DB assertions for embedded
await persistEmbeddedEnvelope(surface);
await assertFeaturePersistedInDatabase({
surface,
...result,
});
});
// Additional embedded-create-specific tests here...
});
test.describe('embedded edit', () => {
test('description of what is tested', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-feature',
});
const result = await runMyFeatureFlow(surface);
// IMPORTANT: Must persist before DB assertions for embedded
await persistEmbeddedEnvelope(surface);
await assertFeaturePersistedInDatabase({
surface,
...result,
});
});
// Additional embedded-edit-specific tests here...
});
```
When a test only applies to specific surfaces (e.g., a document-only action like "send document"), only include it in the relevant describe block(s). Not every describe block needs the same tests -- the structure groups tests by surface, not by requiring symmetry.
## Key Differences Between Surfaces
| Behavior | Document/Template | Embedded Create | Embedded Edit |
| -------------------------- | -------------------------- | ----------------------------------------- | ----------------------------------------- |
| User seeding | Seed + sign in | Seed + API token | Seed + API token + seed envelope |
| "Add Myself" button | Available | Not available | Not available |
| Toast on settings update | Yes (`'Envelope updated'`) | No | No |
| PDF already attached | Yes (1 item) | No (0 items, must upload) | Yes (1 item) |
| Delete confirmation dialog | Yes (`'Delete'` button) | No (immediate) | No (immediate) |
| DB persistence timing | Immediate (autosaved) | After `persistEmbeddedEnvelope()` | After `persistEmbeddedEnvelope()` |
| Persist button label | N/A | `'Create Document'` / `'Create Template'` | `'Update Document'` / `'Update Template'` |
## Available Fixture Helpers
### From `fixtures/envelope-editor.ts`
**Locator helpers** (return Playwright Locators):
- `getEnvelopeEditorSettingsTrigger(root)` - Settings gear button
- `getEnvelopeItemTitleInputs(root)` - Title inputs for envelope items
- `getEnvelopeItemDragHandles(root)` - Drag handles for reordering items
- `getEnvelopeItemRemoveButtons(root)` - Remove buttons for items
- `getEnvelopeItemDropzoneInput(root)` - File input for PDF upload
- `getRecipientEmailInputs(root)` - Email inputs for recipients
- `getRecipientNameInputs(root)` - Name inputs for recipients
- `getRecipientRows(root)` - Full recipient row fieldsets
- `getRecipientRemoveButtons(root)` - Remove buttons for recipients
- `getSigningOrderInputs(root)` - Signing order number inputs
**Action helpers**:
- `addEnvelopeItemPdf(root, fileName?)` - Upload a PDF to the dropzone
- `clickEnvelopeEditorStep(root, stepId)` - Navigate to a step: `'upload'`, `'addFields'`, `'preview'`
- `clickAddMyselfButton(root)` - Click "Add Myself" (native only)
- `clickAddSignerButton(root)` - Click "Add Signer"
- `setRecipientEmail(root, index, email)` - Fill recipient email
- `setRecipientName(root, index, name)` - Fill recipient name
- `setRecipientRole(root, index, roleLabel)` - Set role via combobox
- `assertRecipientRole(root, index, roleLabel)` - Assert role value
- `toggleSigningOrder(root, enabled)` - Toggle signing order switch
- `toggleAllowDictateSigners(root, enabled)` - Toggle dictate signers switch
- `setSigningOrderValue(root, index, value)` - Set signing order number
- `persistEmbeddedEnvelope(surface)` - Click Create/Update button for embedded flows
### From `fixtures/generic.ts`
- `expectTextToBeVisible(page, text)` - Assert text visible on page
- `expectTextToNotBeVisible(page, text)` - Assert text not visible
- `expectToastTextToBeVisible(page, text)` - Assert toast message visible
## External ID Pattern
Every test uses an `externalId` (e.g., `e2e-feature-${nanoid()}`) set via the settings dialog. This unique ID is then used in Prisma queries to reliably locate the envelope in the database for assertions. This is critical because multiple tests run in parallel.
## Running Tests
```bash
# Run all envelope editor tests
npm run test:dev -w @documenso/app-tests -- --grep "Envelope Editor V2"
# Run a specific test file
npm run test:dev -w @documenso/app-tests -- e2e/envelope-editor-v2/envelope-recipients.spec.ts
# Run with UI
npm run test-ui:dev -w @documenso/app-tests -- e2e/envelope-editor-v2/
# Run specific test by name
npm run test:dev -w @documenso/app-tests -- --grep "documents/<id>: add myself"
```
## Checklist When Writing a New Test
1. Create the spec file in `packages/app-tests/e2e/envelope-editor-v2/`
2. Import `TEnvelopeEditorSurface` and the three opener functions
3. Import `persistEmbeddedEnvelope` if you need DB assertions for embedded flows
4. Define a `FlowResult` type for data passed between flow and assertion
5. Define `TEST_VALUES` constants for test data
6. Write `updateExternalId` helper (or reuse the pattern)
7. Write the `runXxxFlow` function handling embedded vs native differences
8. Write the `assertXxxPersistedInDatabase` function using Prisma
9. Create four `test.describe` blocks: `'document editor'`, `'template editor'`, `'embedded create'`, `'embedded edit'`
10. Place tests inside the appropriate describe block for each surface
11. For embedded create tests, add a PDF via `addEnvelopeItemPdf` before the flow
12. For embedded tests, call `persistEmbeddedEnvelope(surface)` before DB assertions
13. Use `surface.isEmbedded` to branch on behavioral differences (toasts, "Add Myself", etc.)
## Common Pitfalls
- **Missing `persistEmbeddedEnvelope`**: Embedded flows don't autosave. You MUST call this before any DB assertions.
- **PDF required for embedded create**: Embedded create starts with 0 items. Upload a PDF before navigating to fields.
- **Toast assertions in embedded**: Don't assert toasts for settings updates in embedded mode (they don't appear).
- **Parallel test isolation**: Always use a unique `externalId` via `nanoid()` so parallel tests don't collide.
- **Navigation verification**: Navigate away from and back to the current step to verify UI state persistence (the editor may re-render).
- **Delete confirmation**: Native surfaces show a confirmation dialog for item deletion; embedded surfaces delete immediately.

View file

@ -0,0 +1,215 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { P, match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
type EnvelopeDeleteDialogProps = {
id: string;
type: EnvelopeType;
trigger?: React.ReactNode;
onDelete?: () => Promise<void> | void;
status: DocumentStatus;
title: string;
canManageDocument: boolean;
};
export const EnvelopeDeleteDialog = ({
id,
type,
trigger,
onDelete,
status,
title,
canManageDocument,
}: EnvelopeDeleteDialogProps) => {
const { toast } = useToast();
const { refreshLimits } = useLimits();
const { t } = useLingui();
const deleteMessage = msg`delete`;
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState('');
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
const { mutateAsync: deleteEnvelope, isPending } = trpcReact.envelope.delete.useMutation({
onSuccess: async () => {
void refreshLimits();
toast({
title: t`Document deleted`,
description: t`"${title}" has been successfully deleted`,
duration: 5000,
});
await onDelete?.();
setOpen(false);
},
onError: () => {
toast({
title: t`Something went wrong`,
description: t`This document could not be deleted at this time. Please try again.`,
variant: 'destructive',
duration: 7500,
});
},
});
useEffect(() => {
if (open) {
setInputValue('');
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
}
}, [open, status]);
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
setIsDeleteEnabled(event.target.value === t(deleteMessage));
};
return (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription>
{canManageDocument ? (
<Trans>
You are about to delete <strong>"{title}"</strong>
</Trans>
) : (
<Trans>
You are about to hide <strong>"{title}"</strong>
</Trans>
)}
</DialogDescription>
</DialogHeader>
{canManageDocument ? (
<Alert variant="warning" className="-mt-1">
{match(status)
.with(DocumentStatus.DRAFT, () => (
<AlertDescription>
{type === EnvelopeType.DOCUMENT ? (
<Trans>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this document will be permanently deleted.
</Trans>
) : (
<Trans>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this template will be permanently deleted.
</Trans>
)}
</AlertDescription>
))
.with(DocumentStatus.PENDING, () => (
<AlertDescription>
<p>
<Trans>
Please note that this action is <strong>irreversible</strong>.
</Trans>
</p>
<p className="mt-1">
<Trans>Once confirmed, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>
<Trans>Document will be permanently deleted</Trans>
</li>
<li>
<Trans>Document signing process will be cancelled</Trans>
</li>
<li>
<Trans>All inserted signatures will be voided</Trans>
</li>
<li>
<Trans>All recipients will be notified</Trans>
</li>
</ul>
</AlertDescription>
))
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
<AlertDescription>
<p>
<Trans>By deleting this document, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>
<Trans>The document will be hidden from your account</Trans>
</li>
<li>
<Trans>Recipients will still retain their copy of the document</Trans>
</li>
</ul>
</AlertDescription>
))
.exhaustive()}
</Alert>
) : (
<Alert variant="warning" className="-mt-1">
<AlertDescription>
<Trans>Please contact support if you would like to revert this action.</Trans>
</AlertDescription>
</Alert>
)}
{status !== DocumentStatus.DRAFT && canManageDocument && (
<Input
type="text"
value={inputValue}
onChange={onInputChange}
placeholder={t`Please type ${`'${t(deleteMessage)}'`} to confirm`}
/>
)}
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
type="button"
loading={isPending}
onClick={() => void deleteEnvelope({ envelopeId: id })}
disabled={!isDeleteEnabled && canManageDocument}
variant="destructive"
>
{canManageDocument ? <Trans>Delete</Trans> : <Trans>Hide</Trans>}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -13,6 +13,7 @@ import * as z from 'zod';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
@ -116,10 +117,15 @@ export const EnvelopeDistributeDialog = ({
} = form;
const { data: emailData, isLoading: isLoadingEmails } =
trpc.enterprise.organisation.email.find.useQuery({
organisationId: organisation.id,
perPage: 100,
});
trpc.enterprise.organisation.email.find.useQuery(
{
organisationId: organisation.id,
perPage: 100,
},
{
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const emails = emailData?.data || [];

View file

@ -15,7 +15,7 @@ import { PDF_VIEWER_PAGE_SELECTOR, getPdfPagesCount } from '@documenso/lib/const
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { nanoid } from '@documenso/lib/universal/id';
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -87,7 +87,7 @@ export const ConfigureFieldsView = ({
const normalizedDocumentData = useMemo(() => {
if (envelopeItem) {
return getDocumentDataUrl({
return getDocumentDataUrlForPdfViewer({
envelopeId: envelopeItem.envelopeId,
envelopeItemId: envelopeItem.id,
documentDataId: envelopeItem.documentDataId,

View file

@ -19,11 +19,12 @@ import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { ZDirectTemplateEmbedDataSchema } from '@documenso/lib/types/embed-direct-template-schema';
import {
isFieldUnsignedAndRequired,
isRequiredField,
} from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { trpc } from '@documenso/trpc/react';
@ -41,7 +42,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { BrandingLogo } from '~/components/general/branding-logo';
import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-schema';
import { injectCss } from '~/utils/css-vars';
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
@ -341,10 +341,10 @@ export const EmbedDirectTemplateClientPage = ({
{/* Viewer */}
<div className="flex-1">
<PDFViewer
data={getDocumentDataUrl({
envelopeId: envelopeItems[0].envelopeId,
envelopeItemId: envelopeItems[0].id,
documentDataId: envelopeItems[0].documentDataId,
data={getDocumentDataUrlForPdfViewer({
envelopeId: envelopeItems[0]?.envelopeId,
envelopeItemId: envelopeItems[0]?.id,
documentDataId: envelopeItems[0]?.documentDataId,
version: 'current',
token: recipient.token,
presignToken: undefined,

View file

@ -9,8 +9,9 @@ import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
@ -32,7 +33,6 @@ import { BrandingLogo } from '~/components/general/branding-logo';
import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
import { injectCss } from '~/utils/css-vars';
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
@ -289,10 +289,10 @@ export const EmbedSignDocumentV1ClientPage = ({
{/* Viewer */}
<div className="embed--DocumentViewer flex-1">
<PDFViewer
data={getDocumentDataUrl({
envelopeId: envelopeItems[0].envelopeId,
envelopeItemId: envelopeItems[0].id,
documentDataId: envelopeItems[0].documentData.id,
data={getDocumentDataUrlForPdfViewer({
envelopeId: envelopeItems[0]?.envelopeId,
envelopeItemId: envelopeItems[0]?.id,
documentDataId: envelopeItems[0]?.documentData.id,
version: 'current',
token: token,
presignToken: undefined,

View file

@ -3,9 +3,9 @@ import { useEffect, useLayoutEffect, useState } from 'react';
import { useLingui } from '@lingui/react';
import { EnvelopeType } from '@prisma/client';
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { ZSignDocumentEmbedDataSchema } from '~/types/embed-document-sign-schema';
import { injectCss } from '~/utils/css-vars';
import { DocumentSigningPageViewV2 } from '../general/document-signing/document-signing-page-view-v2';

View file

@ -9,7 +9,7 @@ import { P, match } from 'ts-pattern';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { trpc } from '@documenso/trpc/react';
@ -230,9 +230,9 @@ export const MultiSignDocumentSigningView = ({
})}
>
<PDFViewer
data={getDocumentDataUrl({
data={getDocumentDataUrlForPdfViewer({
envelopeId: document.envelopeId,
envelopeItemId: document.envelopeItems[0].id,
envelopeItemId: document.envelopeItems[0]?.id,
documentDataId: document.documentData.id,
version: 'current',
token,

View file

@ -184,7 +184,10 @@ export const EditorFieldCheckboxForm = ({
</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectTrigger
data-testid="field-form-direction"
className="w-full bg-background text-muted-foreground"
>
<SelectValue placeholder={t`Select direction`} />
</SelectTrigger>
<SelectContent position="popper">
@ -214,7 +217,10 @@ export const EditorFieldCheckboxForm = ({
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectTrigger
data-testid="field-form-validationRule"
className="w-full bg-background text-muted-foreground"
>
<SelectValue placeholder={t`Select at least`} />
</SelectTrigger>
<SelectContent position="popper">
@ -260,7 +266,10 @@ export const EditorFieldCheckboxForm = ({
void form.trigger();
}}
>
<SelectTrigger className="text-muted-foreground bg-background mt-5 w-full">
<SelectTrigger
data-testid="field-form-validationLength"
className="mt-5 w-full bg-background text-muted-foreground"
>
<SelectValue placeholder={t`Pick a number`} />
</SelectTrigger>
<SelectContent position="popper">
@ -295,7 +304,7 @@ export const EditorFieldCheckboxForm = ({
<Trans>Checkbox values</Trans>
</p>
<button type="button" onClick={() => addValue()}>
<button type="button" data-testid="field-form-values-add" onClick={() => addValue()}>
<PlusIcon className="h-4 w-4" />
</button>
</div>
@ -310,7 +319,8 @@ export const EditorFieldCheckboxForm = ({
<FormItem>
<FormControl>
<Checkbox
className="data-[state=checked]:bg-primary border-foreground/30 h-5 w-5"
data-testid={`field-form-values-${index}-checked`}
className="h-5 w-5 border-foreground/30 data-[state=checked]:bg-primary"
checked={field.value}
onCheckedChange={field.onChange}
/>
@ -325,7 +335,11 @@ export const EditorFieldCheckboxForm = ({
render={({ field }) => (
<FormItem>
<FormControl>
<Input className="w-full" {...field} />
<Input
data-testid={`field-form-values-${index}-value`}
className="w-full"
{...field}
/>
</FormControl>
</FormItem>
)}
@ -333,6 +347,7 @@ export const EditorFieldCheckboxForm = ({
<button
type="button"
data-testid={`field-form-values-${index}-remove`}
className="flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => removeValue(index)}
>

View file

@ -176,7 +176,10 @@ export const EditorFieldDropdownForm = ({
value={field.value ?? '-1'}
onValueChange={(value) => field.onChange(value === '-1' ? undefined : value)}
>
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectTrigger
data-testid="field-form-defaultValue"
className="w-full bg-background text-muted-foreground"
>
<SelectValue placeholder={t`Default Value`} />
</SelectTrigger>
<SelectContent position="popper">
@ -215,7 +218,7 @@ export const EditorFieldDropdownForm = ({
<Trans>Dropdown values</Trans>
</p>
<button type="button" onClick={addValue}>
<button type="button" data-testid="field-form-values-add" onClick={addValue}>
<PlusIcon className="h-4 w-4" />
</button>
</div>
@ -229,7 +232,7 @@ export const EditorFieldDropdownForm = ({
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Input {...field} />
<Input data-testid={`field-form-values-${index}-value`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -238,6 +241,7 @@ export const EditorFieldDropdownForm = ({
<button
type="button"
data-testid={`field-form-values-${index}-remove`}
className="flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => removeValue(index)}
>

View file

@ -50,6 +50,7 @@ export const EditorGenericFontSizeField = ({
</FormLabel>
<FormControl>
<Input
data-testid="field-form-fontSize"
type="number"
min={8}
max={96}
@ -88,7 +89,7 @@ export const EditorGenericTextAlignField = ({
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger>
<SelectTrigger data-testid="field-form-textAlign">
<SelectValue placeholder={t`Select text align`} />
</SelectTrigger>
<SelectContent>
@ -131,7 +132,7 @@ export const EditorGenericVerticalAlignField = ({
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger>
<SelectTrigger data-testid="field-form-verticalAlign">
<SelectValue placeholder={t`Select vertical align`} />
</SelectTrigger>
<SelectContent>
@ -174,6 +175,7 @@ export const EditorGenericLineHeightField = ({
</FormLabel>
<FormControl>
<Input
data-testid="field-form-lineHeight"
type="number"
min={FIELD_MIN_LINE_HEIGHT}
max={FIELD_MAX_LINE_HEIGHT}
@ -209,6 +211,7 @@ export const EditorGenericLetterSpacingField = ({
</FormLabel>
<FormControl>
<Input
data-testid="field-form-letterSpacing"
type="number"
min={FIELD_MIN_LETTER_SPACING}
max={FIELD_MAX_LETTER_SPACING}
@ -250,12 +253,13 @@ export const EditorGenericRequiredField = ({
<FormControl>
<div className="flex items-center">
<Checkbox
data-testid="field-form-required"
id="field-required"
checked={field.value}
onCheckedChange={field.onChange}
/>
<label className="text-muted-foreground ml-2 text-sm" htmlFor="field-required">
<label className="ml-2 text-sm text-muted-foreground" htmlFor="field-required">
<Trans>Required Field</Trans>
</label>
</div>
@ -293,12 +297,13 @@ export const EditorGenericReadOnlyField = ({
<FormControl>
<div className="flex items-center">
<Checkbox
data-testid="field-form-readOnly"
id="field-read-only"
checked={field.value}
onCheckedChange={field.onChange}
/>
<label className="text-muted-foreground ml-2 text-sm" htmlFor="field-read-only">
<label className="ml-2 text-sm text-muted-foreground" htmlFor="field-read-only">
<Trans>Read Only</Trans>
</label>
</div>
@ -329,7 +334,7 @@ export const EditorGenericLabelField = ({
<Trans>Label</Trans>
</FormLabel>
<FormControl>
<Input placeholder={t`Field label`} {...field} />
<Input data-testid="field-form-label" placeholder={t`Field label`} {...field} />
</FormControl>
<FormMessage />
</FormItem>

View file

@ -167,7 +167,12 @@ export const EditorFieldNumberForm = ({
<Trans>Placeholder</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" placeholder={t`Placeholder`} {...field} />
<Input
data-testid="field-form-placeholder"
className="bg-background"
placeholder={t`Placeholder`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@ -183,7 +188,12 @@ export const EditorFieldNumberForm = ({
<Trans>Value</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" placeholder={t`Value`} {...field} />
<Input
data-testid="field-form-value"
className="bg-background"
placeholder={t`Value`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@ -203,7 +213,10 @@ export const EditorFieldNumberForm = ({
value={field.value === null ? '-1' : field.value}
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
>
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectTrigger
data-testid="field-form-numberFormat"
className="w-full bg-background text-muted-foreground"
>
<SelectValue placeholder={t`Field format`} />
</SelectTrigger>
<SelectContent position="popper">
@ -257,6 +270,7 @@ export const EditorFieldNumberForm = ({
</FormLabel>
<FormControl>
<Input
data-testid="field-form-minValue"
className="bg-background"
placeholder={t`E.g. 0`}
{...field}
@ -281,6 +295,7 @@ export const EditorFieldNumberForm = ({
</FormLabel>
<FormControl>
<Input
data-testid="field-form-maxValue"
className="bg-background"
placeholder={t`E.g. 100`}
{...field}

View file

@ -140,7 +140,10 @@ export const EditorFieldRadioForm = ({
</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectTrigger
data-testid="field-form-direction"
className="w-full bg-background text-muted-foreground"
>
<SelectValue placeholder={t`Select direction`} />
</SelectTrigger>
<SelectContent position="popper">
@ -172,7 +175,7 @@ export const EditorFieldRadioForm = ({
<Trans>Radio values</Trans>
</p>
<button type="button" onClick={addValue}>
<button type="button" data-testid="field-form-values-add" onClick={addValue}>
<PlusIcon className="h-4 w-4" />
</button>
</div>
@ -187,7 +190,8 @@ export const EditorFieldRadioForm = ({
<FormItem>
<FormControl>
<Checkbox
className="data-[state=checked]:bg-primary border-foreground/30 h-5 w-5"
data-testid={`field-form-values-${index}-checked`}
className="h-5 w-5 border-foreground/30 data-[state=checked]:bg-primary"
checked={field.value}
onCheckedChange={(value) => {
// Uncheck all other values.
@ -216,7 +220,11 @@ export const EditorFieldRadioForm = ({
render={({ field }) => (
<FormItem>
<FormControl>
<Input className="w-full" {...field} />
<Input
data-testid={`field-form-values-${index}-value`}
className="w-full"
{...field}
/>
</FormControl>
</FormItem>
)}
@ -224,6 +232,7 @@ export const EditorFieldRadioForm = ({
<button
type="button"
data-testid={`field-form-values-${index}-remove`}
className="flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => removeValue(index)}
>

View file

@ -134,7 +134,7 @@ export const EditorFieldTextForm = ({
<Trans>Label</Trans>
</FormLabel>
<FormControl>
<Input placeholder={t`Field label`} {...field} />
<Input data-testid="field-form-label" placeholder={t`Field label`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -150,7 +150,11 @@ export const EditorFieldTextForm = ({
<Trans>Placeholder</Trans>
</FormLabel>
<FormControl>
<Input placeholder={t`Field placeholder`} {...field} />
<Input
data-testid="field-form-placeholder"
placeholder={t`Field placeholder`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@ -167,6 +171,7 @@ export const EditorFieldTextForm = ({
</FormLabel>
<FormControl>
<Textarea
data-testid="field-form-text"
className="h-auto"
placeholder={t`Add text to the field`}
{...field}
@ -200,6 +205,7 @@ export const EditorFieldTextForm = ({
</FormLabel>
<FormControl>
<Input
data-testid="field-form-characterLimit"
className="bg-background"
placeholder={t`Character limit`}
{...field}

View file

@ -9,7 +9,7 @@ import { useNavigate, useSearchParams } from 'react-router';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TTemplate } from '@documenso/lib/types/template';
import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { trpc } from '@documenso/trpc/react';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
@ -154,9 +154,9 @@ export const DirectTemplatePageView = ({
<CardContent className="p-2">
<PDFViewer
key={template.id}
data={getDocumentDataUrl({
data={getDocumentDataUrlForPdfViewer({
envelopeId: template.envelopeId,
envelopeItemId: template.envelopeItems[0].id,
envelopeItemId: template.envelopeItems[0]?.id,
documentDataId: template.templateDocumentDataId,
version: 'current',
token: directTemplateRecipient.token,

View file

@ -22,7 +22,7 @@ import {
} from '@documenso/lib/types/field-meta';
import type { CompletedField } from '@documenso/lib/types/fields';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
@ -274,11 +274,11 @@ export const DocumentSigningPageViewV1 = ({
<Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2">
<PDFViewer
key={document.envelopeItems[0].id}
data={getDocumentDataUrl({
key={document.envelopeItems[0]?.id}
data={getDocumentDataUrlForPdfViewer({
envelopeId: document.envelopeId,
envelopeItemId: document.envelopeItems[0].id,
documentDataId: document.envelopeItems[0].documentData.id,
envelopeItemId: document.envelopeItems[0]?.id,
documentDataId: document.envelopeItems[0]?.documentData.id,
version: 'current',
token: recipient.token,
presignToken: undefined,

View file

@ -8,6 +8,7 @@ import { Paperclip, Plus, X } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@ -36,6 +37,7 @@ const ZAttachmentFormSchema = z.object({
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
// NOTE: REMEMBER TO UPDATE THE EMBEDDED VERSION OF THIS COMPONENT TOO.
export const DocumentAttachmentsPopover = ({
envelopeId,
buttonClassName,
@ -49,9 +51,16 @@ export const DocumentAttachmentsPopover = ({
const utils = trpc.useUtils();
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
envelopeId,
});
const { data: attachments } = trpc.envelope.attachment.find.useQuery(
{
envelopeId,
},
{
// Note: The invalidation of the query is manually handled by the onSuccess
// callbacks below for create and delete mutations.
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const { mutateAsync: createAttachment, isPending: isCreating } =
trpc.envelope.attachment.create.useMutation({
@ -143,7 +152,7 @@ export const DocumentAttachmentsPopover = ({
<h4 className="font-medium">
<Trans>Attachments</Trans>
</h4>
<p className="text-muted-foreground mt-1 text-sm">
<p className="mt-1 text-sm text-muted-foreground">
<Trans>Add links to relevant documents or resources.</Trans>
</p>
</div>
@ -153,7 +162,7 @@ export const DocumentAttachmentsPopover = ({
{attachments?.data.map((attachment) => (
<div
key={attachment.id}
className="border-border flex items-center justify-between rounded-md border p-2"
className="flex items-center justify-between rounded-md border border-border p-2"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{attachment.label}</p>
@ -161,7 +170,7 @@ export const DocumentAttachmentsPopover = ({
href={attachment.data}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground truncate text-xs underline"
className="truncate text-xs text-muted-foreground underline hover:text-foreground"
>
{attachment.data}
</a>

View file

@ -10,7 +10,7 @@ import {
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
@ -151,11 +151,11 @@ export const DocumentCertificateQRView = ({
<div className="mt-12 w-full">
<PDFViewer
key={envelopeItems[0].id}
data={getDocumentDataUrl({
envelopeId: envelopeItems[0].envelopeId,
envelopeItemId: envelopeItems[0].id,
documentDataId: envelopeItems[0].documentDataId,
key={envelopeItems[0]?.id}
data={getDocumentDataUrlForPdfViewer({
envelopeId: envelopeItems[0]?.envelopeId,
envelopeItemId: envelopeItems[0]?.id,
documentDataId: envelopeItems[0]?.documentDataId,
version: 'current',
token,
presignToken: undefined,

View file

@ -14,7 +14,7 @@ import {
} from '@documenso/lib/constants/trpc';
import type { TDocument } from '@documenso/lib/types/document';
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -442,10 +442,10 @@ export const DocumentEditForm = ({
>
<CardContent className="p-2">
<PDFViewer
key={document.envelopeItems[0].id}
data={getDocumentDataUrl({
key={document.envelopeItems[0]?.id}
data={getDocumentDataUrlForPdfViewer({
envelopeId: document.envelopeId,
envelopeItemId: document.envelopeItems[0].id,
envelopeItemId: document.envelopeItems[0]?.id,
documentDataId: initialDocument.documentDataId,
version: 'current',
token: undefined,

View file

@ -0,0 +1,215 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Paperclip, Plus, X } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { nanoid } from '@documenso/lib/universal/id';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EmbeddedEditorAttachmentPopoverProps = {
buttonClassName?: string;
buttonSize?: 'sm' | 'default';
};
const ZAttachmentFormSchema = z.object({
label: z.string().min(1, 'Label is required'),
url: z.string().url('Must be a valid URL'),
});
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
// NOTE: REMEMBER TO UPDATE THE NON-EMBEDDED VERSION OF THIS COMPONENT TOO.
export const EmbeddedEditorAttachmentPopover = ({
buttonClassName,
buttonSize,
}: EmbeddedEditorAttachmentPopoverProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const [isAdding, setIsAdding] = useState(false);
const { envelope, setLocalEnvelope } = useCurrentEnvelopeEditor();
const attachments = envelope.attachments ?? [];
const form = useForm<TAttachmentFormSchema>({
resolver: zodResolver(ZAttachmentFormSchema),
defaultValues: {
label: '',
url: '',
},
});
const onSubmit = (data: TAttachmentFormSchema) => {
setLocalEnvelope({
attachments: [
...attachments,
{
id: nanoid(),
type: 'link',
label: data.label,
data: data.url,
},
],
});
form.reset();
setIsAdding(false);
toast({
title: _(msg`Success`),
description: _(msg`Attachment added successfully.`),
});
};
const onDeleteAttachment = (id: string) => {
setLocalEnvelope({
attachments: attachments.filter((a) => a.id !== id),
});
toast({
title: _(msg`Success`),
description: _(msg`Attachment removed successfully.`),
});
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className={cn('gap-2', buttonClassName)} size={buttonSize}>
<Paperclip className="h-4 w-4" />
<span>
<Trans>Attachments</Trans>
{attachments.length > 0 && <span className="ml-1">({attachments.length})</span>}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-96" align="end">
<div className="space-y-4">
<div>
<h4 className="font-medium">
<Trans>Attachments</Trans>
</h4>
<p className="mt-1 text-sm text-muted-foreground">
<Trans>Add links to relevant documents or resources.</Trans>
</p>
</div>
{attachments.length > 0 && (
<div className="space-y-2">
{attachments.map((attachment) => (
<div
key={attachment.id}
className="flex items-center justify-between rounded-md border border-border p-2"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{attachment.label}</p>
<a
href={attachment.data}
target="_blank"
rel="noopener noreferrer"
className="truncate text-xs text-muted-foreground underline hover:text-foreground"
>
{attachment.data}
</a>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onDeleteAttachment(attachment.id)}
className="ml-2 h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{!isAdding && (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setIsAdding(true)}
>
<Plus className="mr-2 h-4 w-4" />
<Trans>Add Attachment</Trans>
</Button>
)}
{isAdding && (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
<FormField
control={form.control}
name="label"
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder={_(msg`Label`)} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormControl>
<Input type="url" placeholder={_(msg`URL`)} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="flex-1"
onClick={() => {
setIsAdding(false);
form.reset();
}}
>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" size="sm" className="flex-1">
<Trans>Add</Trans>
</Button>
</div>
</form>
</Form>
)}
</div>
</PopoverContent>
</Popover>
);
};

View file

@ -6,7 +6,7 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon, SparklesIcon } from 'lucide-react';
import { Link, useRevalidator, useSearchParams } from 'react-router';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
@ -75,7 +75,7 @@ export const EnvelopeEditorFieldsPage = () => {
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@ -97,14 +97,10 @@ export const EnvelopeEditorFieldsPage = () => {
const isMetaSame = isDeepEqual(selectedField.fieldMeta, fieldMeta);
// Todo: Envelopes - Clean up console logs.
if (!isMetaSame) {
console.log('TRIGGER UPDATE');
editorFields.updateFieldByFormId(selectedField.formId, {
fieldMeta,
});
} else {
console.log('DATA IS SAME, NO UPDATE');
}
};
@ -176,10 +172,8 @@ export const EnvelopeEditorFieldsPage = () => {
</AlertDescription>
</div>
<Button asChild variant="outline">
<Link to={`${relativePath.editorPath}`}>
<Trans>Add Recipients</Trans>
</Link>
<Button variant="outline" onClick={() => void navigateToStep('upload')}>
<Trans>Add Recipients</Trans>
</Button>
</Alert>
)}
@ -250,36 +244,40 @@ export const EnvelopeEditorFieldsPage = () => {
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
{editorConfig.fields?.allowAIDetection && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</>
)}
</section>
{/* Field details section. */}

View file

@ -22,6 +22,7 @@ import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redist
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
import { BrandingLogo } from '~/components/general/branding-logo';
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
import { EmbeddedEditorAttachmentPopover } from '~/components/general/document/embedded-editor-attachment-popover';
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
import { TemplateDirectLinkBadge } from '../template/template-direct-link-badge';
@ -30,21 +31,56 @@ import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
export default function EnvelopeEditorHeader() {
const { t } = useLingui();
const { envelope, isDocument, isTemplate, updateEnvelope, autosaveError, relativePath } =
useCurrentEnvelopeEditor();
const {
envelope,
isDocument,
isTemplate,
isEmbedded,
updateEnvelope,
autosaveError,
relativePath,
editorConfig,
flushAutosave,
} = useCurrentEnvelopeEditor();
const {
embedded,
general: { allowConfigureEnvelopeTitle },
actions: { allowAttachments, allowDistributing },
} = editorConfig;
const handleCreateEmbeddedEnvelope = async () => {
const latestEnvelope = await flushAutosave();
embedded?.onCreate?.(latestEnvelope);
};
const handleUpdateEmbeddedEnvelope = async () => {
const latestEnvelope = await flushAutosave();
embedded?.onUpdate?.(latestEnvelope);
};
return (
<nav className="w-full border-b border-border bg-background px-4 py-3 md:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Link to="/">
<BrandingLogo className="h-6 w-auto" />
</Link>
{editorConfig.embedded?.customBrandingLogo ? (
<img
src={`/api/branding/logo/team/${envelope.teamId}`}
alt="Logo"
className="h-6 w-auto"
/>
) : (
<Link to="/">
<BrandingLogo className="h-6 w-auto" />
</Link>
)}
<Separator orientation="vertical" className="h-6" />
<div className="flex items-center space-x-2">
<EnvelopeItemTitleInput
disabled={envelope.status !== DocumentStatus.DRAFT}
disabled={envelope.status !== DocumentStatus.DRAFT || !allowConfigureEnvelopeTitle}
value={envelope.title}
onChange={(title) => {
updateEnvelope({
@ -127,54 +163,75 @@ export default function EnvelopeEditorHeader() {
</div>
<div className="flex items-center space-x-2">
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
{allowAttachments &&
(isEmbedded ? (
<EmbeddedEditorAttachmentPopover buttonSize="sm" />
) : (
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
))}
<EnvelopeEditorSettingsDialog
trigger={
<Button variant="outline" size="sm">
<SettingsIcon className="h-4 w-4" />
</Button>
}
/>
{isDocument && (
<>
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send Document</Trans>
</Button>
}
/>
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Resend Document</Trans>
</Button>
}
/>
</>
)}
{isTemplate && (
<TemplateUseDialog
envelopeId={envelope.id}
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
templateSigningOrder={envelope.documentMeta?.signingOrder}
recipients={envelope.recipients}
documentRootPath={relativePath.documentRootPath}
{editorConfig.settings && (
<EnvelopeEditorSettingsDialog
trigger={
<Button size="sm">
<Trans>Use Template</Trans>
<Button variant="outline" size="sm">
<SettingsIcon className="h-4 w-4" />
</Button>
}
/>
)}
{match({ isEmbedded, isDocument, isTemplate, allowDistributing })
.with({ isEmbedded: false, isDocument: true, allowDistributing: true }, () => (
<>
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send Document</Trans>
</Button>
}
/>
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Resend Document</Trans>
</Button>
}
/>
</>
))
.with({ isEmbedded: false, isTemplate: true, allowDistributing: true }, () => (
<TemplateUseDialog
envelopeId={envelope.id}
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
templateSigningOrder={envelope.documentMeta?.signingOrder}
recipients={envelope.recipients}
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<Trans>Use Template</Trans>
</Button>
}
/>
))
.otherwise(() => null)}
{embedded?.mode === 'create' && (
<Button size="sm" onClick={handleCreateEmbeddedEnvelope}>
{isDocument ? <Trans>Create Document</Trans> : <Trans>Create Template</Trans>}
</Button>
)}
{embedded?.mode === 'edit' && (
<Button size="sm" onClick={handleUpdateEmbeddedEnvelope}>
{isDocument ? <Trans>Update Document</Trans> : <Trans>Update Template</Trans>}
</Button>
)}
</div>
</div>
</nav>

View file

@ -21,13 +21,12 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
import {
RecipientAutoCompleteInput,
@ -63,8 +62,14 @@ import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-de
import { useCurrentTeam } from '~/providers/team';
export const EnvelopeEditorRecipientForm = () => {
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients } =
useCurrentEnvelopeEditor();
const {
envelope,
setRecipientsDebounced,
updateEnvelope,
editorRecipients,
isEmbedded,
editorConfig,
} = useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
@ -72,7 +77,9 @@ export const EnvelopeEditorRecipientForm = () => {
const { t } = useLingui();
const { toast } = useToast();
const { remaining } = useLimits();
const { user } = useSession();
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const [searchParams, setSearchParams] = useSearchParams();
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
@ -132,7 +139,8 @@ export const EnvelopeEditorRecipientForm = () => {
query: debouncedRecipientSearchQuery,
},
{
enabled: debouncedRecipientSearchQuery.length > 1,
enabled: debouncedRecipientSearchQuery.length > 1 && !isEmbedded,
retry: false,
},
);
@ -603,37 +611,41 @@ export const EnvelopeEditorRecipientForm = () => {
</div>
<div className="flex flex-row items-center space-x-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
type="button"
size="sm"
disabled={isSubmitting}
onClick={onDetectRecipientsClick}
>
<SparklesIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
{editorConfig.recipients?.allowAIDetection && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
type="button"
size="sm"
disabled={isSubmitting}
onClick={onDetectRecipientsClick}
>
<SparklesIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{team.preferences.aiFeaturesEnabled ? (
<Trans>Detect recipients with AI</Trans>
) : (
<Trans>Enable AI detection</Trans>
)}
</TooltipContent>
</Tooltip>
<TooltipContent>
{team.preferences.aiFeaturesEnabled ? (
<Trans>Detect recipients with AI</Trans>
) : (
<Trans>Enable AI detection</Trans>
)}
</TooltipContent>
</Tooltip>
)}
<Button
variant="outline"
className="flex flex-row items-center"
size="sm"
disabled={isSubmitting || isUserAlreadyARecipient}
onClick={() => onAddSelfSigner()}
>
<Trans>Add Myself</Trans>
</Button>
{!isEmbedded && (
<Button
variant="outline"
className="flex flex-row items-center"
size="sm"
disabled={isSubmitting || isUserAlreadyARecipient}
onClick={() => onAddSelfSigner()}
>
<Trans>Add Myself</Trans>
</Button>
)}
<Button
variant="outline"
@ -650,26 +662,32 @@ export const EnvelopeEditorRecipientForm = () => {
</CardHeader>
<CardContent>
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}>
<div className="-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4">
{organisation.organisationClaim.flags.cfr21 && (
<div className="flex flex-row items-center">
<Checkbox
id="showAdvancedRecipientSettings"
checked={showAdvancedSettings}
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
/>
<Form {...form}>
<div
className={cn('-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4', {
hidden:
!editorConfig.recipients?.allowConfigureSigningOrder &&
!organisation.organisationClaim.flags.cfr21,
})}
>
{organisation.organisationClaim.flags.cfr21 && (
<div className="flex flex-row items-center">
<Checkbox
id="showAdvancedRecipientSettings"
checked={showAdvancedSettings}
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
/>
<label
className="ml-2 text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
htmlFor="showAdvancedRecipientSettings"
>
<Trans>Show advanced settings</Trans>
</label>
</div>
)}
<label
className="ml-2 text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
htmlFor="showAdvancedRecipientSettings"
>
<Trans>Show advanced settings</Trans>
</label>
</div>
)}
{editorConfig.recipients?.allowConfigureSigningOrder && (
<FormField
control={form.control}
name="signingOrder"
@ -728,271 +746,328 @@ export const EnvelopeEditorRecipientForm = () => {
</FormItem>
)}
/>
)}
{isSigningOrderSequential && (
<FormField
control={form.control}
name="allowDictateNextSigner"
render={({ field: { value, ...field } }) => (
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="allowDictateNextSigner"
checked={value}
onCheckedChange={(checked) => {
field.onChange(checked);
}}
disabled={
isSubmitting || hasDocumentBeenSent || !isSigningOrderSequential
}
/>
</FormControl>
{isSigningOrderSequential && (
<FormField
control={form.control}
name="allowDictateNextSigner"
render={({ field: { value, ...field } }) => (
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="allowDictateNextSigner"
checked={value}
onCheckedChange={(checked) => {
field.onChange(checked);
}}
disabled={isSubmitting || hasDocumentBeenSent || !isSigningOrderSequential}
/>
</FormControl>
<div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
<FormLabel
htmlFor="allowDictateNextSigner"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Allow signers to dictate next signer</Trans>
</FormLabel>
<div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
<FormLabel
htmlFor="allowDictateNextSigner"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Allow signers to dictate next signer</Trans>
</FormLabel>
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-1 cursor-help text-muted-foreground">
<HelpCircleIcon className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 p-4">
<p>
<Trans>
When enabled, signers can choose who should sign next in the
sequence instead of following the predefined order.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
</FormItem>
)}
/>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-1 cursor-help text-muted-foreground">
<HelpCircleIcon className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 p-4">
<p>
<Trans>
When enabled, signers can choose who should sign next in the sequence
instead of following the predefined order.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
</FormItem>
)}
/>
)}
</div>
<DragDropContext
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
$sensorApi.current = api;
},
]}
>
<Droppable droppableId="signers">
{(provided) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className="flex w-full flex-col gap-y-2"
>
{signers.map((signer, index) => {
const isDirectRecipient =
envelope.type === EnvelopeType.TEMPLATE &&
envelope.directLink !== null &&
signer.id === envelope.directLink.directTemplateRecipientId;
<DragDropContext
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
$sensorApi.current = api;
},
]}
>
<Droppable droppableId="signers">
{(provided) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className="flex w-full flex-col gap-y-2"
>
{signers.map((signer, index) => {
const isDirectRecipient =
envelope.type === EnvelopeType.TEMPLATE &&
envelope.directLink !== null &&
signer.id === envelope.directLink.directTemplateRecipientId;
return (
<Draggable
key={`${signer.nativeId}-${signer.signingOrder}`}
draggableId={signer['nativeId']}
index={index}
isDragDisabled={
!isSigningOrderSequential ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
!signer.signingOrder
}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={cn('py-1', {
'pointer-events-none rounded-md bg-widget-foreground pt-2':
snapshot.isDragging,
return (
<Draggable
key={`${signer.nativeId}-${signer.signingOrder}`}
draggableId={signer['nativeId']}
index={index}
isDragDisabled={
!isSigningOrderSequential ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
!signer.signingOrder
}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={cn('py-1', {
'pointer-events-none rounded-md bg-widget-foreground pt-2':
snapshot.isDragging,
})}
>
<motion.fieldset
data-native-id={signer.id}
disabled={isSubmitting || !canRecipientBeModified(signer.id)}
className={cn('pb-2', {
'border-b pb-4':
showAdvancedSettings && index !== signers.length - 1,
'pt-2': showAdvancedSettings && index === 0,
'pr-3': isSigningOrderSequential,
})}
>
<motion.fieldset
data-native-id={signer.id}
disabled={isSubmitting || !canRecipientBeModified(signer.id)}
className={cn('pb-2', {
'border-b pb-4':
showAdvancedSettings && index !== signers.length - 1,
'pt-2': showAdvancedSettings && index === 0,
'pr-3': isSigningOrderSequential,
})}
>
<div className="flex flex-row items-center gap-x-2">
{isSigningOrderSequential && (
<FormField
control={form.control}
name={`signers.${index}.signingOrder`}
render={({ field }) => (
<FormItem
className={cn(
'mt-auto flex items-center gap-x-1 space-y-0',
{
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.signingOrder,
},
)}
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
<FormControl>
<Input
type="number"
max={signers.length}
data-testid="signing-order-input"
className={cn(
'w-10 text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
)}
{...field}
onChange={(e) => {
field.onChange(e);
handleSigningOrderChange(index, e.target.value);
}}
onBlur={(e) => {
field.onBlur();
handleSigningOrderChange(index, e.target.value);
}}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
<div className="flex flex-row items-center gap-x-2">
{isSigningOrderSequential && (
<FormField
control={form.control}
name={`signers.${index}.signingOrder`}
render={({ field }) => (
<FormItem
className={cn(
'mt-auto flex items-center gap-x-1 space-y-0',
{
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.signingOrder,
},
)}
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
<FormControl>
<Input
type="number"
max={signers.length}
data-testid="signing-order-input"
className={cn(
'w-10 text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
)}
{...field}
onChange={(e) => {
field.onChange(e);
handleSigningOrderChange(index, e.target.value);
}}
onBlur={(e) => {
field.onBlur();
handleSigningOrderChange(index, e.target.value);
}}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('relative w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.email,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
)}
/>
<FormControl>
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={field.value}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
isDirectRecipient
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn('w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.name,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Recipient ${index + 1}`}
{...field}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
isDirectRecipient
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto w-fit', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.role,
})}
>
<FormControl>
<RecipientRoleSelect
{...field}
hideAssistantRole={
!editorConfig.recipients?.allowAssistantRole
}
hideCCerRole={!editorConfig.recipients?.allowCCerRole}
hideViewerRole={!editorConfig.recipients?.allowViewerRole}
hideApproverRole={
!editorConfig.recipients?.allowApproverRole
}
isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole);
field.onChange(value);
}}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': form.formState.errors.signers?.[index],
})}
data-testid="remove-signer-button"
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
signers.length === 1 ||
isDirectRecipient
}
onClick={() => onRemoveSigner(index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
{showAdvancedSettings &&
organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${index}.email`}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('relative w-full', {
className={cn('mt-2 w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.email,
!form.formState.errors.signers[index]?.actionAuth,
'pl-6': isSigningOrderSequential,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={field.value}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
isDirectRecipient
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn('w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.name,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Recipient ${index + 1}`}
<RecipientActionAuthSelect
{...field}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
isDirectRecipient
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto w-fit', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.role,
})}
>
<FormControl>
<RecipientRoleSelect
{...field}
isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole);
field.onChange(value);
}}
onValueChange={field.onChange}
disabled={
snapshot.isDragging ||
isSubmitting ||
@ -1005,77 +1080,26 @@ export const EnvelopeEditorRecipientForm = () => {
</FormItem>
)}
/>
)}
</motion.fieldset>
</div>
)}
</Draggable>
);
})}
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': form.formState.errors.signers?.[index],
})}
data-testid="remove-signer-button"
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
signers.length === 1 ||
isDirectRecipient
}
onClick={() => onRemoveSigner(index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
{showAdvancedSettings &&
organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('mt-2 w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.actionAuth,
'pl-6': isSigningOrderSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</motion.fieldset>
</div>
)}
</Draggable>
);
})}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
<FormErrorMessage
className="mt-2"
// Dirty hack to handle errors when .root is populated for an array type
error={'signers__root' in errors && errors['signers__root']}
/>
</Form>
</AnimateGenericFadeInOut>
<FormErrorMessage
className="mt-2"
// Dirty hack to handle errors when .root is populated for an array type
error={'signers__root' in errors && errors['signers__root']}
/>
</Form>
<SigningOrderConfirmation
open={showSigningOrderConfirmation}
@ -1083,13 +1107,15 @@ export const EnvelopeEditorRecipientForm = () => {
onConfirm={handleSigningOrderDisable}
/>
<AiRecipientDetectionDialog
open={isAiDialogOpen}
onOpenChange={onAiDialogOpenChange}
onComplete={onAiDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
{editorConfig.recipients?.allowAIDetection && (
<AiRecipientDetectionDialog
open={isAiDialogOpen}
onOpenChange={onAiDialogOpenChange}
onComplete={onAiDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
)}
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}

View file

@ -0,0 +1,28 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
export const EnvelopeEditorRenderProviderWrapper = ({
children,
token,
presignedToken,
}: {
children: React.ReactNode;
token?: string;
presignedToken?: string;
}) => {
const { envelope } = useCurrentEnvelopeEditor();
return (
<EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
fields={envelope.fields}
recipients={envelope.recipients}
token={token}
presignToken={presignedToken}
>
{children}
</EnvelopeRenderProvider>
);
};

View file

@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg, t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import {
DocumentDistributionMethod,
@ -30,6 +30,7 @@ import {
isValidLanguageCode,
} from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import {
ZDocumentAccessAuthTypesSchema,
@ -175,10 +176,13 @@ export const EnvelopeEditorSettingsDialog = ({
trigger,
...props
}: EnvelopeEditorSettingsDialogProps) => {
const { _ } = useLingui();
const { t } = useLingui();
const { toast } = useToast();
const { envelope, updateEnvelopeAsync } = useCurrentEnvelopeEditor();
const { envelope, updateEnvelopeAsync, editorConfig, isEmbedded, organisationEmails } =
useCurrentEnvelopeEditor();
const { settings } = editorConfig;
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
@ -228,12 +232,18 @@ export const EnvelopeEditorSettingsDialog = ({
const emailSettings = form.watch('meta.emailSettings');
const { data: emailData, isLoading: isLoadingEmails } =
trpc.enterprise.organisation.email.find.useQuery({
organisationId: organisation.id,
perPage: 100,
});
trpc.enterprise.organisation.email.find.useQuery(
{
organisationId: organisation.id,
perPage: 100,
},
{
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
enabled: Boolean(organisationEmails !== undefined && organisation.id),
},
);
const emails = emailData?.data || [];
const emails = emailData?.data || organisationEmails || [];
const canUpdateVisibility = canAccessTeamDocument(team.currentTeamRole, envelope.visibility);
@ -285,11 +295,13 @@ export const EnvelopeEditorSettingsDialog = ({
setOpen(false);
toast({
title: t`Success`,
description: t`Envelope updated`,
duration: 5000,
});
if (!isEmbedded) {
toast({
title: t`Success`,
description: t`Envelope updated`,
duration: 5000,
});
}
} catch (err) {
const error = AppError.parseError(err);
@ -326,7 +338,7 @@ export const EnvelopeEditorSettingsDialog = ({
const selectedTab = tabs.find((tab) => tab.id === activeTab);
if (!selectedTab) {
if (!selectedTab || !settings) {
return null;
}
@ -347,34 +359,40 @@ export const EnvelopeEditorSettingsDialog = ({
<DialogContent className="flex w-full !max-w-5xl flex-row gap-0 p-0">
{/* Sidebar. */}
<div className="flex w-80 flex-col border-r bg-accent/20">
<DialogHeader className="p-6 pb-4">
<DialogHeader className="p-6 pb-4" data-testid="envelope-editor-settings-dialog-header">
<DialogTitle>
<Trans>Document Settings</Trans>
</DialogTitle>
</DialogHeader>
<nav className="col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 px-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2">
{tabs.map((tab) => (
<Button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
variant="ghost"
className={cn('w-full justify-start', {
'bg-secondary': activeTab === tab.id,
})}
>
<tab.icon className="mr-2 h-5 w-5" />
{_(tab.title)}
</Button>
))}
{tabs.map((tab) => {
if (tab.id === 'email' && !settings.allowConfigureDistribution) {
return null;
}
return (
<Button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
variant="ghost"
className={cn('w-full justify-start', {
'bg-secondary': activeTab === tab.id,
})}
>
<tab.icon className="mr-2 h-5 w-5" />
{t(tab.title)}
</Button>
);
})}
</nav>
</div>
{/* Content. */}
<div className="flex w-full flex-col">
<CardHeader className="border-b pb-4">
<CardTitle>{selectedTab ? _(selectedTab.title) : ''}</CardTitle>
<CardDescription>{selectedTab ? _(selectedTab.description) : ''}</CardDescription>
<CardTitle>{selectedTab ? t(selectedTab.title) : ''}</CardTitle>
<CardDescription>{selectedTab ? t(selectedTab.description) : ''}</CardDescription>
</CardHeader>
<Form {...form}>
@ -384,137 +402,151 @@ export const EnvelopeEditorSettingsDialog = ({
disabled={form.formState.isSubmitting}
key={activeTab}
>
{match(activeTab)
.with('general', () => (
{match({ activeTab, settings })
.with({ activeTab: 'general' }, () => (
<>
<FormField
control={form.control}
name="meta.language"
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center">
<Trans>Language</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
{settings.allowConfigureLanguage && (
<FormField
control={form.control}
name="meta.language"
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center">
<Trans>Language</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<Trans>
Controls the language for the document, including the language
to be used for email notifications, and the final certificate
that is generated and attached to the document.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<Trans>
Controls the language for the document, including the language
to be used for email notifications, and the final certificate
that is generated and attached to the document.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<FormControl>
<Select
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}>
{_(language.full)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.signatureTypes"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Allowed Signature Types</Trans>
<DocumentSignatureSettingsTooltip />
</FormLabel>
<SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}>
{t(language.full)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
label: _(option.label),
value: option.value,
}))}
selectedValues={field.value}
onChange={field.onChange}
className="w-full bg-background"
emptySelectionPlaceholder="Select signature types"
/>
</FormControl>
{settings.allowConfigureSignatureTypes && (
<FormField
control={form.control}
name="meta.signatureTypes"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Allowed Signature Types</Trans>
<DocumentSignatureSettingsTooltip />
</FormLabel>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.dateFormat"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Date Format</Trans>
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map(
(option) => ({
label: t(option.label),
value: option.value,
}),
)}
selectedValues={field.value}
onChange={field.onChange}
className="w-full bg-background"
emptySelectionPlaceholder="Select signature types"
/>
</FormControl>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={envelopeHasBeenSent}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<FormMessage />
</FormItem>
)}
/>
)}
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
{settings.allowConfigureDateFormat && (
<FormField
control={form.control}
name="meta.dateFormat"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Date Format</Trans>
</FormLabel>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.timezone"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Time Zone</Trans>
</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={envelopeHasBeenSent}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<FormControl>
<Combobox
className="bg-background"
options={TIME_ZONES}
value={field.value}
onChange={(value) => value && field.onChange(value)}
disabled={envelopeHasBeenSent}
/>
</FormControl>
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{settings.allowConfigureTimezone && (
<FormField
control={form.control}
name="meta.timezone"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Time Zone</Trans>
</FormLabel>
<FormControl>
<Combobox
className="bg-background"
options={TIME_ZONES}
value={field.value}
onChange={(value) => value && field.onChange(value)}
disabled={envelopeHasBeenSent}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="externalId"
@ -573,233 +605,244 @@ export const EnvelopeEditorSettingsDialog = ({
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.distributionMethod"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Document Distribution Method</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Document Distribution Method</Trans>
</strong>
</h2>
<p>
<Trans>
This is how the document will reach the recipients once the
document is ready for signing.
</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<Trans>
<strong>Email</strong> - The recipient will be emailed the
document to sign, approve, etc.
</Trans>
</li>
<li>
<Trans>
<strong>None</strong> - We will generate links which you can
send to the recipients manually.
</Trans>
</li>
</ul>
<Trans>
<strong>Note</strong> - If you use Links in combination with
direct templates, you will need to manually send the links to
the remaining recipients.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue data-testid="documentDistributionMethodSelectValue" />
</SelectTrigger>
<SelectContent position="popper">
{Object.values(DOCUMENT_DISTRIBUTION_METHODS).map(
({ value, description }) => (
<SelectItem key={value} value={value}>
{_(description)}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.envelopeExpirationPeriod"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Expiration</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
How long recipients have to complete this document after it is
sent. Uses the team default when set to inherit.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<ExpirationPeriodPicker
value={field.value}
onChange={field.onChange}
disabled={envelopeHasBeenSent}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
))
.with('email', () => (
<>
{organisation.organisationClaim.flags.emailDomains && (
{settings.allowConfigureDistribution && (
<FormField
control={form.control}
name="meta.emailId"
name="meta.distributionMethod"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email Sender</Trans>
<FormLabel className="flex flex-row items-center">
<Trans>Document Distribution Method</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Document Distribution Method</Trans>
</strong>
</h2>
<p>
<Trans>
This is how the document will reach the recipients once the
document is ready for signing.
</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<Trans>
<strong>Email</strong> - The recipient will be emailed the
document to sign, approve, etc.
</Trans>
</li>
<li>
<Trans>
<strong>None</strong> - We will generate links which you
can send to the recipients manually.
</Trans>
</li>
</ul>
<Trans>
<strong>Note</strong> - If you use Links in combination with
direct templates, you will need to manually send the links to
the remaining recipients.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value}
onValueChange={(value) =>
field.onChange(value === '-1' ? null : value)
}
>
<SelectTrigger
loading={isLoadingEmails}
className="bg-background"
>
<SelectValue />
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue data-testid="documentDistributionMethodSelectValue" />
</SelectTrigger>
<SelectContent>
{emails.map((email) => (
<SelectItem key={email.id} value={email.id}>
{email.email}
</SelectItem>
))}
<SelectItem value={'-1'}>Documenso</SelectItem>
<SelectContent position="popper">
{Object.values(DOCUMENT_DISTRIBUTION_METHODS).map(
({ value, description }) => (
<SelectItem key={value} value={value}>
{t(description)}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
)}
{settings.allowConfigureExpirationPeriod && (
<FormField
control={form.control}
name="meta.envelopeExpirationPeriod"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Expiration</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
How long recipients have to complete this document after it is
sent. Uses the team default when set to inherit.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<ExpirationPeriodPicker
value={field.value}
onChange={field.onChange}
disabled={envelopeHasBeenSent}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="meta.emailReplyTo"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Reply To Email{' '}
<span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.subject"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Subject <span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.message"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>
Message <span className="text-muted-foreground">(Optional)</span>
</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="p-4 text-muted-foreground">
<DocumentSendEmailMessageHelper />
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Textarea className="h-16 resize-none bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DocumentEmailCheckboxes
value={emailSettings}
onChange={(value) => form.setValue('meta.emailSettings', value)}
/>
</>
))
.with('security', () => (
.with(
{ activeTab: 'email', settings: { allowConfigureDistribution: true } },
() => (
<>
{settings.allowConfigureEmailSender &&
organisation.organisationClaim.flags.emailDomains && (
<FormField
control={form.control}
name="meta.emailId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email Sender</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value}
onValueChange={(value) =>
field.onChange(value === '-1' ? null : value)
}
>
<SelectTrigger
loading={isLoadingEmails}
className="bg-background"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{emails.map((email) => (
<SelectItem key={email.id} value={email.id}>
{email.email}
</SelectItem>
))}
<SelectItem value={'-1'}>Documenso</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{settings.allowConfigureEmailReplyTo && (
<FormField
control={form.control}
name="meta.emailReplyTo"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Reply To Email{' '}
<span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="meta.subject"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Subject <span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.message"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>
Message <span className="text-muted-foreground">(Optional)</span>
</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="p-4 text-muted-foreground">
<DocumentSendEmailMessageHelper />
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Textarea className="h-16 resize-none bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DocumentEmailCheckboxes
value={emailSettings}
onChange={(value) => form.setValue('meta.emailSettings', value)}
/>
</>
),
)
.with({ activeTab: 'security' }, () => (
<>
{organisation.organisationClaim.flags.cfr21 && (
<FormField
@ -845,30 +888,32 @@ export const EnvelopeEditorSettingsDialog = ({
)}
/>
<FormField
control={form.control}
name="visibility"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Document visibility</Trans>
<DocumentVisibilityTooltip />
</FormLabel>
{!isEmbedded && (
<FormField
control={form.control}
name="visibility"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Document visibility</Trans>
<DocumentVisibilityTooltip />
</FormLabel>
<FormControl>
<DocumentVisibilitySelect
canUpdateVisibility={canUpdateVisibility}
currentTeamMemberRole={team.currentTeamRole}
{...field}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormControl>
<DocumentVisibilitySelect
canUpdateVisibility={canUpdateVisibility}
currentTeamMemberRole={team.currentTeamRole}
{...field}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
</>
))
.exhaustive()}
.otherwise(() => null)}
</fieldset>
<div className="flex flex-row justify-end gap-4 p-6">

View file

@ -9,6 +9,7 @@ export type EnvelopeItemTitleInputProps = {
className?: string;
placeholder?: string;
disabled?: boolean;
dataTestId?: string;
};
export const EnvelopeItemTitleInput = ({
@ -17,6 +18,7 @@ export const EnvelopeItemTitleInput = ({
className,
placeholder,
disabled,
dataTestId,
}: EnvelopeItemTitleInputProps) => {
const [envelopeItemTitle, setEnvelopeItemTitle] = useState(value);
const [isError, setIsError] = useState(false);
@ -63,6 +65,7 @@ export const EnvelopeItemTitleInput = ({
{envelopeItemTitle || placeholder}
</span>
<input
data-testid={dataTestId}
data-1p-ignore
autoComplete="off"
ref={inputRef}
@ -72,7 +75,7 @@ export const EnvelopeItemTitleInput = ({
disabled={disabled}
style={{ width: `${inputWidth}px` }}
className={cn(
'text-foreground hover:outline-muted-foreground focus:outline-muted-foreground rounded-sm border-0 bg-transparent p-1 text-sm font-medium outline-none hover:outline hover:outline-1 focus:outline focus:outline-1',
'rounded-sm border-0 bg-transparent p-1 text-sm font-medium text-foreground outline-none hover:outline hover:outline-1 hover:outline-muted-foreground focus:outline focus:outline-1 focus:outline-muted-foreground',
className,
{
'outline-red-500': isError,

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import type { DropResult } from '@hello-pangea/dnd';
@ -8,16 +8,15 @@ import { DocumentStatus } from '@prisma/client';
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
import { X } from 'lucide-react';
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
import { Link } from 'react-router';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import {
useCurrentEnvelopeEditor,
useDebounceFunction,
} from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useEnvelopeAutosave } from '@documenso/lib/client-only/hooks/use-envelope-autosave';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
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 { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
@ -49,10 +48,22 @@ export const EnvelopeEditorUploadPage = () => {
const organisation = useCurrentOrganisation();
const { t } = useLingui();
const { envelope, setLocalEnvelope, relativePath, editorFields } = useCurrentEnvelopeEditor();
const { maximumEnvelopeItemCount, remaining } = useLimits();
const { toast } = useToast();
const {
envelope,
setLocalEnvelope,
editorFields,
editorConfig,
isEmbedded,
navigateToStep,
registerExternalFlush,
registerPendingMutation,
} = useCurrentEnvelopeEditor();
const { envelopeItems: uploadConfig } = editorConfig;
const [localFiles, setLocalFiles] = useState<LocalFile[]>(
envelope.envelopeItems
.sort((a, b) => a.order - b.order)
@ -103,17 +114,46 @@ export const EnvelopeEditorUploadPage = () => {
);
const onFileDrop = async (files: File[]) => {
const newUploadingFiles: (LocalFile & { file: File })[] = files.map((file) => ({
id: nanoid(),
envelopeItemId: null,
title: file.name,
file,
isUploading: true,
isError: false,
}));
const newUploadingFiles: (LocalFile & {
file: File;
data: TEditorEnvelope['envelopeItems'][number]['data'] | null;
})[] = await Promise.all(
files.map(async (file) => {
return {
id: nanoid(),
envelopeItemId: isEmbedded ? `${PRESIGNED_ENVELOPE_ITEM_ID_PREFIX}${nanoid()}` : null,
title: file.name,
file,
isUploading: isEmbedded ? false : true,
// Clone the buffer so it can be read multiple times (File.arrayBuffer() consumes the stream once)
data: isEmbedded ? new Uint8Array((await file.arrayBuffer()).slice(0)) : null,
isError: false,
};
}),
);
setLocalFiles((prev) => [...prev, ...newUploadingFiles]);
// Directly commit the files for embedded documents since those are not uploaded
// until the end of the embedded flow.
if (isEmbedded) {
setLocalEnvelope({
envelopeItems: [
...envelope.envelopeItems,
...newUploadingFiles.map((file) => ({
id: file.envelopeItemId!,
title: file.title,
order: envelope.envelopeItems.length + 1,
envelopeId: envelope.id,
data: file.data!,
documentDataId: '',
})),
],
});
return;
}
const payload = {
envelopeId: envelope.id,
} satisfies TCreateEnvelopeItemsPayload;
@ -126,7 +166,11 @@ export const EnvelopeEditorUploadPage = () => {
formData.append('files', file);
}
const { data } = await createEnvelopeItems(formData).catch((error) => {
const createPromise = createEnvelopeItems(formData);
registerPendingMutation(createPromise);
const { data } = await createPromise.catch((error) => {
console.error(error);
// Set error state on files in batch upload.
@ -163,7 +207,9 @@ export const EnvelopeEditorUploadPage = () => {
* Hide the envelope item from the list on deletion.
*/
const onFileDelete = (envelopeItemId: string) => {
setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.id !== envelopeItemId));
setLocalFiles((prev) =>
prev.filter((uploadingFile) => uploadingFile.envelopeItemId !== envelopeItemId),
);
const fieldsWithoutDeletedItem = envelope.fields.filter(
(field) => field.envelopeItemId !== envelopeItemId,
@ -194,18 +240,60 @@ export const EnvelopeEditorUploadPage = () => {
debouncedUpdateEnvelopeItems(items);
};
const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => {
void updateEnvelopeItems({
envelopeId: envelope.id,
data: files
.filter((item) => item.envelopeItemId)
.map((item, index) => ({
envelopeItemId: item.envelopeItemId || '',
order: index + 1,
title: item.title,
})),
});
}, 1000);
const { triggerSave: debouncedUpdateEnvelopeItems, flush: flushUpdateEnvelopeItems } =
useEnvelopeAutosave(
async (files: LocalFile[]) => {
if (isEmbedded) {
const nextEnvelopeItems = files
.filter((item) => item.envelopeItemId)
.map((item, index) => {
const originalEnvelopeItem = envelope.envelopeItems.find(
(envelopeItem) => envelopeItem.id === item.envelopeItemId,
);
return {
id: item.envelopeItemId || '',
title: item.title,
order: index + 1,
envelopeId: envelope.id,
data: originalEnvelopeItem?.data,
documentDataId: originalEnvelopeItem?.documentDataId || '',
};
});
setLocalEnvelope({
envelopeItems: nextEnvelopeItems,
});
return;
}
await updateEnvelopeItems({
envelopeId: envelope.id,
data: files
.filter((item) => item.envelopeItemId)
.map((item, index) => ({
envelopeItemId: item.envelopeItemId || '',
order: index + 1,
title: item.title,
})),
});
},
isEmbedded ? 0 : 1000,
);
const flushUpdateEnvelopeItemsRef = useRef(flushUpdateEnvelopeItems);
flushUpdateEnvelopeItemsRef.current = flushUpdateEnvelopeItems;
// Register the flush callback with the provider so flushAutosave can await
// pending envelope item mutations. We intentionally do NOT unregister on unmount
// because the upload page is unmounted (replaced with a spinner) before
// flushAutosave runs during step transitions. The hook's internal refs survive
// unmounting, so the flush callback remains valid.
useEffect(() => {
registerExternalFlush('envelopeItems', async () => flushUpdateEnvelopeItemsRef.current());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onEnvelopeItemTitleChange = (envelopeItemId: string, title: string) => {
const newLocalFilesValue = localFiles.map((uploadingFile) =>
@ -277,32 +365,45 @@ export const EnvelopeEditorUploadPage = () => {
</CardHeader>
<CardContent>
<DocumentDropzone
onDrop={onFileDrop}
allowMultiple
className="pb-4 pt-6"
disabled={dropzoneDisabledMessage !== null}
disabledMessage={dropzoneDisabledMessage || undefined}
disabledHeading={msg`Upload disabled`}
maxFiles={maximumEnvelopeItemCount - localFiles.length}
onDropRejected={onFileDropRejected}
/>
{uploadConfig?.allowUpload && (
<DocumentDropzone
data-testid="envelope-item-dropzone"
onDrop={onFileDrop}
allowMultiple
className="pb-4 pt-6"
disabled={dropzoneDisabledMessage !== null}
disabledMessage={dropzoneDisabledMessage || undefined}
disabledHeading={msg`Upload disabled`}
maxFiles={maximumEnvelopeItemCount - localFiles.length}
onDropRejected={onFileDropRejected}
/>
)}
{/* Uploaded Files List */}
<div className="mt-4">
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="files">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="space-y-2">
<div
data-testid="envelope-items-list"
{...provided.droppableProps}
ref={provided.innerRef}
className="space-y-2"
>
{localFiles.map((localFile, index) => (
<Draggable
key={localFile.id}
isDragDisabled={isCreatingEnvelopeItems || !canItemsBeModified}
isDragDisabled={
isCreatingEnvelopeItems ||
!canItemsBeModified ||
!uploadConfig?.allowConfigureOrder
}
draggableId={localFile.id}
index={index}
>
{(provided, snapshot) => (
<div
data-testid={`envelope-item-row-${localFile.id}`}
ref={provided.innerRef}
{...provided.draggableProps}
style={provided.draggableProps.style}
@ -311,18 +412,25 @@ export const EnvelopeEditorUploadPage = () => {
}`}
>
<div className="flex items-center space-x-3">
<div
{...provided.dragHandleProps}
className="cursor-grab active:cursor-grabbing"
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
</div>
{uploadConfig?.allowConfigureOrder && (
<div
{...provided.dragHandleProps}
data-testid={`envelope-item-drag-handle-${localFile.id}`}
className="cursor-grab active:cursor-grabbing"
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
</div>
)}
<div>
{localFile.envelopeItemId !== null ? (
<EnvelopeItemTitleInput
disabled={envelope.status !== DocumentStatus.DRAFT}
disabled={
envelope.status !== DocumentStatus.DRAFT ||
!uploadConfig?.allowConfigureTitle
}
value={localFile.title}
dataTestId={`envelope-item-title-input-${localFile.id}`}
placeholder={t`Document Title`}
onChange={(title) => {
onEnvelopeItemTitleChange(localFile.envelopeItemId!, title);
@ -355,20 +463,36 @@ export const EnvelopeEditorUploadPage = () => {
</div>
)}
{!localFile.isUploading && localFile.envelopeItemId && (
<EnvelopeItemDeleteDialog
canItemBeDeleted={canItemsBeModified}
envelopeId={envelope.id}
envelopeItemId={localFile.envelopeItemId}
envelopeItemTitle={localFile.title}
onDelete={onFileDelete}
trigger={
<Button variant="ghost" size="sm">
<X className="h-4 w-4" />
</Button>
}
/>
)}
{!localFile.isUploading &&
localFile.envelopeItemId &&
uploadConfig?.allowDelete &&
(isEmbedded ? (
<Button
variant="ghost"
size="sm"
data-testid={`envelope-item-remove-button-${localFile.id}`}
onClick={() => onFileDelete(localFile.envelopeItemId!)}
>
<X className="h-4 w-4" />
</Button>
) : (
<EnvelopeItemDeleteDialog
canItemBeDeleted={canItemsBeModified}
envelopeId={envelope.id}
envelopeItemId={localFile.envelopeItemId}
envelopeItemTitle={localFile.title}
onDelete={onFileDelete}
trigger={
<Button
variant="ghost"
size="sm"
data-testid={`envelope-item-remove-button-${localFile.id}`}
>
<X className="h-4 w-4" />
</Button>
}
/>
))}
</div>
</div>
)}
@ -386,13 +510,13 @@ export const EnvelopeEditorUploadPage = () => {
{/* Recipients Section */}
<EnvelopeEditorRecipientForm />
<div className="flex justify-end">
<Button asChild>
<Link to={`${relativePath.editorPath}?step=addFields`}>
{editorConfig.general.allowAddFieldsStep && (
<div className="flex justify-end">
<Button type="button" onClick={() => void navigateToStep('addFields')}>
<Trans>Add Fields</Trans>
</Link>
</Button>
</div>
</Button>
</div>
)}
</div>
);
};

View file

@ -1,7 +1,9 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { motion } from 'framer-motion';
import {
ArrowLeftIcon,
@ -9,32 +11,30 @@ import {
DownloadCloudIcon,
EyeIcon,
LinkIcon,
MousePointer,
type LucideIcon,
MousePointerIcon,
SendIcon,
SettingsIcon,
Trash2Icon,
Upload,
UploadIcon,
} from 'lucide-react';
import { useNavigate, useSearchParams } from 'react-router';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import type { EnvelopeEditorStep } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import {
mapSecondaryIdToDocumentId,
mapSecondaryIdToTemplateId,
} from '@documenso/lib/utils/envelope';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribute-dialog';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { TemplateDeleteDialog } from '~/components/dialogs/template-delete-dialog';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
@ -43,52 +43,92 @@ import EnvelopeEditorHeader from './envelope-editor-header';
import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page';
import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page';
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
type EnvelopeEditorStepData = {
id: string;
title: MessageDescriptor;
icon: LucideIcon;
description: MessageDescriptor;
};
const envelopeEditorSteps = [
{
id: 'upload',
order: 1,
title: msg`Document & Recipients`,
icon: Upload,
description: msg`Upload documents and add recipients`,
},
{
id: 'addFields',
order: 2,
title: msg`Add Fields`,
icon: MousePointer,
description: msg`Place and configure form fields in the document`,
},
{
id: 'preview',
order: 3,
title: msg`Preview`,
icon: EyeIcon,
description: msg`Preview the document before sending`,
},
];
const UPLOAD_STEP = {
id: 'upload',
title: msg`Document & Recipients`,
icon: UploadIcon,
description: msg`Upload documents and add recipients`,
};
export default function EnvelopeEditor() {
const ADD_FIELDS_STEP = {
id: 'addFields',
title: msg`Add Fields`,
icon: MousePointerIcon,
description: msg`Place and configure form fields in the document`,
};
const PREVIEW_STEP = {
id: 'preview',
title: msg`Preview`,
icon: EyeIcon,
description: msg`Preview the document before sending`,
};
export const EnvelopeEditor = () => {
const { t } = useLingui();
const navigate = useNavigate();
const {
envelope,
editorConfig,
isDocument,
isTemplate,
isAutosaving,
flushAutosave,
relativePath,
navigateToStep,
syncEnvelope,
flushAutosave,
resetForms,
} = useCurrentEnvelopeEditor();
const [searchParams, setSearchParams] = useSearchParams();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isStepLoading, setIsStepLoading] = useState(false);
const [currentStep, setCurrentStep] = useState<EnvelopeEditorStep>(() => {
const {
general: {
minimizeLeftSidebar,
allowUploadAndRecipientStep,
allowAddFieldsStep,
allowPreviewStep,
},
actions: {
allowDistributing,
allowDirectLink,
allowDuplication,
allowDownloadPDF,
allowDeletion,
},
} = editorConfig;
const envelopeEditorSteps = useMemo(() => {
const steps: EnvelopeEditorStepData[] = [];
if (allowUploadAndRecipientStep) {
steps.push(UPLOAD_STEP);
}
if (allowAddFieldsStep) {
steps.push(ADD_FIELDS_STEP);
}
if (allowPreviewStep) {
steps.push(PREVIEW_STEP);
}
return steps.map((step, index) => ({
...step,
order: index + 1,
}));
}, [editorConfig]);
const searchParamsStep = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const searchParamStep = searchParams.get('step') as EnvelopeEditorStep | undefined;
// Empty URL param equals upload, otherwise use the step URL param
@ -103,31 +143,28 @@ export default function EnvelopeEditor() {
}
return 'upload';
});
}, [searchParams]);
const navigateToStep = (step: EnvelopeEditorStep) => {
setCurrentStep(step);
const [pageToRender, setPageToRender] = useState<EnvelopeEditorStep | 'loading'>(
searchParamsStep,
);
void flushAutosave();
const latestStepChangeTime = useRef(0);
if (!isStepLoading && isAutosaving) {
setIsStepLoading(true);
}
const handleStepChange = async (step: EnvelopeEditorStep) => {
setPageToRender('loading');
// Update URL params: empty for upload, otherwise set the step
if (step === 'upload') {
setSearchParams((prev) => {
const newParams = new URLSearchParams(prev);
newParams.delete('step');
return newParams;
});
} else {
setSearchParams((prev) => {
const newParams = new URLSearchParams(prev);
newParams.set('step', step);
return newParams;
});
}
const currentTime = Date.now();
latestStepChangeTime.current = currentTime;
await flushAutosave().then(() => {
if (currentTime !== latestStepChangeTime.current) {
return;
}
resetForms();
setPageToRender(step);
});
};
// Watch the URL params and setStep if the step changes.
@ -136,79 +173,140 @@ export default function EnvelopeEditor() {
const foundStep = envelopeEditorSteps.find((step) => step.id === stepParam);
if (foundStep && foundStep.id !== currentStep) {
if (foundStep && foundStep.id !== pageToRender) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
navigateToStep(foundStep.id as EnvelopeEditorStep);
void handleStepChange(foundStep.id as EnvelopeEditorStep);
}
}, [searchParams]);
useEffect(() => {
if (!isAutosaving) {
setIsStepLoading(false);
}
}, [isAutosaving]);
const currentStepData =
envelopeEditorSteps.find((step) => step.id === currentStep) || envelopeEditorSteps[0];
envelopeEditorSteps.find((step) => step.id === searchParamsStep) || envelopeEditorSteps[0];
return (
<div className="h-screen w-screen bg-gray-50 dark:bg-background">
<div className="h-screen w-screen bg-envelope-editor-background">
<EnvelopeEditorHeader />
{/* Main Content Area */}
<div className="flex h-[calc(100vh-4rem)] w-screen">
{/* Left Section - Step Navigation */}
<div className="flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4">
<div
className={cn(
'flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4',
{
'w-14': minimizeLeftSidebar,
},
)}
>
{/* Left section step selector. */}
<div className="px-4">
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
<Trans context="The step counter">
Step {currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span>
</h3>
<div className="relative my-4 h-[4px] rounded-md bg-muted">
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
}}
/>
{minimizeLeftSidebar ? (
<div className="flex justify-center px-4">
<div className="relative flex h-10 w-10 items-center justify-center">
<svg className="size-10 -rotate-90" viewBox="0 0 40 40" aria-hidden>
{/* Track circle */}
<circle
cx="20"
cy="20"
r="16"
fill="none"
stroke="currentColor"
strokeWidth="3"
className="text-muted"
/>
{/* Progress arc */}
<motion.circle
cx="20"
cy="20"
r="16"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
className="text-primary"
strokeDasharray={2 * Math.PI * 16}
initial={false}
animate={{
strokeDashoffset:
2 *
Math.PI *
16 *
(1 - (currentStepData.order ?? 0) / envelopeEditorSteps.length),
}}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-foreground">
<Trans context="The step counter">
{currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span>
</div>
</div>
) : (
<div className="px-4">
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
<div className="space-y-3">
{envelopeEditorSteps.map((step) => {
const Icon = step.icon;
const isActive = currentStep === step.id;
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
<Trans context="The step counter">
Step {currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span>
</h3>
return (
<div
key={step.id}
className={`cursor-pointer rounded-lg p-3 transition-colors ${
<div className="relative my-4 h-[4px] rounded-md bg-muted">
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
}}
/>
</div>
</div>
)}
<div
className={cn('space-y-3', {
'px-4': !minimizeLeftSidebar,
'mt-4 flex flex-col items-center': minimizeLeftSidebar,
})}
>
{envelopeEditorSteps.map((step) => {
const Icon = step.icon;
const isActive = searchParamsStep === step.id;
return (
<button
key={step.id}
data-testid={`envelope-editor-step-${step.id}`}
type="button"
className={cn(
`cursor-pointer rounded-lg text-left transition-colors ${
isActive
? 'border border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
: 'border border-gray-200 hover:bg-gray-50 dark:border-gray-400/20 dark:hover:bg-gray-400/10'
}`}
onClick={() => navigateToStep(step.id as EnvelopeEditorStep)}
>
<div className="flex items-center space-x-3">
<div
className={`rounded border p-2 ${
isActive
? 'border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
: 'border-gray-100 bg-gray-100 dark:border-gray-400/20 dark:bg-gray-400/10'
}`}
>
<Icon
className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-gray-600'}`}
/>
</div>
}`,
{
'p-3': !minimizeLeftSidebar,
},
)}
onClick={() => void navigateToStep(step.id as EnvelopeEditorStep)}
>
<div className="flex items-center space-x-3">
<div
className={`rounded border p-2 ${
isActive
? 'border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
: 'border-gray-100 bg-gray-100 dark:border-gray-400/20 dark:bg-gray-400/10'
}`}
>
<Icon
className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-gray-600'}`}
/>
</div>
{!minimizeLeftSidebar && (
<div>
<div
className={`text-sm font-medium ${
@ -221,59 +319,101 @@ export default function EnvelopeEditor() {
</div>
<div className="text-xs text-muted-foreground">{t(step.description)}</div>
</div>
</div>
)}
</div>
);
})}
</div>
</button>
);
})}
</div>
<Separator className="my-6" />
<Separator
className={cn('my-6', {
'mx-auto mb-4 w-4/5': minimizeLeftSidebar,
})}
/>
{/* Quick Actions. */}
<div className="space-y-3 px-4">
<h4 className="text-sm font-semibold text-foreground">
<Trans>Quick Actions</Trans>
</h4>
<EnvelopeEditorSettingsDialog
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<SettingsIcon className="mr-2 h-4 w-4" />
{isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>}
</Button>
}
/>
<div
className={cn('space-y-3 px-4 [&_.lucide]:text-muted-foreground', {
'px-2': minimizeLeftSidebar,
})}
>
{!minimizeLeftSidebar && (
<h4 className="text-sm font-semibold text-foreground">
<Trans>Quick Actions</Trans>
</h4>
)}
{isDocument && (
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
{editorConfig.settings && (
<EnvelopeEditorSettingsDialog
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send Document</Trans>
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Settings`)}
>
<SettingsIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? (
<Trans>Document Settings</Trans>
) : (
<Trans>Template Settings</Trans>
)}
</span>
)}
</Button>
}
/>
)}
{isDocument && (
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Resend Document</Trans>
</Button>
}
/>
{isDocument && allowDistributing && (
<>
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Send Envelope`)}
>
<SendIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Send Document</Trans>
</span>
)}
</Button>
}
/>
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Resend Envelope`)}
>
<SendIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Resend Document</Trans>
</span>
)}
</Button>
}
/>
</>
)}
{/* <Button variant="ghost" size="sm" className="w-full justify-start">
<FileText className="mr-2 h-4 w-4" />
Save as Template
</Button> */}
{isTemplate && (
{isTemplate && allowDirectLink && (
<TemplateDirectLinkDialog
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
directLink={envelope.directLink}
@ -281,100 +421,168 @@ export default function EnvelopeEditor() {
onCreateSuccess={async () => await syncEnvelope()}
onDeleteSuccess={async () => await syncEnvelope()}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<LinkIcon className="mr-2 h-4 w-4" />
<Trans>Direct Link</Trans>
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Direct Link`)}
>
<LinkIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Direct Link</Trans>
</span>
)}
</Button>
}
/>
)}
<EnvelopeDuplicateDialog
envelopeId={envelope.id}
envelopeType={envelope.type}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<CopyPlusIcon className="mr-2 h-4 w-4" />
{isDocument ? (
<Trans>Duplicate Document</Trans>
) : (
<Trans>Duplicate Template</Trans>
)}
</Button>
}
/>
{allowDuplication && (
<EnvelopeDuplicateDialog
envelopeId={envelope.id}
envelopeType={envelope.type}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Duplicate Envelope`)}
>
<CopyPlusIcon className="h-4 w-4" />
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
envelopeItems={envelope.envelopeItems}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<DownloadCloudIcon className="mr-2 h-4 w-4" />
<Trans>Download PDF</Trans>
</Button>
}
/>
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? (
<Trans>Duplicate Document</Trans>
) : (
<Trans>Duplicate Template</Trans>
)}
</span>
)}
</Button>
}
/>
)}
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={() => setDeleteDialogOpen(true)}
>
<Trash2Icon className="mr-2 h-4 w-4" />
{isDocument ? <Trans>Delete Document</Trans> : <Trans>Delete Template</Trans>}
</Button>
{allowDownloadPDF && (
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
envelopeItems={envelope.envelopeItems}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Download PDF`)}
>
<DownloadCloudIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Download PDF</Trans>
</span>
)}
</Button>
}
/>
)}
{/* Check envelope ID since it can be in embedded create mode. */}
{allowDeletion && envelope.id && (
<EnvelopeDeleteDialog
id={envelope.id}
type={envelope.type}
status={envelope.status}
title={envelope.title}
canManageDocument={true}
trigger={
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Delete Envelope`)}
>
<Trash2Icon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? (
<Trans>Delete Document</Trans>
) : (
<Trans>Delete Template</Trans>
)}
</span>
)}
</Button>
}
onDelete={async () => {
await navigate(
envelope.type === EnvelopeType.DOCUMENT
? relativePath.documentRootPath
: relativePath.templateRootPath,
);
}}
/>
)}
</div>
{isDocument ? (
<DocumentDeleteDialog
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
status={envelope.status}
documentTitle={envelope.title}
canManageDocument={true}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onDelete={async () => {
await navigate(relativePath.documentRootPath);
}}
/>
) : (
<TemplateDeleteDialog
id={mapSecondaryIdToTemplateId(envelope.secondaryId)}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onDelete={async () => {
await navigate(relativePath.templateRootPath);
}}
/>
)}
{/* Footer of left sidebar. */}
<div className="mt-auto px-4">
<Button variant="ghost" className="w-full justify-start" asChild>
<Link to={relativePath.basePath}>
<ArrowLeftIcon className="mr-2 h-4 w-4" />
{isDocument ? (
<Trans>Return to documents</Trans>
) : (
<Trans>Return to templates</Trans>
)}
</Link>
</Button>
</div>
{!editorConfig.embedded && (
<div
className={cn('mt-auto px-4', {
'px-2': minimizeLeftSidebar,
})}
>
<Button
variant="ghost"
className={cn('w-full justify-start', {
'flex items-center justify-center': minimizeLeftSidebar,
})}
asChild
>
<Link to={relativePath.basePath}>
<ArrowLeftIcon className="h-4 w-4 flex-shrink-0" />
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? (
<Trans>Return to documents</Trans>
) : (
<Trans>Return to templates</Trans>
)}
</span>
)}
</Link>
</Button>
</div>
)}
</div>
{/* Main Content - Changes based on current step */}
<AnimateGenericFadeInOut className="flex-1 overflow-y-auto" key={currentStep}>
{match({ currentStep, isStepLoading })
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
.with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />)
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />)
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />)
.exhaustive()}
</AnimateGenericFadeInOut>
<div className="flex-1 overflow-y-auto">
{match({
pageToRender,
allowUploadAndRecipientStep,
allowAddFieldsStep,
allowPreviewStep,
})
.with({ pageToRender: 'loading' }, () => <SpinnerBox className="py-32" />)
.with({ pageToRender: 'upload', allowUploadAndRecipientStep: true }, () => (
<EnvelopeEditorUploadPage />
))
.with({ pageToRender: 'addFields', allowAddFieldsStep: true }, () => (
<EnvelopeEditorFieldsPage />
))
.with({ pageToRender: 'preview', allowPreviewStep: true }, () => (
<EnvelopeEditorPreviewPage />
))
.otherwise(() => null)}
</div>
</div>
</div>
);
}
};

View file

@ -43,7 +43,7 @@ export const EnvelopePdfViewer = ({
) : (
<div className="flex h-[80vh] max-h-[60rem] w-full flex-col items-center justify-center overflow-hidden rounded">
<p className="text-sm text-muted-foreground">
<Trans>No document selected</Trans>
<Trans>No document found</Trans>
</p>
</div>
)}

View file

@ -39,8 +39,10 @@ export type PDFViewerProps = {
* The PDF data to render.
*
* If it's a URL, it will be fetched and rendered.
*
* If null will render an empty state.
*/
data: Uint8Array | string;
data: Uint8Array | string | null;
/**
* Ref to the scrollable parent container that handles scrolling.
@ -80,6 +82,10 @@ export const PDFViewer = ({
const [pages, setPages] = useState<PageMeta[]>([]);
useEffect(() => {
if (!data) {
return;
}
const fetchMetadata = async () => {
try {
setLoadingState('loading');
@ -153,6 +159,16 @@ export const PDFViewer = ({
const isLoading = loadingState === 'loading';
const hasError = loadingState === 'error';
if (!data) {
return (
<div ref={$el} className={cn('h-full w-full', className)} {...props}>
<p className="py-32 text-center text-sm text-muted-foreground">
<Trans>No document found</Trans>
</p>
</div>
);
}
return (
<div ref={$el} className={cn('h-full w-full', className)} {...props}>
{/* Loading State */}

View file

@ -13,7 +13,7 @@ import {
} from '@documenso/lib/constants/trpc';
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
import type { TTemplate } from '@documenso/lib/types/template';
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -314,10 +314,10 @@ export const TemplateEditForm = ({
>
<CardContent className="p-2">
<PDFViewer
key={template.envelopeItems[0].id}
data={getDocumentDataUrl({
key={template.envelopeItems[0]?.id}
data={getDocumentDataUrlForPdfViewer({
envelopeId: template.envelopeId,
envelopeItemId: template.envelopeItems[0].id,
envelopeItemId: template.envelopeItems[0]?.id,
documentDataId: initialTemplate.templateDocumentDataId,
version: 'current',
token: undefined,

View file

@ -8,8 +8,9 @@ import { match } from 'ts-pattern';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import {
@ -52,9 +53,14 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
data: envelope,
isLoading: isLoadingEnvelope,
isError: isErrorEnvelope,
} = trpc.envelope.get.useQuery({
envelopeId: params.id,
});
} = trpc.envelope.get.useQuery(
{
envelopeId: params.id,
},
{
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
if (isLoadingEnvelope) {
return (
@ -194,15 +200,15 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
)}
<PDFViewer
data={getDocumentDataUrl({
data={getDocumentDataUrlForPdfViewer({
envelopeId: envelope.id,
envelopeItemId: envelope.envelopeItems[0].id,
documentDataId: envelope.envelopeItems[0].documentDataId,
envelopeItemId: envelope.envelopeItems[0]?.id,
documentDataId: envelope.envelopeItems[0]?.documentDataId,
version: 'current',
token: undefined,
presignToken: undefined,
})}
key={envelope.envelopeItems[0].id}
key={envelope.envelopeItems[0]?.id}
scrollParentRef="window"
/>
</CardContent>

View file

@ -6,13 +6,14 @@ import { EnvelopeType } from '@prisma/client';
import { Link, useNavigate } from 'react-router';
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Spinner } from '@documenso/ui/primitives/spinner';
import EnvelopeEditor from '~/components/general/envelope-editor/envelope-editor';
import { EnvelopeEditor } from '~/components/general/envelope-editor/envelope-editor';
import { EnvelopeEditorRenderProviderWrapper } from '~/components/general/envelope-editor/envelope-editor-renderer-provider-wrapper';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { useCurrentTeam } from '~/providers/team';
@ -26,12 +27,13 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
data: envelope,
isLoading: isLoadingEnvelope,
isError: isErrorEnvelope,
} = trpc.envelope.get.useQuery(
} = trpc.envelope.editor.get.useQuery(
{
envelopeId: params.id,
},
{
retry: false,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
@ -98,16 +100,9 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
return (
<EnvelopeEditorProvider initialEnvelope={envelope}>
<EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={undefined}
fields={envelope.fields}
recipients={envelope.recipients}
>
<EnvelopeEditorRenderProviderWrapper>
<EnvelopeEditor />
</EnvelopeRenderProvider>
</EnvelopeEditorRenderProviderWrapper>
</EnvelopeEditorProvider>
);
}

View file

@ -8,7 +8,7 @@ import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/env
import { useSession } from '@documenso/lib/client-only/providers/session';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
@ -211,15 +211,15 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
/>
<PDFViewer
data={getDocumentDataUrl({
data={getDocumentDataUrlForPdfViewer({
envelopeId: envelope.id,
envelopeItemId: envelope.envelopeItems[0].id,
documentDataId: envelope.envelopeItems[0].documentDataId,
envelopeItemId: envelope.envelopeItems[0]?.id,
documentDataId: envelope.envelopeItems[0]?.documentDataId,
version: 'current',
token: undefined,
presignToken: undefined,
})}
key={envelope.envelopeItems[0].id}
key={envelope.envelopeItems[0]?.id}
scrollParentRef="window"
/>
</CardContent>

View file

@ -0,0 +1,656 @@
/**
* This is an internal test page for the embedding system.
*
* We use this to test embeds for E2E testing.
*
* No translations required.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
export const loader = () => {
if (process.env.NODE_ENV !== 'development') {
throw new Error('This page is only available in development mode.');
}
};
/**
* Playground embed test page.
*
* Simulates an embedding parent that renders the V2 authoring iframe
* with configurable features, externalId, and mode.
*
* Navigate to /embed/playground to use.
*/
export default function EmbedPlaygroundPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [token, setToken] = useState(() => searchParams.get('token') || '');
const [externalId, setExternalId] = useState(() => searchParams.get('externalId') || '');
const [mode, setMode] = useState<'create' | 'edit'>(
() => (searchParams.get('mode') as 'create' | 'edit') || 'create',
);
const [envelopeId, setEnvelopeId] = useState(() => searchParams.get('envelopeId') || '');
const [envelopeType, setEnvelopeType] = useState<'DOCUMENT' | 'TEMPLATE'>(
() => (searchParams.get('envelopeType') as 'DOCUMENT' | 'TEMPLATE') || 'DOCUMENT',
);
// Auto-launch if query params are present on mount
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
const [iframeKey, setIframeKey] = useState(0);
const [messages, setMessages] = useState<string[]>([]);
// Feature flags state -- grouped by section
const [generalFeatures, setGeneralFeatures] = useState({
allowConfigureEnvelopeTitle: true,
allowUploadAndRecipientStep: true,
allowAddFieldsStep: true,
allowPreviewStep: true,
minimizeLeftSidebar: true,
});
const [settingsFeatures, setSettingsFeatures] = useState({
allowConfigureSignatureTypes: true,
allowConfigureLanguage: true,
allowConfigureDateFormat: true,
allowConfigureTimezone: true,
allowConfigureRedirectUrl: true,
allowConfigureDistribution: true,
allowConfigureExpirationPeriod: true,
allowConfigureEmailSender: true,
allowConfigureEmailReplyTo: true,
});
const [actionsFeatures, setActionsFeatures] = useState({
allowAttachments: true,
allowDistributing: false,
allowDirectLink: false,
allowDuplication: false,
allowDownloadPDF: false,
allowDeletion: false,
});
const [envelopeItemsFeatures, setEnvelopeItemsFeatures] = useState({
allowConfigureTitle: true,
allowConfigureOrder: true,
allowUpload: true,
allowDelete: true,
});
const [recipientsFeatures, setRecipientsFeatures] = useState({
allowAIDetection: true,
allowConfigureSigningOrder: true,
allowConfigureDictateNextSigner: true,
allowApproverRole: true,
allowViewerRole: true,
allowCCerRole: true,
allowAssistantRole: true,
});
const [fieldsFeatures, setFieldsFeatures] = useState({
allowAIDetection: true,
});
// CSS theming state
const [darkModeDisabled, setDarkModeDisabled] = useState(false);
const [rawCss, setRawCss] = useState('');
const [cssVars, setCssVars] = useState<Record<string, string>>({
background: '',
envelopeEditorBackground: '',
foreground: '',
muted: '',
mutedForeground: '',
popover: '',
popoverForeground: '',
card: '',
cardBorder: '',
cardBorderTint: '',
cardForeground: '',
fieldCard: '',
fieldCardBorder: '',
fieldCardForeground: '',
widget: '',
widgetForeground: '',
border: '',
input: '',
primary: '',
primaryForeground: '',
secondary: '',
secondaryForeground: '',
accent: '',
accentForeground: '',
destructive: '',
destructiveForeground: '',
ring: '',
radius: '',
warning: '',
});
const [isResolvingToken, setIsResolvingToken] = useState(false);
const [tokenError, setTokenError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const hasAutoLaunched = useRef(false);
/**
* If the token starts with "api_", exchange it for a presign token
* via the embedding presign endpoint. Otherwise return as-is.
*/
const resolveToken = async (inputToken: string): Promise<string> => {
if (!inputToken.startsWith('api_')) {
return inputToken;
}
const response = await fetch('/api/v2/embedding/create-presign-token', {
method: 'POST',
headers: {
Authorization: `Bearer ${inputToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to exchange API token (${response.status}): ${text}`);
}
const data = await response.json();
const presignToken = data?.token;
if (!presignToken || typeof presignToken !== 'string') {
throw new Error(`Unexpected response shape: ${JSON.stringify(data)}`);
}
return presignToken;
};
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const timestamp = new Date().toISOString().slice(11, 19);
setMessages((prev) => [...prev, `[${timestamp}] ${JSON.stringify(event.data, null, 2)}`]);
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, []);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Auto-launch on mount if token is present in query params
useEffect(() => {
if (hasAutoLaunched.current) {
return;
}
const initialToken = searchParams.get('token');
if (initialToken) {
hasAutoLaunched.current = true;
void launchEmbed(initialToken);
}
}, []);
const updateQueryParams = (params: {
token: string;
externalId: string;
mode: string;
envelopeId: string;
envelopeType: string;
}) => {
const newParams = new URLSearchParams();
if (params.token) {
newParams.set('token', params.token);
}
if (params.externalId) {
newParams.set('externalId', params.externalId);
}
if (params.mode && params.mode !== 'create') {
newParams.set('mode', params.mode);
}
if (params.envelopeId) {
newParams.set('envelopeId', params.envelopeId);
}
if (params.envelopeType && params.envelopeType !== 'DOCUMENT') {
newParams.set('envelopeType', params.envelopeType);
}
const qs = newParams.toString();
void navigate(qs ? `?${qs}` : '.', { replace: true });
};
const launchEmbed = async (overrideToken?: string) => {
const inputToken = overrideToken ?? token;
if (!inputToken) {
return;
}
setTokenError(null);
setIsResolvingToken(true);
let presignToken: string;
try {
presignToken = await resolveToken(inputToken);
} catch (err) {
setTokenError(err instanceof Error ? err.message : String(err));
setIsResolvingToken(false);
return;
}
setIsResolvingToken(false);
// Filter out empty cssVars entries
const filteredCssVars: Record<string, string> = {};
for (const [key, value] of Object.entries(cssVars)) {
if (value) {
filteredCssVars[key] = value;
}
}
const hashData = {
externalId: externalId || undefined,
type: mode === 'create' ? envelopeType : undefined,
darkModeDisabled: darkModeDisabled || undefined,
css: rawCss || undefined,
cssVars: Object.keys(filteredCssVars).length > 0 ? filteredCssVars : undefined,
features: {
general: generalFeatures,
settings: settingsFeatures,
actions: actionsFeatures,
envelopeItems: envelopeItemsFeatures,
recipients: recipientsFeatures,
fields: fieldsFeatures,
},
};
const hash = btoa(encodeURIComponent(JSON.stringify(hashData)));
const basePath =
mode === 'create'
? '/embed/v2/authoring/envelope/create'
: `/embed/v2/authoring/envelope/edit/${envelopeId}`;
const buildIframeSrc = (path: string, tokenValue: string, hashValue: string): string => {
// Ensure the token is treated strictly as a query parameter value.
const encodedToken = encodeURIComponent(tokenValue);
return `${path}?token=${encodedToken}#${hashValue}`;
};
setIframeSrc(buildIframeSrc(basePath, presignToken, hash));
setIframeKey((prev) => prev + 1);
updateQueryParams({ token: inputToken, externalId, mode, envelopeId, envelopeType });
};
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
void launchEmbed();
},
[
token,
externalId,
mode,
envelopeId,
envelopeType,
generalFeatures,
settingsFeatures,
actionsFeatures,
envelopeItemsFeatures,
recipientsFeatures,
fieldsFeatures,
darkModeDisabled,
rawCss,
cssVars,
],
);
const handleClear = () => {
setToken('');
setExternalId('');
setMode('create');
setEnvelopeId('');
setEnvelopeType('DOCUMENT');
setIframeSrc(null);
setMessages([]);
setTokenError(null);
setDarkModeDisabled(false);
setRawCss('');
setCssVars((prev) => {
const cleared: Record<string, string> = {};
for (const key of Object.keys(prev)) {
cleared[key] = '';
}
return cleared;
});
void navigate('.', { replace: true });
};
const renderCheckboxGroup = <T extends Record<string, boolean>>(
label: string,
state: T,
setState: React.Dispatch<React.SetStateAction<T>>,
) => (
<fieldset
style={{ border: '1px solid #ccc', padding: '8px', marginBottom: '8px', borderRadius: '4px' }}
>
<legend style={{ fontWeight: 'bold', fontSize: '13px' }}>{label}</legend>
{Object.entries(state).map(([key, value]) => (
<label key={key} style={{ display: 'block', fontSize: '12px', marginBottom: '2px' }}>
<input
type="checkbox"
checked={value}
onChange={(e) => setState((prev) => ({ ...prev, [key]: e.target.checked }))}
style={{ marginRight: '4px' }}
/>
{key}
</label>
))}
</fieldset>
);
return (
<div style={{ display: 'flex', height: '100vh', fontFamily: 'monospace' }}>
{/* Left panel: controls */}
<div
style={{
width: '320px',
padding: '12px',
borderRight: '1px solid #ccc',
overflowY: 'auto',
flexShrink: 0,
}}
>
<h2 style={{ margin: '0 0 12px', fontSize: '16px' }}>Embed Test</h2>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '8px' }}>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>
API or Embedded Token (Required)
</label>
<input
type="text"
value={token}
onChange={(e) => setToken(e.target.value)}
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
placeholder="api_... or presign token"
required
/>
{tokenError && (
<div style={{ color: 'red', fontSize: '11px', marginTop: '4px' }}>{tokenError}</div>
)}
</div>
<div style={{ marginBottom: '8px' }}>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>
External ID (optional)
</label>
<input
type="text"
value={externalId}
onChange={(e) => setExternalId(e.target.value)}
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
placeholder="your-correlation-id"
/>
</div>
<div style={{ marginBottom: '8px' }}>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>Mode</label>
<select
value={mode}
onChange={(e) => setMode(e.target.value as 'create' | 'edit')}
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
>
<option value="create">Create</option>
<option value="edit">Edit</option>
</select>
</div>
{mode === 'create' && (
<div style={{ marginBottom: '8px' }}>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>
Envelope Type
</label>
<select
value={envelopeType}
onChange={(e) => setEnvelopeType(e.target.value as 'DOCUMENT' | 'TEMPLATE')}
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
>
<option value="DOCUMENT">Document</option>
<option value="TEMPLATE">Template</option>
</select>
</div>
)}
{mode === 'edit' && (
<div style={{ marginBottom: '8px' }}>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>
Envelope ID
</label>
<input
type="text"
value={envelopeId}
onChange={(e) => setEnvelopeId(e.target.value)}
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
placeholder="envelope_..."
required
/>
</div>
)}
<h3 style={{ fontSize: '14px', margin: '12px 0 4px' }}>Feature Flags</h3>
{renderCheckboxGroup('General', generalFeatures, setGeneralFeatures)}
{renderCheckboxGroup('Settings', settingsFeatures, setSettingsFeatures)}
{renderCheckboxGroup('Actions', actionsFeatures, setActionsFeatures)}
{renderCheckboxGroup('Envelope Items', envelopeItemsFeatures, setEnvelopeItemsFeatures)}
{renderCheckboxGroup('Recipients', recipientsFeatures, setRecipientsFeatures)}
{renderCheckboxGroup('Fields', fieldsFeatures, setFieldsFeatures)}
<h3 style={{ fontSize: '14px', margin: '12px 0 4px' }}>CSS Theming</h3>
<label style={{ display: 'block', fontSize: '12px', marginBottom: '8px' }}>
<input
type="checkbox"
checked={darkModeDisabled}
onChange={(e) => setDarkModeDisabled(e.target.checked)}
style={{ marginRight: '4px' }}
/>
darkModeDisabled
</label>
<fieldset
style={{
border: '1px solid #ccc',
padding: '8px',
marginBottom: '8px',
borderRadius: '4px',
}}
>
<legend style={{ fontWeight: 'bold', fontSize: '13px' }}>CSS Variables</legend>
<div
style={{
maxHeight: '200px',
overflowY: 'auto',
}}
>
{Object.entries(cssVars).map(([key, value]) => (
<div
key={key}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '2px',
}}
>
<label style={{ fontSize: '11px', width: '140px', flexShrink: 0 }}>{key}</label>
{key !== 'radius' && (
<input
type="color"
value={value || '#000000'}
onChange={(e) => setCssVars((prev) => ({ ...prev, [key]: e.target.value }))}
style={{ width: '24px', height: '20px', padding: 0, border: 'none' }}
/>
)}
<input
type="text"
value={value}
onChange={(e) => setCssVars((prev) => ({ ...prev, [key]: e.target.value }))}
style={{ flex: 1, padding: '2px 4px', fontSize: '11px' }}
placeholder={key === 'radius' ? '0.5rem' : '#hex or color'}
/>
{value && (
<button
type="button"
onClick={() => setCssVars((prev) => ({ ...prev, [key]: '' }))}
style={{
fontSize: '10px',
cursor: 'pointer',
padding: '0 4px',
lineHeight: '18px',
}}
>
x
</button>
)}
</div>
))}
</div>
</fieldset>
<fieldset
style={{
border: '1px solid #ccc',
padding: '8px',
marginBottom: '8px',
borderRadius: '4px',
}}
>
<legend style={{ fontWeight: 'bold', fontSize: '13px' }}>Raw CSS</legend>
<textarea
value={rawCss}
onChange={(e) => setRawCss(e.target.value)}
style={{
width: '100%',
height: '80px',
padding: '4px',
fontSize: '11px',
fontFamily: 'monospace',
resize: 'vertical',
}}
placeholder=".my-class { color: red; }"
/>
</fieldset>
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
<button
type="submit"
disabled={isResolvingToken}
style={{
flex: 1,
padding: '8px',
fontSize: '13px',
fontWeight: 'bold',
cursor: isResolvingToken ? 'not-allowed' : 'pointer',
opacity: isResolvingToken ? 0.6 : 1,
}}
>
{isResolvingToken ? 'Resolving Token...' : 'Launch Embed'}
</button>
<button
type="button"
onClick={handleClear}
style={{
padding: '8px 12px',
fontSize: '13px',
cursor: 'pointer',
}}
>
Clear
</button>
</div>
</form>
{/* Message log */}
<div style={{ marginTop: '16px' }}>
<h3 style={{ fontSize: '14px', margin: '0 0 4px' }}>
PostMessage Log
{messages.length > 0 && (
<button
type="button"
onClick={() => setMessages([])}
style={{ marginLeft: '8px', fontSize: '10px', cursor: 'pointer' }}
>
clear
</button>
)}
</h3>
<div
style={{
height: '200px',
overflowY: 'auto',
border: '1px solid #ccc',
padding: '4px',
fontSize: '11px',
backgroundColor: '#f9f9f9',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{messages.length === 0 && (
<span style={{ color: '#999' }}>Waiting for messages...</span>
)}
{messages.map((msg, i) => (
<div key={i} style={{ borderBottom: '1px solid #eee', padding: '2px 0' }}>
{msg}
</div>
))}
<div ref={messagesEndRef} />
</div>
</div>
</div>
{/* Right panel: iframe */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{iframeSrc ? (
<iframe
key={iframeKey}
src={iframeSrc}
style={{ flex: 1, border: 'none', width: '100%', height: '100%' }}
title="Embedded Authoring"
/>
) : (
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#999',
fontSize: '14px',
}}
>
Enter a token and click "Launch Embed" to start
</div>
)}
</div>
</div>
);
}

View file

@ -5,9 +5,9 @@ import { Outlet, useLoaderData } from 'react-router';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { ZBaseEmbedAuthoringSchema } from '@documenso/lib/types/embed-authoring-base-schema';
import { TrpcProvider } from '@documenso/trpc/react';
import { ZBaseEmbedAuthoringSchema } from '~/types/embed-authoring-base-schema';
import { injectCss } from '~/utils/css-vars';
import type { Route } from './+types/_layout';

View file

@ -4,6 +4,10 @@ import { useLingui } from '@lingui/react';
import { useNavigate } from 'react-router';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import {
type TBaseEmbedAuthoringSchema,
ZBaseEmbedAuthoringSchema,
} from '@documenso/lib/types/embed-authoring-base-schema';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { Stepper } from '@documenso/ui/primitives/stepper';
@ -14,10 +18,6 @@ import { ConfigureDocumentView } from '~/components/embed/authoring/configure-do
import type { TConfigureEmbedFormSchema } from '~/components/embed/authoring/configure-document-view.types';
import { ConfigureFieldsView } from '~/components/embed/authoring/configure-fields-view';
import type { TConfigureFieldsFormSchema } from '~/components/embed/authoring/configure-fields-view.types';
import {
type TBaseEmbedAuthoringSchema,
ZBaseEmbedAuthoringSchema,
} from '~/types/embed-authoring-base-schema';
export default function EmbeddingAuthoringDocumentCreatePage() {
const { _ } = useLingui();

View file

@ -15,6 +15,10 @@ import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import {
type TBaseEmbedAuthoringSchema,
ZBaseEmbedAuthoringEditSchema,
} from '@documenso/lib/types/embed-authoring-base-schema';
import { nanoid } from '@documenso/lib/universal/id';
import { trpc } from '@documenso/trpc/react';
import { Stepper } from '@documenso/ui/primitives/stepper';
@ -25,10 +29,6 @@ import { ConfigureDocumentView } from '~/components/embed/authoring/configure-do
import type { TConfigureEmbedFormSchema } from '~/components/embed/authoring/configure-document-view.types';
import { ConfigureFieldsView } from '~/components/embed/authoring/configure-fields-view';
import type { TConfigureFieldsFormSchema } from '~/components/embed/authoring/configure-fields-view.types';
import {
type TBaseEmbedAuthoringSchema,
ZBaseEmbedAuthoringEditSchema,
} from '~/types/embed-authoring-base-schema';
import type { Route } from './+types/document.edit.$id';

View file

@ -3,6 +3,10 @@ import { useLayoutEffect, useState } from 'react';
import { useLingui } from '@lingui/react';
import { useNavigate } from 'react-router';
import {
type TBaseEmbedAuthoringSchema,
ZBaseEmbedAuthoringSchema,
} from '@documenso/lib/types/embed-authoring-base-schema';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { Stepper } from '@documenso/ui/primitives/stepper';
@ -13,10 +17,6 @@ import { ConfigureDocumentView } from '~/components/embed/authoring/configure-do
import type { TConfigureEmbedFormSchema } from '~/components/embed/authoring/configure-document-view.types';
import { ConfigureFieldsView } from '~/components/embed/authoring/configure-fields-view';
import type { TConfigureFieldsFormSchema } from '~/components/embed/authoring/configure-fields-view.types';
import {
type TBaseEmbedAuthoringSchema,
ZBaseEmbedAuthoringSchema,
} from '~/types/embed-authoring-base-schema';
export default function EmbeddingAuthoringTemplateCreatePage() {
const { _ } = useLingui();

View file

@ -15,6 +15,10 @@ import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import {
type TBaseEmbedAuthoringSchema,
ZBaseEmbedAuthoringEditSchema,
} from '@documenso/lib/types/embed-authoring-base-schema';
import { nanoid } from '@documenso/lib/universal/id';
import { trpc } from '@documenso/trpc/react';
import { Stepper } from '@documenso/ui/primitives/stepper';
@ -25,10 +29,6 @@ import { ConfigureDocumentView } from '~/components/embed/authoring/configure-do
import type { TConfigureEmbedFormSchema } from '~/components/embed/authoring/configure-document-view.types';
import { ConfigureFieldsView } from '~/components/embed/authoring/configure-fields-view';
import type { TConfigureFieldsFormSchema } from '~/components/embed/authoring/configure-fields-view.types';
import {
type TBaseEmbedAuthoringSchema,
ZBaseEmbedAuthoringEditSchema,
} from '~/types/embed-authoring-base-schema';
import type { Route } from './+types/document.edit.$id';

View file

@ -8,12 +8,12 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
import { BrandingLogo } from '~/components/general/branding-logo';
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
import { DocumentSigningRecipientProvider } from '~/components/general/document-signing/document-signing-recipient-provider';
import { ZSignDocumentEmbedDataSchema } from '~/types/embed-document-sign-schema';
import { injectCss } from '~/utils/css-vars';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
@ -283,7 +283,7 @@ export default function MultisignPage() {
</DocumentSigningProvider>
{!hidePoweredBy && (
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100">
<span>
<Trans>Powered by</Trans>
</span>
@ -300,7 +300,7 @@ export default function MultisignPage() {
<MultiSignDocumentList envelopes={envelopes} onDocumentSelect={onSelectDocument} />
{!hidePoweredBy && (
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100">
<span>
<Trans>Powered by</Trans>
</span>

View file

@ -0,0 +1,181 @@
import { useLayoutEffect } from 'react';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole, OrganisationType, TeamMemberRole } from '@prisma/client';
import { Outlet, isRouteErrorResponse, useLoaderData } from 'react-router';
import { match } from 'ts-pattern';
import { PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import { ZBaseEmbedDataSchema } from '@documenso/lib/types/embed-base-schemas';
import { TrpcProvider } from '@documenso/trpc/react';
import type { OrganisationSession } from '@documenso/trpc/server/organisation-router/get-organisation-session.types';
import { TeamProvider } from '~/providers/team';
import { injectCss } from '~/utils/css-vars';
import type { Route } from './+types/_layout';
export const loader = async ({ request }: Route.LoaderArgs) => {
const url = new URL(request.url);
const token = url.searchParams.get('token');
if (!token) {
throw new Response('Invalid token', { status: 404 });
}
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
if (!result) {
throw new Response('Invalid token', { status: 404 });
}
const organisationClaim = await getOrganisationClaimByTeamId({
teamId: result.teamId,
});
const teamSettings = await getTeamSettings({
userId: result.userId,
teamId: result.teamId,
});
return {
token,
userId: result.userId,
teamId: result.teamId,
organisationClaim,
preferences: {
aiFeaturesEnabled: teamSettings.aiFeaturesEnabled,
},
};
};
export default function AuthoringLayout() {
const { token, teamId, organisationClaim, preferences } = useLoaderData<typeof loader>();
const allowEmbedAuthoringWhiteLabel = organisationClaim.flags.embedAuthoringWhiteLabel ?? false;
useLayoutEffect(() => {
try {
const hash = window.location.hash.slice(1);
const result = ZBaseEmbedDataSchema.safeParse(JSON.parse(decodeURIComponent(atob(hash))));
if (!result.success) {
return;
}
const { css, cssVars, darkModeDisabled } = result.data;
if (darkModeDisabled) {
document.documentElement.classList.add('dark-mode-disabled');
}
if (allowEmbedAuthoringWhiteLabel) {
injectCss({
css,
cssVars,
});
}
} catch (error) {
console.error(error);
}
}, []);
/**
* Dummy data for providers.
*/
const team: OrganisationSession['teams'][number] = {
id: teamId,
name: '',
url: '',
createdAt: new Date(),
avatarImageId: null,
organisationId: '',
currentTeamRole: TeamMemberRole.MEMBER,
preferences: {
aiFeaturesEnabled: preferences.aiFeaturesEnabled,
},
};
/**
* Dummy data for providers.
*/
const organisation: OrganisationSession = {
id: '',
createdAt: new Date(),
updatedAt: new Date(),
type: OrganisationType.ORGANISATION,
name: '',
url: '',
avatarImageId: null,
customerId: null,
ownerUserId: -1,
organisationClaim,
teams: [team],
subscription: null,
currentOrganisationRole: OrganisationMemberRole.MEMBER,
};
return (
<OrganisationProvider organisation={organisation}>
<TeamProvider team={team}>
<TrpcProvider
headers={{ authorization: `Bearer ${token}`, 'x-team-Id': team.id.toString() }}
>
<LimitsProvider
disableLimitsFetch={true}
initialValue={{
quota: PAID_PLAN_LIMITS,
remaining: PAID_PLAN_LIMITS,
maximumEnvelopeItemCount: organisationClaim.envelopeItemCount,
}}
teamId={team.id}
>
<Outlet />
</LimitsProvider>
</TrpcProvider>
</TeamProvider>
</OrganisationProvider>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
return (
<div>
{match(errorCode)
.with(404, () => (
<div>
<p>
<Trans>Token Not Found</Trans>
</p>
<ul>
<li>
<Trans>Ensure that you are using the embedding token, not the API token</Trans>
</li>
<li>
<Trans>
If you are using staging, ensure that you have set the host prop on the embedding
component to the staging domain (https://stg-app.documenso.com)
</Trans>
</li>
</ul>
</div>
))
.otherwise(() => (
<p>
<Trans>An error occurred</Trans>
{errorCode}
</p>
))}
</div>
);
}

View file

@ -0,0 +1,434 @@
import { useLayoutEffect, useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { useLingui } from '@lingui/react/macro';
import {
DocumentStatus,
EnvelopeType,
ReadStatus,
SendStatus,
SigningStatus,
} from '@prisma/client';
import { CheckCircle2Icon } from 'lucide-react';
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import type { SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import type { TDocumentMetaDateFormat } from '@documenso/lib/types/document-meta';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
import {
type TEmbedCreateEnvelopeAuthoring,
ZEmbedCreateEnvelopeAuthoringSchema,
} from '@documenso/lib/types/envelope-editor';
import type { TEnvelopeFieldAndMeta } from '@documenso/lib/types/field-meta';
import { extractDerivedDocumentMeta } from '@documenso/lib/utils/document';
import { buildEmbeddedFeatures } from '@documenso/lib/utils/embed-config';
import { buildEmbeddedEditorOptions } from '@documenso/lib/utils/embed-config';
import { prisma } from '@documenso/prisma';
import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { Spinner } from '@documenso/ui/primitives/spinner';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { EnvelopeEditor } from '~/components/general/envelope-editor/envelope-editor';
import { EnvelopeEditorRenderProviderWrapper } from '~/components/general/envelope-editor/envelope-editor-renderer-provider-wrapper';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/envelope.create._index';
export const loader = async ({ request }: Route.LoaderArgs) => {
const url = new URL(request.url);
// We know that the token is present because we're checking it in the parent _layout route
const token = url.searchParams.get('token') || '';
if (!token) {
throw new Response('Invalid token', { status: 404 });
}
// We also know that the token is valid, but we need the userId + teamId
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
if (!result) {
throw new Response('Invalid token', { status: 404 });
}
const teamSettings = await getTeamSettings({
userId: result.userId,
teamId: result.teamId,
});
const organisationEmails = await prisma.organisationEmail.findMany({
where: {
organisation: {
members: {
some: {
userId: result.userId,
},
},
teams: {
some: {
id: result.teamId,
},
},
},
},
orderBy: {
createdAt: 'desc',
},
select: {
id: true,
email: true,
},
});
return superLoaderJson({
token,
tokenUserId: result.userId,
tokenTeamId: result.teamId,
teamSettings,
organisationEmails,
});
};
export default function EmbeddingAuthoringEnvelopeCreatePage() {
const [hasInitialized, setHasInitialized] = useState(false);
const [embedAuthoringOptions, setEmbedAuthoringOptions] =
useState<TEmbedCreateEnvelopeAuthoring | null>(null);
useLayoutEffect(() => {
try {
const hash = window.location.hash.slice(1);
if (hash) {
const result = ZEmbedCreateEnvelopeAuthoringSchema.safeParse(
JSON.parse(decodeURIComponent(atob(hash))),
);
if (result.success) {
setEmbedAuthoringOptions({
...result.data,
features: buildEmbeddedFeatures(result.data.features),
});
}
}
} catch (err) {
console.error('Error parsing embedding params:', err);
}
setHasInitialized(true);
}, []);
if (!hasInitialized) {
return (
<div className="flex min-h-screen items-center justify-center">
<Spinner />
</div>
);
}
if (!embedAuthoringOptions) {
return (
<div className="flex min-h-screen items-center justify-center">
<Trans>Invalid Embedding Parameters</Trans>
</div>
);
}
return <EnvelopeCreatePage embedAuthoringOptions={embedAuthoringOptions} />;
}
type EnvelopeCreatePageProps = {
embedAuthoringOptions: TEmbedCreateEnvelopeAuthoring;
};
const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps) => {
const { token, tokenUserId, tokenTeamId, teamSettings, organisationEmails } =
useSuperLoaderData<typeof loader>();
const { t } = useLingui();
const { toast } = useToast();
const [isCreatingEnvelope, setIsCreatingEnvelope] = useState(false);
const [createdEnvelope, setCreatedEnvelope] = useState<{ id: string } | null>(null);
const { mutateAsync: createEmbeddingEnvelope } =
trpc.embeddingPresign.createEmbeddingEnvelope.useMutation();
const buildCreateEnvelopeRequest = (
envelope: Omit<TEditorEnvelope, 'id'>,
): { payload: TCreateEnvelopePayload; files: File[] } => {
const sortedItems = [...envelope.envelopeItems].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const itemIdToIndex = new Map<string, number>();
sortedItems.forEach((item, index) => {
itemIdToIndex.set(String(item.id), index);
});
const files: File[] = [];
for (const item of sortedItems) {
if (!item.data) {
throw new Error(`Envelope item "${item.title ?? item.id}" has no PDF data`);
}
files.push(
new File([item.data], item.title, {
type: 'application/pdf',
}),
);
}
const recipients = envelope.recipients.map((recipient) => {
const recipientFields = envelope.fields.filter((f) => f.recipientId === recipient.id);
const fields = recipientFields.map((field) => {
return {
identifier: itemIdToIndex.get(String(field.envelopeItemId)),
page: field.page,
positionX: Number(field.positionX),
positionY: Number(field.positionY),
width: Number(field.width),
height: Number(field.height),
...({
type: field.type,
fieldMeta: field.fieldMeta ?? undefined,
} as TEnvelopeFieldAndMeta),
};
});
return {
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder ?? undefined,
accessAuth: recipient.authOptions?.accessAuth ?? [],
actionAuth: recipient.authOptions?.actionAuth ?? [],
fields,
};
});
const payload: TCreateEnvelopePayload = {
title: envelope.title,
type: envelope.type,
externalId: envelope.externalId ?? undefined,
visibility: envelope.visibility,
globalAccessAuth: envelope.authOptions?.globalAccessAuth?.length
? envelope.authOptions?.globalAccessAuth
: undefined,
globalActionAuth: envelope.authOptions?.globalActionAuth?.length
? envelope.authOptions?.globalActionAuth
: undefined,
folderId: envelope.folderId ?? undefined,
recipients,
attachments: envelope.attachments,
meta: {
...envelope.documentMeta,
subject: envelope.documentMeta.subject ?? undefined,
message: envelope.documentMeta.message ?? undefined,
timezone: envelope.documentMeta.timezone ?? undefined,
distributionMethod: envelope.documentMeta.distributionMethod ?? undefined,
signingOrder: envelope.documentMeta.signingOrder ?? undefined,
allowDictateNextSigner: envelope.documentMeta.allowDictateNextSigner ?? undefined,
redirectUrl: envelope.documentMeta.redirectUrl ?? undefined,
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled ?? undefined,
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled ?? undefined,
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled ?? undefined,
dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined,
language: envelope.documentMeta.language as SupportedLanguageCodes,
},
};
return { payload, files };
};
const createEmbeddedEnvelope = async (envelopeWithoutId: Omit<TEditorEnvelope, 'id'>) => {
if (isCreatingEnvelope) {
return;
}
setIsCreatingEnvelope(true);
try {
const { payload, files } = buildCreateEnvelopeRequest(envelopeWithoutId);
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
for (const file of files) {
formData.append('files', file);
}
const { id } = await createEmbeddingEnvelope(formData);
// Send a message to the parent window with the document details
if (window.parent !== window) {
window.parent.postMessage(
{
type: 'envelope-created',
envelopeId: id,
externalId: envelopeWithoutId.externalId,
},
'*',
);
}
setCreatedEnvelope({ id });
} catch (err) {
console.error('Failed to create envelope:', err);
toast({
variant: 'destructive',
title: t`Error`,
description: t`Failed to create document. Please try again.`,
});
}
setIsCreatingEnvelope(false);
};
const embedded = useMemo(
() => ({
presignToken: token,
mode: 'create' as const,
onCreate: async (envelope: Omit<TEditorEnvelope, 'id'>) => createEmbeddedEnvelope(envelope),
customBrandingLogo: Boolean(teamSettings.brandingEnabled && teamSettings.brandingLogo),
}),
[token],
);
const editorConfig = useMemo(() => {
return buildEmbeddedEditorOptions(embedAuthoringOptions.features, embedded);
}, [embedAuthoringOptions.features, embedded]);
const initialEnvelope = useMemo((): TEditorEnvelope => {
const defaultDocumentMeta = extractDerivedDocumentMeta(teamSettings, undefined);
const defaultRecipients = teamSettings.defaultRecipients
? ZDefaultRecipientsSchema.parse(teamSettings.defaultRecipients)
: [];
const recipients: TEditorEnvelope['recipients'] = defaultRecipients.map((recipient, index) => ({
id: -(index + 1),
envelopeId: '',
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: '',
readStatus: ReadStatus.NOT_OPENED,
signingStatus: SigningStatus.NOT_SIGNED,
sendStatus: SendStatus.NOT_SENT,
documentDeletedAt: null,
expired: null,
signedAt: null,
authOptions: {
accessAuth: [],
actionAuth: [],
},
signingOrder: index + 1,
rejectionReason: null,
expiresAt: null,
expirationNotifiedAt: null,
}));
const type = embedAuthoringOptions.type;
return {
id: '',
secondaryId: '',
internalVersion: 2,
type,
status: DocumentStatus.DRAFT,
source: 'DOCUMENT',
visibility: teamSettings.documentVisibility,
templateType: 'PRIVATE',
completedAt: null,
deletedAt: null,
title: type === EnvelopeType.DOCUMENT ? 'Document Title' : 'Template Title',
authOptions: {
globalAccessAuth: [],
globalActionAuth: [],
},
publicTitle: '',
publicDescription: '',
userId: tokenUserId,
teamId: tokenTeamId,
folderId: null,
documentMeta: {
id: '',
...defaultDocumentMeta,
},
recipients,
fields: [],
envelopeItems: [],
directLink: null,
team: {
id: tokenTeamId,
url: '',
organisationId: '',
},
user: {
id: tokenUserId,
name: '',
email: '',
},
externalId: embedAuthoringOptions?.externalId ?? null,
attachments: [],
};
}, []);
return (
<div className="min-w-screen relative min-h-screen">
{isCreatingEnvelope && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
<Spinner />
<p className="mt-2 text-sm text-muted-foreground">
{initialEnvelope.type === EnvelopeType.DOCUMENT ? (
<Trans>Creating Document</Trans>
) : (
<Trans>Creating Template</Trans>
)}
</p>
</div>
)}
{createdEnvelope && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
<div className="mx-auto w-full max-w-md text-center">
<CheckCircle2Icon className="mx-auto h-16 w-16 text-primary" />
<h1 className="mt-6 text-2xl font-bold">
{initialEnvelope.type === EnvelopeType.TEMPLATE ? (
<Trans>Template Created</Trans>
) : (
<Trans>Document Created</Trans>
)}
</h1>
<p className="mt-2 text-muted-foreground">
{initialEnvelope.type === EnvelopeType.TEMPLATE ? (
<Trans>Your template has been created successfully</Trans>
) : (
<Trans>Your document has been created successfully</Trans>
)}
</p>
</div>
</div>
)}
<EnvelopeEditorProvider
initialEnvelope={initialEnvelope}
editorConfig={editorConfig}
organisationEmails={organisationEmails}
>
<EnvelopeEditorRenderProviderWrapper presignedToken={token}>
<EnvelopeEditor />
</EnvelopeEditorRenderProviderWrapper>
</EnvelopeEditorProvider>
</div>
);
};

View file

@ -0,0 +1,371 @@
import { useLayoutEffect, useMemo, useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { CheckCircle2Icon } from 'lucide-react';
import { redirect } from 'react-router';
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import type { SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getEditorEnvelopeById } from '@documenso/lib/server-only/envelope/get-editor-envelope-by-id';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import type { TDocumentMetaDateFormat } from '@documenso/lib/types/document-meta';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
import {
type TEmbedEditEnvelopeAuthoring,
ZEmbedEditEnvelopeAuthoringSchema,
} from '@documenso/lib/types/envelope-editor';
import type { TEnvelopeFieldAndMeta } from '@documenso/lib/types/field-meta';
import { buildEmbeddedEditorOptions } from '@documenso/lib/utils/embed-config';
import { prisma } from '@documenso/prisma';
import { trpc } from '@documenso/trpc/react';
import type { TUpdateEmbeddingEnvelopePayload } from '@documenso/trpc/server/embedding-router/update-embedding-envelope.types';
import { Spinner } from '@documenso/ui/primitives/spinner';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { EnvelopeEditor } from '~/components/general/envelope-editor/envelope-editor';
import { EnvelopeEditorRenderProviderWrapper } from '~/components/general/envelope-editor/envelope-editor-renderer-provider-wrapper';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/envelope.edit.$id';
export const loader = async ({ request, params }: Route.LoaderArgs) => {
const url = new URL(request.url);
const { id } = params;
if (!id || !id.startsWith('envelope_')) {
throw redirect(`/embed/v2/authoring/error/not-found`);
}
// We know that the token is present because we're checking it in the parent _layout route
const token = url.searchParams.get('token') || '';
if (!token) {
throw new Response('Invalid token', { status: 404 });
}
// We also know that the token is valid, but we need the userId + teamId
const result = await verifyEmbeddingPresignToken({ token, scope: `envelopeId:${id}` }).catch(
() => null,
);
if (!result) {
throw new Error('Invalid token');
}
const settings = await getTeamSettings({
userId: result.userId,
teamId: result.teamId,
});
const envelope = await getEditorEnvelopeById({
id: {
type: 'envelopeId',
id,
},
type: null,
userId: result.userId,
teamId: result.teamId,
}).catch(() => null);
if (!envelope) {
throw redirect(`/embed/v2/authoring/error/not-found`);
}
const organisationEmails = await prisma.organisationEmail.findMany({
where: {
organisationId: envelope.team.organisationId,
},
orderBy: {
createdAt: 'desc',
},
select: {
id: true,
email: true,
},
});
let brandingLogo: string | undefined = undefined;
if (settings.brandingEnabled && settings.brandingLogo) {
brandingLogo = settings.brandingLogo;
}
return superLoaderJson({
token,
envelope,
organisationEmails,
brandingLogo,
});
};
export default function EmbeddingAuthoringEnvelopeEditPage() {
const [hasInitialized, setHasInitialized] = useState(false);
const [embedAuthoringOptions, setEmbedAuthoringOptions] =
useState<TEmbedEditEnvelopeAuthoring | null>(null);
useLayoutEffect(() => {
try {
const hash = window.location.hash.slice(1);
if (hash) {
const result = ZEmbedEditEnvelopeAuthoringSchema.safeParse(
JSON.parse(decodeURIComponent(atob(hash))),
);
if (result.success) {
setEmbedAuthoringOptions(result.data);
}
}
} catch (err) {
console.error('Error parsing embedding params:', err);
}
setHasInitialized(true);
}, []);
if (!hasInitialized) {
return (
<div className="flex min-h-screen items-center justify-center">
<Spinner />
</div>
);
}
if (!embedAuthoringOptions) {
return (
<div className="flex min-h-screen items-center justify-center">
<Trans>Invalid Embedding Parameters</Trans>
</div>
);
}
return <EnvelopeEditPage embedAuthoringOptions={embedAuthoringOptions} />;
}
type EnvelopeEditPageProps = {
embedAuthoringOptions: TEmbedEditEnvelopeAuthoring;
};
const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
const { envelope, token, brandingLogo, organisationEmails } = useSuperLoaderData<typeof loader>();
const { t } = useLingui();
const { toast } = useToast();
const [isUpdatingEnvelope, setIsUpdatingEnvelope] = useState(false);
const [updatedEnvelope, setUpdatedEnvelope] = useState<{ id: string } | null>(null);
const { mutateAsync: updateEmbeddingEnvelope } =
trpc.embeddingPresign.updateEmbeddingEnvelope.useMutation();
const buildUpdateEnvelopeRequest = (
envelope: TEditorEnvelope,
): { payload: TUpdateEmbeddingEnvelopePayload; files: File[] } => {
const files: File[] = [];
const envelopeItems = envelope.envelopeItems.map((item) => {
// Attach any new envelope item files to the request.
if (item.data) {
files.push(
new File(
[item.data],
item.title?.endsWith('.pdf') ? item.title : `${item.title ?? 'document'}.pdf`,
{
type: 'application/pdf',
},
),
);
}
return {
id: item.id,
title: item.title,
order: item.order,
index: item.data ? files.length - 1 : undefined,
};
});
const recipients = envelope.recipients.map((recipient) => {
const recipientFields = envelope.fields.filter((f) => f.recipientId === recipient.id);
const fields = recipientFields.map((field) => ({
id: field.id,
envelopeItemId: field.envelopeItemId,
page: field.page,
positionX: Number(field.positionX),
positionY: Number(field.positionY),
width: Number(field.width),
height: Number(field.height),
...({
type: field.type,
fieldMeta: field.fieldMeta ?? undefined,
} as TEnvelopeFieldAndMeta),
}));
return {
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder ?? undefined,
accessAuth: recipient.authOptions?.accessAuth ?? [],
actionAuth: recipient.authOptions?.actionAuth ?? [],
fields,
};
});
const payload: TUpdateEmbeddingEnvelopePayload = {
envelopeId: envelope.id,
data: {
title: envelope.title,
externalId: envelope.externalId,
visibility: envelope.visibility,
globalAccessAuth: envelope.authOptions?.globalAccessAuth,
globalActionAuth: envelope.authOptions?.globalActionAuth,
folderId: envelope.folderId,
recipients,
envelopeItems,
attachments: envelope.attachments,
},
meta: {
...envelope.documentMeta,
subject: envelope.documentMeta.subject ?? undefined,
message: envelope.documentMeta.message ?? undefined,
timezone: envelope.documentMeta.timezone ?? undefined,
distributionMethod: envelope.documentMeta.distributionMethod ?? undefined,
signingOrder: envelope.documentMeta.signingOrder ?? undefined,
allowDictateNextSigner: envelope.documentMeta.allowDictateNextSigner ?? undefined,
redirectUrl: envelope.documentMeta.redirectUrl ?? undefined,
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled, //
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled, //
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled, //
dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined,
language: envelope.documentMeta.language as SupportedLanguageCodes,
},
};
return { payload, files };
};
const updateEmbeddedEnvelope = async (envelope: TEditorEnvelope) => {
if (isUpdatingEnvelope) {
return;
}
setIsUpdatingEnvelope(true);
try {
const { payload, files } = buildUpdateEnvelopeRequest(envelope);
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
for (const file of files) {
formData.append('files', file);
}
await updateEmbeddingEnvelope(formData);
// Send a message to the parent window with the document details
if (window.parent !== window) {
window.parent.postMessage(
{
type: 'envelope-updated',
envelopeId: envelope.id,
externalId: envelope.externalId || null,
},
'*',
);
}
// Navigate to the completion page.
setUpdatedEnvelope({ id: envelope.id });
} catch (err) {
console.error('Failed to update envelope:', err);
toast({
variant: 'destructive',
title: t`Error`,
description: t`Failed to update envelope. Please try again.`,
});
}
setIsUpdatingEnvelope(false);
};
const embedded = useMemo(
() => ({
presignToken: token,
mode: 'edit' as const,
onUpdate: async (envelope: TEditorEnvelope) => updateEmbeddedEnvelope(envelope),
brandingLogo,
}),
[token],
);
const editorConfig = useMemo(() => {
return buildEmbeddedEditorOptions(embedAuthoringOptions.features, embedded);
}, [embedAuthoringOptions.features, embedded]);
const initialEnvelope = useMemo(
() => ({
...envelope,
externalId: embedAuthoringOptions?.externalId || envelope.externalId || null,
}),
[envelope, embedAuthoringOptions?.externalId],
);
return (
<div className="min-w-screen relative min-h-screen">
{isUpdatingEnvelope && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
<Spinner />
<p className="mt-2 text-sm text-muted-foreground">
{envelope.type === EnvelopeType.DOCUMENT ? (
<Trans>Updating Document</Trans>
) : (
<Trans>Updating Template</Trans>
)}
</p>
</div>
)}
{updatedEnvelope && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
<div className="mx-auto w-full max-w-md text-center">
<CheckCircle2Icon className="mx-auto h-16 w-16 text-primary" />
<h1 className="mt-6 text-2xl font-bold">
{envelope.type === EnvelopeType.TEMPLATE ? (
<Trans>Template Updated</Trans>
) : (
<Trans>Document Updated</Trans>
)}
</h1>
<p className="mt-2 text-muted-foreground">
{envelope.type === EnvelopeType.TEMPLATE ? (
<Trans>Your template has been updated successfully</Trans>
) : (
<Trans>Your document has been updated successfully</Trans>
)}
</p>
</div>
</div>
)}
<EnvelopeEditorProvider
initialEnvelope={initialEnvelope}
editorConfig={editorConfig}
organisationEmails={organisationEmails}
>
<EnvelopeEditorRenderProviderWrapper presignedToken={token}>
<EnvelopeEditor />
</EnvelopeEditorRenderProviderWrapper>
</EnvelopeEditorProvider>
</div>
);
};

View file

@ -1,41 +1,7 @@
import { colord } from 'colord';
import { toKebabCase } from 'remeda';
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'),
})
.describe('Custom CSS variables for theming');
export type TCssVarsSchema = z.infer<typeof ZCssVarsSchema>;
import type { TCssVarsSchema } from '@documenso/lib/types/css-vars';
export const toNativeCssVars = (vars: TCssVarsSchema) => {
const cssVars: Record<string, string> = {};

View file

@ -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);
});
});

View file

@ -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,
});
});
});

View file

@ -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);
});
});

View file

@ -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,
});
});
});

View file

@ -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.
});
});

View file

@ -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,
});
});
});

View file

@ -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,
});
});
});

View file

@ -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;
};

View file

@ -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 },
);
};

View file

@ -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();
};

View file

@ -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;
};

View file

@ -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 = {

View file

@ -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: {

View file

@ -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,
};
});
};

View file

@ -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) {

View file

@ -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,
},
};
};

View file

@ -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,
};
});
});
};

View file

@ -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,
},
},
});
};

View file

@ -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;
};

View file

@ -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) => ({

View file

@ -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,
},
};
};

View file

@ -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;
};

View file

@ -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>;

View file

@ -1,6 +1,6 @@
import { z } from 'zod';
import { ZCssVarsSchema } from '../utils/css-vars';
import { ZCssVarsSchema } from './css-vars';
export const ZBaseEmbedDataSchema = z.object({
darkModeDisabled: z.boolean().optional().default(false),

View file

@ -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;
};
};

View file

@ -419,4 +419,4 @@ export const ZEnvelopeFieldAndMetaSchema = z.discriminatedUnion('type', [
}),
]);
type TEnvelopeFieldAndMeta = z.infer<typeof ZEnvelopeFieldAndMetaSchema>;
export type TEnvelopeFieldAndMeta = z.infer<typeof ZEnvelopeFieldAndMetaSchema>;

View file

@ -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),
]);

View file

@ -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,
},
};
};

View file

@ -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);
};

View file

@ -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))',

View file

@ -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,

View file

@ -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,
});
});

View file

@ -0,0 +1,8 @@
import {
ZCreateEnvelopeRequestSchema,
ZCreateEnvelopeResponseSchema,
} from '../envelope-router/create-envelope.types';
export const ZCreateEmbeddingEnvelopeRequestSchema = ZCreateEnvelopeRequestSchema;
export const ZCreateEmbeddingEnvelopeResponseSchema = ZCreateEnvelopeResponseSchema;

View file

@ -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({

View file

@ -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;
};

View 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>;

View file

@ -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 {

View file

@ -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,
};
};

View file

@ -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;

View file

@ -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,
});
});

View file

@ -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>;

View file

@ -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,

View file

@ -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({

View file

@ -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,

View file

@ -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>
),

View file

@ -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>

View file

@ -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%;
}
}