mirror of
https://github.com/documenso/documenso.git
synced 2026-06-24 05:12:04 +10:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de134afba1 | |||
| 36bbd97514 | |||
| 943a0b50e3 | |||
| 6ef501c9f2 | |||
| ac09a48eaa | |||
| 70fb834a6a | |||
| 66e357c9b3 | |||
| 3106fd7483 | |||
| 32c54e1245 | |||
| 83fbc70a1c | |||
| 1ee6ec87a2 | |||
| 6b1b1d0417 | |||
| 9f680c7a61 | |||
| 76d96d2f65 | |||
| 2f2b5dd232 | |||
| 8d97f1dcfa | |||
| e67e19358a | |||
| 364537e8fe | |||
| 4751c9cecc | |||
| a5fd814fbc | |||
| 1d2c781a6d | |||
| 03ca3971a0 | |||
| 5ea4060fd7 | |||
| af346b179c | |||
| ab69ee627b | |||
| 4daec44550 | |||
| 11eb4dd2cd | |||
| cc71c7d9ba | |||
| f82bf97480 | |||
| 0e20d364ef | |||
| ef57c8448a | |||
| eaaf8f9e63 | |||
| 58f0c98038 | |||
| da7b5d12f8 | |||
| 7cfe876762 | |||
| 15399cbe8e | |||
| c4754553c9 | |||
| 6c8726b58c | |||
| abd031b58b | |||
| 1ff8680c32 | |||
| 7ea664214a | |||
| 7e2cbe46c0 | |||
| c63b4ca3cc | |||
| 6faa01d384 | |||
| 0ce909a298 | |||
| 7f271379b9 | |||
| 406e77e4be | |||
| bff360b084 |
@@ -0,0 +1,113 @@
|
||||
---
|
||||
date: 2026-03-07
|
||||
title: Search Query Optimization
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
The `searchDocumentsWithKeyword` and `searchTemplatesWithKeyword` functions generate a single massive Prisma `findMany` with 7 OR branches. This produces a SQL query that:
|
||||
|
||||
- Joins `Team` twice (aliased j3 and j8) for the two team-access branches
|
||||
- Embeds 4-level deep EXISTS subqueries (`TeamGroup -> OrganisationGroup -> OrganisationGroupMember -> OrganisationMember`) for each team branch
|
||||
- Uses `ILIKE` across multiple columns with no way for Postgres to use indexes effectively across the OR
|
||||
- Includes `recipients: true` on the result even though only a small subset of fields are needed
|
||||
- Fetches all matching rows then filters visibility **in application code**
|
||||
|
||||
With 1,000 documents seeded under `medium-account@documenso.com`, this query is noticeably slow.
|
||||
|
||||
---
|
||||
|
||||
## Option A: Pre-resolve team IDs, keep Prisma
|
||||
|
||||
**Change:** Before the envelope query, resolve the user's accessible team IDs in a single query:
|
||||
|
||||
```ts
|
||||
const teamIds = await prisma.teamGroup
|
||||
.findMany({
|
||||
where: {
|
||||
organisationGroup: {
|
||||
organisationGroupMembers: {
|
||||
some: { organisationMember: { userId } },
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { teamId: true },
|
||||
})
|
||||
.then((rows) => [...new Set(rows.map((r) => r.teamId))]);
|
||||
```
|
||||
|
||||
Then replace `team: buildTeamWhereQuery(...)` with `teamId: { in: teamIds }` in the envelope query.
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Eliminates the duplicated 4-level deep join chain from the envelope query
|
||||
- The team ID resolution is a simple indexed lookup (runs once, not twice)
|
||||
- Minimal code change -- still Prisma, same structure
|
||||
- Can also pre-resolve team roles to move visibility filtering into the WHERE clause
|
||||
|
||||
**Drawbacks:**
|
||||
|
||||
- Still a single large OR query with ILIKE branches
|
||||
- Prisma still generates suboptimal SQL for the remaining OR conditions
|
||||
|
||||
---
|
||||
|
||||
## Option B: Kysely rewrite with pre-resolved teams
|
||||
|
||||
**Change:** Rewrite using Kysely (already set up in codebase as `kyselyPrisma.$kysely`). Follow the pattern in `find-envelopes.ts` -- use Kysely for filtering/ID fetching, then Prisma for hydration.
|
||||
|
||||
Structure as a UNION of targeted queries instead of a single OR:
|
||||
|
||||
```
|
||||
Query 1: owned docs matching title/externalId (simple indexed lookup)
|
||||
Query 2: docs where user is recipient matching title (EXISTS on Recipient)
|
||||
Query 3: team docs matching title/externalId (using pre-resolved teamIds)
|
||||
UNION ALL -> deduplicate -> ORDER BY createdAt DESC -> LIMIT 20
|
||||
```
|
||||
|
||||
Then hydrate the 20 IDs with Prisma for the include data.
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Each sub-query is simple and independently optimizable by Postgres
|
||||
- UNION eliminates the massive OR which forces bad query plans
|
||||
- Kysely gives control over exact SQL structure
|
||||
- Only hydrate the final 20 results (not all matches)
|
||||
- Follows existing `find-envelopes.ts` pattern -- not a new paradigm
|
||||
|
||||
**Drawbacks:**
|
||||
|
||||
- More code than Option A
|
||||
- Two query layers (Kysely for IDs, Prisma for hydration)
|
||||
|
||||
---
|
||||
|
||||
## Option C: Hybrid -- pre-resolve teams + simplify Prisma OR
|
||||
|
||||
**Change:** Pre-resolve team IDs (like Option A), but also restructure the Prisma query to reduce OR branches:
|
||||
|
||||
- Merge "owned + title" and "owned + externalId" and "owned + recipient email" into a single owned-docs branch with nested OR
|
||||
- Merge "team + title" and "team + externalId" into a single team-docs branch
|
||||
- Keep "recipient inbox" branches separate
|
||||
|
||||
This reduces from 7 OR branches to ~3-4, with simpler conditions in each.
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Simpler than Kysely rewrite
|
||||
- Fewer OR branches = better query plan
|
||||
- Pre-resolved team IDs eliminate the deep joins
|
||||
- Still pure Prisma
|
||||
|
||||
**Drawbacks:**
|
||||
|
||||
- Postgres still has to handle OR across different access patterns in one query
|
||||
- Less control over SQL than Kysely
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Option B (Kysely)** is the strongest choice. The codebase already uses this exact pattern for `find-envelopes.ts` which solves the same class of problem. The UNION approach gives Postgres the best chance at using indexes per sub-query. Pre-resolving team IDs is a prerequisite for all options and is trivially cheap.
|
||||
|
||||
The template search query has the same structure and should get the same treatment.
|
||||
@@ -0,0 +1,337 @@
|
||||
---
|
||||
name: create-documentation
|
||||
description: Generate markdown documentation for a module or feature
|
||||
---
|
||||
|
||||
You are creating proper markdown documentation for a feature or guide in the Documenso documentation site.
|
||||
|
||||
**Read [WRITING_STYLE.md](../../../WRITING_STYLE.md) first** for tone, formatting conventions, and anti-patterns to avoid.
|
||||
|
||||
## Your Task
|
||||
|
||||
1. **Identify the scope** - Based on the conversation context, determine what feature or topic needs documentation. Ask the user if unclear.
|
||||
2. **Identify the audience** - Is this for Users, Developers, or Self-Hosters?
|
||||
3. **Read the source code** - Understand the feature, API, or configuration being documented.
|
||||
4. **Read existing docs** - Check `apps/docs/content/docs/` for documentation to update.
|
||||
5. **Write comprehensive documentation** - Create or update MDX docs following the patterns below.
|
||||
6. **Update navigation** - Add to the relevant `meta.json` if creating a new page.
|
||||
|
||||
## Documentation Framework
|
||||
|
||||
This project uses [Fumadocs](https://fumadocs.dev). All documentation lives in `apps/docs/content/docs/` as MDX files. The docs app is a Next.js app at `apps/docs/`.
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
```
|
||||
apps/docs/content/docs/
|
||||
├── index.mdx # Landing page with audience navigation
|
||||
├── meta.json # Root navigation: guides + resources
|
||||
├── users/ # Application usage guides
|
||||
│ ├── meta.json # { "root": true, "pages": [...] }
|
||||
│ ├── getting-started/ # Account creation, first document
|
||||
│ ├── documents/ # Upload, recipients, fields, send
|
||||
│ │ └── advanced/ # AI detection, visibility, placeholders
|
||||
│ ├── templates/ # Create and use templates
|
||||
│ ├── organisations/ # Overview, members, groups, SSO, billing
|
||||
│ │ ├── single-sign-on/
|
||||
│ │ └── preferences/
|
||||
│ └── settings/ # Profile, security, API tokens
|
||||
├── developers/ # API and integration docs
|
||||
│ ├── meta.json # { "root": true, "pages": [...] }
|
||||
│ ├── getting-started/ # Authentication, first API call
|
||||
│ ├── api/ # Documents, recipients, fields, templates, teams
|
||||
│ ├── webhooks/ # Setup, events, verification
|
||||
│ ├── embedding/ # Authoring, direct links, CSS vars, SDKs
|
||||
│ │ └── sdks/ # React, Vue, Svelte, Solid, Preact, Angular
|
||||
│ ├── examples/ # Common workflows
|
||||
│ ├── local-development/ # Quickstart, manual, translations
|
||||
│ └── contributing/ # Contributing translations
|
||||
├── self-hosting/ # Self-hosting documentation
|
||||
│ ├── meta.json # { "root": true, "pages": [...] }
|
||||
│ ├── getting-started/ # Quick start, requirements, tips
|
||||
│ ├── deployment/ # Docker, docker-compose, Kubernetes, Railway
|
||||
│ ├── configuration/ # Environment, database, email, storage
|
||||
│ │ ├── signing-certificate/ # Local, Google Cloud HSM, timestamp
|
||||
│ │ └── advanced/ # OAuth providers, AI features
|
||||
│ └── maintenance/ # Upgrades, backups, troubleshooting
|
||||
├── concepts/ # Shared across audiences
|
||||
│ └── ... # Document lifecycle, field types, signing
|
||||
├── compliance/ # eSign, GDPR, standards, certifications
|
||||
└── policies/ # Terms, privacy, security, licenses
|
||||
```
|
||||
|
||||
### Where to Put Documentation
|
||||
|
||||
| Type | Location | When to use |
|
||||
| ------------------- | ------------------------------------------------ | -------------------------------------------------- |
|
||||
| **User Guide** | `apps/docs/content/docs/users/<section>/` | UI workflows for using the Documenso web app |
|
||||
| **Developer Guide** | `apps/docs/content/docs/developers/<section>/` | API reference, SDK guides, webhooks, embedding |
|
||||
| **Self-Hosting** | `apps/docs/content/docs/self-hosting/<section>/` | Deployment, configuration, environment variables |
|
||||
| **Concept** | `apps/docs/content/docs/concepts/` | Cross-audience concepts (document lifecycle, etc.) |
|
||||
| **Compliance** | `apps/docs/content/docs/compliance/` | Legal and regulatory documentation |
|
||||
|
||||
### Navigation (meta.json)
|
||||
|
||||
Each directory has a `meta.json` controlling navigation order:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Section Title",
|
||||
"pages": ["getting-started", "documents", "templates"]
|
||||
}
|
||||
```
|
||||
|
||||
Top-level audience sections use `"root": true`:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Users",
|
||||
"description": "Send and sign documents",
|
||||
"root": true,
|
||||
"pages": ["getting-started", "documents", "templates", "organisations", "settings"]
|
||||
}
|
||||
```
|
||||
|
||||
Root `meta.json` uses `---Label---` for section dividers:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Documentation",
|
||||
"pages": [
|
||||
"---Guides---",
|
||||
"users",
|
||||
"developers",
|
||||
"self-hosting",
|
||||
"---Resources---",
|
||||
"concepts",
|
||||
"compliance",
|
||||
"policies"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## MDX File Format
|
||||
|
||||
### Frontmatter
|
||||
|
||||
Every page needs frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Upload Documents
|
||||
description: Upload documents to Documenso to prepare them for signing. Covers supported formats, file size limits, and upload methods.
|
||||
---
|
||||
```
|
||||
|
||||
### Fumadocs Components
|
||||
|
||||
Import components at the top of the file (after frontmatter):
|
||||
|
||||
```mdx
|
||||
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
|
||||
;
|
||||
```
|
||||
|
||||
Callouts (use sparingly for warnings, beta features, security):
|
||||
|
||||
```mdx
|
||||
<Callout type="info">Informational note about behavior.</Callout>
|
||||
|
||||
<Callout type="warn">Warning about potential issues or breaking changes.</Callout>
|
||||
|
||||
<Callout type="error">Critical warning about data loss or security.</Callout>
|
||||
```
|
||||
|
||||
Steps (for sequential UI instructions):
|
||||
|
||||
```mdx
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
### Step title
|
||||
|
||||
Step description.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
### Next step
|
||||
|
||||
Next description.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
```
|
||||
|
||||
Tabs (for multiple approaches or platforms):
|
||||
|
||||
````mdx
|
||||
<Tabs items={['cURL', 'JavaScript', 'Python']}>
|
||||
<Tab value="cURL">```bash curl -X POST ... ```</Tab>
|
||||
<Tab value="JavaScript">```typescript const response = await fetch(...) ```</Tab>
|
||||
</Tabs>
|
||||
````
|
||||
|
||||
## Page Structure by Audience
|
||||
|
||||
### User Documentation
|
||||
|
||||
```mdx
|
||||
---
|
||||
title: Feature Name
|
||||
description: Brief description for SEO and previews.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
## Limitations
|
||||
|
||||
| Limitation | Value |
|
||||
| ----------------- | -------- |
|
||||
| Supported format | PDF only |
|
||||
| Maximum file size | 50MB |
|
||||
|
||||
## How to Do the Thing
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
### Navigate to the page
|
||||
|
||||
Open **Settings > Feature**.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
### Configure the setting
|
||||
|
||||
Fill in the required fields and click **Save**.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Related Guide](/docs/users/related)
|
||||
```
|
||||
|
||||
### Developer Documentation
|
||||
|
||||
````mdx
|
||||
---
|
||||
title: Documents API
|
||||
description: Create, manage, and send documents for signing via the API.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
|
||||
<Callout type="warn">
|
||||
This guide may not reflect the latest endpoints. For an always up-to-date reference, see the
|
||||
[OpenAPI Reference](https://openapi.documenso.com).
|
||||
</Callout>
|
||||
|
||||
## Overview
|
||||
|
||||
Brief description of the resource and what you can do with it.
|
||||
|
||||
## Resource Object
|
||||
|
||||
| Property | Type | Description |
|
||||
| -------- | ------ | ----------------- |
|
||||
| `id` | string | Unique identifier |
|
||||
| `status` | string | Current status |
|
||||
|
||||
## Create a Resource
|
||||
|
||||
```typescript
|
||||
const response = await fetch('https://app.documenso.com/api/v2/document', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer YOUR_API_TOKEN',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: 'Service Agreement',
|
||||
}),
|
||||
});
|
||||
```
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Related Guide](/docs/developers/related)
|
||||
|
||||
````
|
||||
|
||||
### Self-Hosting Documentation
|
||||
|
||||
```mdx
|
||||
---
|
||||
title: Environment Variables
|
||||
description: Complete reference for all environment variables used to configure Documenso.
|
||||
---
|
||||
|
||||
## Required Variables
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------ | ------------------------------------------------ |
|
||||
| `NEXTAUTH_SECRET` | Secret key for session encryption (min 32 chars) |
|
||||
| `DATABASE_URL` | PostgreSQL connection URL |
|
||||
|
||||
---
|
||||
|
||||
## Optional Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------------- | ------- | ---------------------- |
|
||||
| `PORT` | `3000` | Port the server runs on |
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Database Configuration](/docs/self-hosting/configuration/database)
|
||||
````
|
||||
|
||||
## Documentation Audiences
|
||||
|
||||
Tailor content to the audience:
|
||||
|
||||
- **User docs**: Focus on UI workflows, bold UI elements (**Settings**, **Save**), use `>` for navigation paths (**Settings > Team > Members**), number sequential steps, no code required
|
||||
- **Developer docs**: API/SDK examples, authentication, webhooks, code samples in TypeScript, link to OpenAPI reference
|
||||
- **Self-hosting docs**: Deployment guides, environment variables, Docker/non-Docker approaches, system requirements, troubleshooting
|
||||
|
||||
## Guidelines
|
||||
|
||||
See [WRITING_STYLE.md](../../../WRITING_STYLE.md) for complete guidelines. Key points:
|
||||
|
||||
- **Tone**: Direct, second-person, no emojis, no excessive personality
|
||||
- **Examples**: Progressive complexity, all must be valid TypeScript
|
||||
- **Tables**: Use Sharp-style nested parameter tables for API docs
|
||||
- **Callouts**: Use sparingly for warnings, beta features, security
|
||||
- **Cross-references**: Link related docs, add "See Also" sections
|
||||
- **Navigation**: Update `meta.json` when adding new pages
|
||||
- **Limitations**: Explicitly list what is NOT supported
|
||||
- **Images**: Use `.webp` format, store in `apps/docs/public/`
|
||||
|
||||
## Process
|
||||
|
||||
1. **Identify the audience** - Users, Developers, or Self-Hosters?
|
||||
2. **Explore the code** - Read source files to understand the feature
|
||||
3. **Check existing docs** - Look in `apps/docs/content/docs/` for related pages
|
||||
4. **Draft the structure** - Outline sections before writing
|
||||
5. **Write content** - Fill in each section following audience-specific patterns
|
||||
6. **Update navigation** - Add to relevant `meta.json` if creating a new page
|
||||
7. **Add cross-references** - Link from related docs, add "See Also" section
|
||||
|
||||
## Begin
|
||||
|
||||
Analyze the conversation context to determine the documentation scope, read the relevant source code, and create comprehensive MDX documentation in `apps/docs/content/docs/`.
|
||||
@@ -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.
|
||||
@@ -153,6 +153,8 @@ NEXT_PUBLIC_POSTHOG_KEY=""
|
||||
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
|
||||
# OPTIONAL: Leave blank to allow users to signup through /signup page.
|
||||
NEXT_PUBLIC_DISABLE_SIGNUP=
|
||||
# OPTIONAL: Comma-separated list of email domains allowed to sign up (e.g., example.com,acme.org).
|
||||
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=
|
||||
# OPTIONAL: Set to true to use internal webapp url in browserless requests.
|
||||
NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ CLAUDE.md
|
||||
|
||||
# scripts
|
||||
scripts/output*
|
||||
scripts/bench-*
|
||||
|
||||
# license
|
||||
.documenso-license.json
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
description: Generate markdown documentation for a module or feature
|
||||
argument-hint: <topic-or-feature>
|
||||
---
|
||||
|
||||
You are generating documentation for the Documenso project.
|
||||
|
||||
## Your Task
|
||||
|
||||
Load and follow the skill at `.agents/skills/create-documentation/SKILL.md`. It contains the complete instructions for writing documentation including:
|
||||
|
||||
- Documentation structure and file locations
|
||||
- MDX format and Fumadocs components
|
||||
- Audience-specific patterns (Users, Developers, Self-Hosters)
|
||||
- Navigation (`meta.json`) updates
|
||||
- Writing style guidelines
|
||||
|
||||
## Context
|
||||
|
||||
The topic or feature to document is: `$ARGUMENTS`
|
||||
|
||||
## Begin
|
||||
|
||||
1. **Read the skill** at `.agents/skills/create-documentation/SKILL.md`
|
||||
2. **Read WRITING_STYLE.md** for tone and formatting conventions
|
||||
3. **Follow the skill instructions** to create comprehensive documentation
|
||||
4. **Use TodoWrite** to track your progress throughout
|
||||
@@ -7,69 +7,17 @@ You are creating a new justification file in the `.agents/justifications/` direc
|
||||
|
||||
## Your Task
|
||||
|
||||
1. **Determine the slug** - Use `$ARGUMENTS` as the file slug (kebab-case recommended)
|
||||
2. **Gather content** - Collect or generate the justification content
|
||||
3. **Create the file** - Use the create-justification script to generate the file
|
||||
Load and follow the skill at `.agents/skills/create-justification/SKILL.md`. It contains the complete instructions for creating justification files including:
|
||||
|
||||
## Usage
|
||||
- Unique three-word ID generation
|
||||
- Frontmatter format with date and title
|
||||
- Script usage (`scripts/create-justification.ts`)
|
||||
|
||||
The script will automatically:
|
||||
- Generate a unique three-word ID (e.g., `swift-emerald-river`)
|
||||
- Create frontmatter with current date and formatted title
|
||||
- Save the file as `{id}-{slug}.md` in `.agents/justifications/`
|
||||
## Context
|
||||
|
||||
## Creating the File
|
||||
|
||||
### Option 1: Direct Content
|
||||
|
||||
If you have the content ready, run:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/create-justification.ts "$ARGUMENTS" "Your justification content here"
|
||||
```
|
||||
|
||||
### Option 2: Multi-line Content (Heredoc)
|
||||
|
||||
For multi-line content, use heredoc:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/create-justification.ts "$ARGUMENTS" << HEREDOC
|
||||
Your multi-line
|
||||
justification content
|
||||
goes here
|
||||
HEREDOC
|
||||
```
|
||||
|
||||
### Option 3: Pipe Content
|
||||
|
||||
You can also pipe content:
|
||||
|
||||
```bash
|
||||
echo "Your content" | npx tsx scripts/create-justification.ts "$ARGUMENTS"
|
||||
```
|
||||
|
||||
## File Format
|
||||
|
||||
The created file will have:
|
||||
|
||||
```markdown
|
||||
---
|
||||
date: 2026-01-13
|
||||
title: Justification Title
|
||||
---
|
||||
|
||||
Your content here
|
||||
```
|
||||
|
||||
The title is automatically formatted from the slug (e.g., `architecture-decision` → `Architecture Decision`).
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Use descriptive slugs in kebab-case (e.g., `tech-stack-choice`, `api-design-rationale`)
|
||||
- Include clear reasoning and context for the decision
|
||||
- The unique ID ensures no filename conflicts
|
||||
- Files are automatically dated for organization
|
||||
The justification slug and optional content: `$ARGUMENTS`
|
||||
|
||||
## Begin
|
||||
|
||||
Create a justification file using the slug from `$ARGUMENTS` and appropriate content documenting the reasoning or justification.
|
||||
1. **Read the skill** at `.agents/skills/create-justification/SKILL.md`
|
||||
2. **Create the justification file** using the slug from `$ARGUMENTS` and appropriate content documenting the reasoning or justification
|
||||
|
||||
@@ -7,70 +7,17 @@ You are creating a new plan file in the `.agents/plans/` directory.
|
||||
|
||||
## Your Task
|
||||
|
||||
1. **Determine the slug** - Use `$ARGUMENTS` as the file slug (kebab-case recommended)
|
||||
2. **Gather content** - Collect or generate the plan content
|
||||
3. **Create the file** - Use the create-plan script to generate the file
|
||||
Load and follow the skill at `.agents/skills/create-plan/SKILL.md`. It contains the complete instructions for creating plan files including:
|
||||
|
||||
## Usage
|
||||
- Unique three-word ID generation
|
||||
- Frontmatter format with date and title
|
||||
- Script usage (`scripts/create-plan.ts`)
|
||||
|
||||
The script will automatically:
|
||||
## Context
|
||||
|
||||
- Generate a unique three-word ID (e.g., `happy-blue-moon`)
|
||||
- Create frontmatter with current date and formatted title
|
||||
- Save the file as `{id}-{slug}.md` in `.agents/plans/`
|
||||
|
||||
## Creating the File
|
||||
|
||||
### Option 1: Direct Content
|
||||
|
||||
If you have the content ready, run:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/create-plan.ts "$ARGUMENTS" "Your plan content here"
|
||||
```
|
||||
|
||||
### Option 2: Multi-line Content (Heredoc)
|
||||
|
||||
For multi-line content, use heredoc:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/create-plan.ts "$ARGUMENTS" << HEREDOC
|
||||
Your multi-line
|
||||
plan content
|
||||
goes here
|
||||
HEREDOC
|
||||
```
|
||||
|
||||
### Option 3: Pipe Content
|
||||
|
||||
You can also pipe content:
|
||||
|
||||
```bash
|
||||
echo "Your content" | npx tsx scripts/create-plan.ts "$ARGUMENTS"
|
||||
```
|
||||
|
||||
## File Format
|
||||
|
||||
The created file will have:
|
||||
|
||||
```markdown
|
||||
---
|
||||
date: 2026-01-13
|
||||
title: Plan Title
|
||||
---
|
||||
|
||||
Your content here
|
||||
```
|
||||
|
||||
The title is automatically formatted from the slug (e.g., `my-feature` → `My Feature`).
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Use descriptive slugs in kebab-case (e.g., `user-authentication`, `api-integration`)
|
||||
- Include clear, actionable plan content
|
||||
- The unique ID ensures no filename conflicts
|
||||
- Files are automatically dated for organization
|
||||
The plan slug and optional content: `$ARGUMENTS`
|
||||
|
||||
## Begin
|
||||
|
||||
Create a plan file using the slug from `$ARGUMENTS` and appropriate content for the planning task.
|
||||
1. **Read the skill** at `.agents/skills/create-plan/SKILL.md`
|
||||
2. **Create the plan file** using the slug from `$ARGUMENTS` and appropriate content
|
||||
|
||||
@@ -7,69 +7,17 @@ You are creating a new scratch file in the `.agents/scratches/` directory.
|
||||
|
||||
## Your Task
|
||||
|
||||
1. **Determine the slug** - Use `$ARGUMENTS` as the file slug (kebab-case recommended)
|
||||
2. **Gather content** - Collect or generate the scratch content
|
||||
3. **Create the file** - Use the create-scratch script to generate the file
|
||||
Load and follow the skill at `.agents/skills/create-scratch/SKILL.md`. It contains the complete instructions for creating scratch files including:
|
||||
|
||||
## Usage
|
||||
- Unique three-word ID generation
|
||||
- Frontmatter format with date and title
|
||||
- Script usage (`scripts/create-scratch.ts`)
|
||||
|
||||
The script will automatically:
|
||||
- Generate a unique three-word ID (e.g., `calm-teal-cloud`)
|
||||
- Create frontmatter with current date and formatted title
|
||||
- Save the file as `{id}-{slug}.md` in `.agents/scratches/`
|
||||
## Context
|
||||
|
||||
## Creating the File
|
||||
|
||||
### Option 1: Direct Content
|
||||
|
||||
If you have the content ready, run:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/create-scratch.ts "$ARGUMENTS" "Your scratch content here"
|
||||
```
|
||||
|
||||
### Option 2: Multi-line Content (Heredoc)
|
||||
|
||||
For multi-line content, use heredoc:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/create-scratch.ts "$ARGUMENTS" << HEREDOC
|
||||
Your multi-line
|
||||
scratch content
|
||||
goes here
|
||||
HEREDOC
|
||||
```
|
||||
|
||||
### Option 3: Pipe Content
|
||||
|
||||
You can also pipe content:
|
||||
|
||||
```bash
|
||||
echo "Your content" | npx tsx scripts/create-scratch.ts "$ARGUMENTS"
|
||||
```
|
||||
|
||||
## File Format
|
||||
|
||||
The created file will have:
|
||||
|
||||
```markdown
|
||||
---
|
||||
date: 2026-01-13
|
||||
title: Scratch Title
|
||||
---
|
||||
|
||||
Your content here
|
||||
```
|
||||
|
||||
The title is automatically formatted from the slug (e.g., `quick-notes` → `Quick Notes`).
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Use descriptive slugs in kebab-case (e.g., `exploration-ideas`, `temporary-notes`)
|
||||
- Scratch files are for temporary notes, explorations, or ideas
|
||||
- The unique ID ensures no filename conflicts
|
||||
- Files are automatically dated for organization
|
||||
The scratch slug and optional content: `$ARGUMENTS`
|
||||
|
||||
## Begin
|
||||
|
||||
Create a scratch file using the slug from `$ARGUMENTS` and appropriate content for notes or exploration.
|
||||
1. **Read the skill** at `.agents/skills/create-scratch/SKILL.md`
|
||||
2. **Create the scratch file** using the slug from `$ARGUMENTS` and appropriate content for notes or exploration
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
---
|
||||
description: Generate MDX documentation for a module or feature
|
||||
argument-hint: <module-path-or-feature>
|
||||
---
|
||||
|
||||
You are creating proper MDX documentation for a module or feature in Documenso using Nextra.
|
||||
|
||||
## Your Task
|
||||
|
||||
1. **Identify the scope** - What does `$ARGUMENTS` refer to? (file, directory, or feature name)
|
||||
2. **Read the source code** - Understand the public API, types, and behavior
|
||||
3. **Read existing docs** - Check if there's documentation to update or reference
|
||||
4. **Write comprehensive documentation** - Create or update MDX docs in the appropriate location
|
||||
5. **Update navigation** - Add entry to `_meta.js` if creating a new page
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
Create documentation in the appropriate location:
|
||||
|
||||
- **Developer docs**: `apps/documentation/pages/developers/`
|
||||
- **User docs**: `apps/documentation/pages/users/`
|
||||
|
||||
### File Format
|
||||
|
||||
All documentation files must be `.mdx` files with frontmatter:
|
||||
|
||||
```mdx
|
||||
---
|
||||
title: Page Title
|
||||
description: Brief description for SEO and meta tags
|
||||
---
|
||||
|
||||
# Page Title
|
||||
|
||||
Content starts here...
|
||||
```
|
||||
|
||||
### Navigation
|
||||
|
||||
Each directory should have a `_meta.js` file that defines the navigation structure:
|
||||
|
||||
```javascript
|
||||
export default {
|
||||
index: 'Introduction',
|
||||
'feature-name': 'Feature Name',
|
||||
'another-feature': 'Another Feature',
|
||||
};
|
||||
```
|
||||
|
||||
If creating a new page, add it to the appropriate `_meta.js` file.
|
||||
|
||||
### Documentation Format
|
||||
|
||||
````mdx
|
||||
---
|
||||
title: <Module|Feature Name>
|
||||
description: Brief description of what this does and when to use it
|
||||
---
|
||||
|
||||
# <Module|Feature Name>
|
||||
|
||||
Brief description of what this module/feature does and when to use it.
|
||||
|
||||
## Installation
|
||||
|
||||
If there are specific packages or imports needed:
|
||||
|
||||
```bash
|
||||
npm install @documenso/package-name
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```jsx
|
||||
// Minimal working example
|
||||
import { Component } from '@documenso/package';
|
||||
|
||||
const Example = () => {
|
||||
return <Component />;
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Component/Function Name
|
||||
|
||||
Description of what it does.
|
||||
|
||||
#### Props/Parameters
|
||||
|
||||
| Prop/Param | Type | Description |
|
||||
| ---------- | -------------------- | ------------------------- |
|
||||
| prop | `string` | Description of the prop |
|
||||
| optional | `boolean` (optional) | Optional prop description |
|
||||
|
||||
#### Example
|
||||
|
||||
```jsx
|
||||
import { Component } from '@documenso/package';
|
||||
|
||||
<Component prop="value" optional={true} />;
|
||||
```
|
||||
|
||||
### Types
|
||||
|
||||
#### `TypeName`
|
||||
|
||||
```typescript
|
||||
type TypeName = {
|
||||
property: string;
|
||||
optional?: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Common Use Case
|
||||
|
||||
```jsx
|
||||
// Full working example
|
||||
```
|
||||
|
||||
### Advanced Usage
|
||||
|
||||
```jsx
|
||||
// More complex example
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [Link to related documentation](/developers/path)
|
||||
- [Another related page](/users/path)
|
||||
````
|
||||
|
||||
## Guidelines
|
||||
|
||||
### Content Quality
|
||||
|
||||
- **Be accurate** - Verify behavior by reading the code
|
||||
- **Be complete** - Document all public API surface
|
||||
- **Be practical** - Include real, working examples
|
||||
- **Be concise** - Don't over-explain obvious things
|
||||
- **Be user-focused** - Write for the target audience (developers or users)
|
||||
|
||||
### Code Examples
|
||||
|
||||
- Use appropriate language tags: `jsx`, `tsx`, `typescript`, `bash`, `json`
|
||||
- Show imports when not obvious
|
||||
- Include expected output in comments where helpful
|
||||
- Progress from simple to complex
|
||||
- Use real examples from the codebase when possible
|
||||
|
||||
### Formatting
|
||||
|
||||
- Always include frontmatter with `title` and `description`
|
||||
- Use proper markdown headers (h1 for title, h2 for sections)
|
||||
- Use tables for props/parameters documentation (matching existing style)
|
||||
- Use code fences with appropriate language tags
|
||||
- Use Nextra components when appropriate:
|
||||
- `<Callout type="info">` for notes
|
||||
- `<Steps>` for step-by-step instructions
|
||||
- Use relative links for internal documentation (e.g., `/developers/embedding/react`)
|
||||
|
||||
### Nextra Components
|
||||
|
||||
You can import and use Nextra components:
|
||||
|
||||
```jsx
|
||||
import { Callout, Steps } from 'nextra/components';
|
||||
|
||||
<Callout type="info">
|
||||
This is an informational note.
|
||||
</Callout>
|
||||
|
||||
<Steps>
|
||||
<Steps.Step>First step</Steps.Step>
|
||||
<Steps.Step>Second step</Steps.Step>
|
||||
</Steps>
|
||||
```
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Include types inline so docs don't get stale
|
||||
- Reference source file locations for complex behavior
|
||||
- Keep examples up-to-date with the codebase
|
||||
- Update `_meta.js` when adding new pages
|
||||
|
||||
## Process
|
||||
|
||||
1. **Explore the code** - Read source files to understand the API
|
||||
2. **Identify the audience** - Is this for developers or users?
|
||||
3. **Check existing docs** - Look for similar pages to match style
|
||||
4. **Draft the structure** - Outline sections before writing
|
||||
5. **Write content** - Fill in each section with frontmatter
|
||||
6. **Add examples** - Create working code samples
|
||||
7. **Update navigation** - Add to `_meta.js` if needed
|
||||
8. **Review** - Read through for clarity and accuracy
|
||||
|
||||
## Begin
|
||||
|
||||
Analyze `$ARGUMENTS`, read the relevant source code, check existing documentation patterns, and create comprehensive MDX documentation following the Documenso documentation style.
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/agent-browser
|
||||
@@ -43,7 +43,7 @@ ISO 27001 is an international standard for managing information security, specif
|
||||
|
||||
## HIPAA
|
||||
|
||||
<Callout type="warn">Status: [Planned](https://github.com/documenso/backlog/issues/25)</Callout>
|
||||
<Callout type="info">Status: [Compliant](https://documen.so/trust)</Callout>
|
||||
|
||||
The HIPAA (Health Insurance Portability and Accountability Act) is a U.S. law designed to protect patient health information's privacy and security and improve the healthcare system's efficiency and effectiveness.
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: Authoring
|
||||
description: Embed document, template, and envelope creation directly in your application.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
In addition to embedding signing, Documenso supports embedded authoring. It allows your users to create and edit documents, templates, and envelopes without leaving your application.
|
||||
|
||||
<Callout type="warn">
|
||||
Embedded authoring is included with [Enterprise](https://documen.so/enterprise-cta) plans. It is
|
||||
also available as a paid add-on for the [Platform Plan](https://documen.so/platform-cta-pricing).
|
||||
Contact sales for access.
|
||||
</Callout>
|
||||
|
||||
## Versions
|
||||
|
||||
Embedded authoring is available in two versions:
|
||||
|
||||
- **[V1 Authoring](/docs/developers/embedding/authoring/v1)** — Works with V1 Documents and Templates.
|
||||
- **[V2 Authoring](/docs/developers/embedding/authoring/v2)** — Works with Envelopes, which are the unified model for documents and templates.
|
||||
|
||||
### Comparison
|
||||
|
||||
| Aspect | V1 | V2 |
|
||||
| --- | --- | --- |
|
||||
| Entity model | Documents and Templates (separate) | Envelopes (unified, can be documents or templates) |
|
||||
| API compatibility | V1 Documents/Templates API | V2 Envelopes API |
|
||||
| Customization | 6 simple boolean flags | Rich structured settings with sections (general, settings, actions, envelope items, recipients) |
|
||||
|
||||
---
|
||||
|
||||
## Presign Tokens
|
||||
|
||||
Before using any authoring component, obtain a presign token from your backend:
|
||||
|
||||
```
|
||||
POST /api/v2/embedding/create-presign-token
|
||||
```
|
||||
|
||||
This endpoint requires your Documenso API key. The token has a default expiration of 1 hour.
|
||||
|
||||
See the [API documentation](https://openapi.documenso.com/reference#tag/embedding) for full details.
|
||||
|
||||
<Callout type="warn">
|
||||
Presign tokens should be created server-side. Never expose your API key in client-side code.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [V1 Authoring](/docs/developers/embedding/authoring/v1) — Create and edit documents and templates using V1 components
|
||||
- [V2 Authoring](/docs/developers/embedding/authoring/v2) — Create and edit envelopes using V2 components
|
||||
- [CSS Variables](/docs/developers/embedding/css-variables) — Customize the appearance of embedded components
|
||||
- [SDKs](/docs/developers/embedding/sdks) — Framework-specific SDK documentation
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Authoring",
|
||||
"pages": ["v1", "v2"]
|
||||
}
|
||||
+8
-15
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: Authoring
|
||||
description: Embed document and template creation directly in your application.
|
||||
title: V1 Authoring
|
||||
description: Embed V1 document and template creation directly in your application.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
In addition to embedding signing, Documenso supports embedded authoring. It allows your users to create and edit documents and templates without leaving your application.
|
||||
V1 authoring components allow your users to create and edit documents and templates using the V1 Documents and Templates API without leaving your application.
|
||||
|
||||
<Callout type="warn">
|
||||
Embedded authoring is included with [Enterprise](https://documen.so/enterprise-cta) plans. It is
|
||||
@@ -15,7 +15,7 @@ In addition to embedding signing, Documenso supports embedded authoring. It allo
|
||||
|
||||
## Components
|
||||
|
||||
The SDK provides four authoring components:
|
||||
The SDK provides four V1 authoring components:
|
||||
|
||||
| Component | Purpose |
|
||||
| ----------------------- | ----------------------- |
|
||||
@@ -24,24 +24,16 @@ The SDK provides four authoring components:
|
||||
| `EmbedUpdateDocumentV1` | Edit existing documents |
|
||||
| `EmbedUpdateTemplateV1` | Edit existing templates |
|
||||
|
||||
All authoring components require a **presign token** for authentication instead of a regular token.
|
||||
|
||||
---
|
||||
|
||||
## Presign Tokens
|
||||
|
||||
Before using any authoring component, obtain a presign token from your backend:
|
||||
All authoring components require a **presign token** for authentication. See the [Authoring overview](/docs/developers/embedding/authoring) for details on obtaining presign tokens.
|
||||
|
||||
```
|
||||
POST /api/v2/embedding/create-presign-token
|
||||
```
|
||||
|
||||
This endpoint requires your Documenso API key. The token has a default expiration of 1 hour.
|
||||
|
||||
See the [API documentation](https://openapi.documenso.com/reference#tag/embedding) for full details.
|
||||
|
||||
<Callout type="warn">
|
||||
Presign tokens should be created server-side. Never expose your API key in client-side code.
|
||||
A presigned token is NOT an API token
|
||||
</Callout>
|
||||
|
||||
---
|
||||
@@ -147,7 +139,7 @@ const TemplateEditor = ({ presignToken, templateId }) => {
|
||||
| `externalId` | `string` | No | Your reference ID to link with the document or template |
|
||||
| `host` | `string` | No | Custom host URL. Defaults to `https://app.documenso.com` |
|
||||
| `css` | `string` | No | Custom CSS string (Platform Plan) |
|
||||
| `cssVars` | `object` | No | CSS variable overrides (Platform Plan) |
|
||||
| `cssVars` | `object` | No | [CSS variable](/docs/developers/embedding/css-variables) overrides (Platform Plan) |
|
||||
| `darkModeDisabled` | `boolean` | No | Disable dark mode (Platform Plan) |
|
||||
| `className` | `string` | No | CSS class for the iframe |
|
||||
| `features` | `object` | No | Feature toggles for the authoring experience |
|
||||
@@ -301,6 +293,7 @@ Pass extra props to the iframe for testing experimental features:
|
||||
## See Also
|
||||
|
||||
- [Embedding Overview](/docs/developers/embedding) - Signing embed concepts and props
|
||||
- [V2 Authoring](/docs/developers/embedding/authoring/v2) - V2 envelope authoring
|
||||
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
|
||||
- [Documents API](/docs/developers/api/documents) - Create documents via API
|
||||
- [Templates API](/docs/developers/api/templates) - Create templates via API
|
||||
@@ -0,0 +1,341 @@
|
||||
---
|
||||
title: V2 Authoring
|
||||
description: Embed envelope creation and editing directly in your application.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
V2 authoring components allow your users to create and edit envelopes without leaving your application. Envelopes are the unified model for documents and templates in the V2 API.
|
||||
|
||||
<Callout type="warn">
|
||||
Embedded authoring is included with [Enterprise](https://documen.so/enterprise-cta) plans. It is
|
||||
also available as a paid add-on for the [Platform Plan](https://documen.so/platform-cta-pricing).
|
||||
Contact sales for access.
|
||||
</Callout>
|
||||
|
||||
## Components
|
||||
|
||||
The SDK provides two V2 authoring components:
|
||||
|
||||
| Component | Purpose |
|
||||
| ---------------------- | ------------------------ |
|
||||
| `EmbedCreateEnvelope` | Create new envelopes |
|
||||
| `EmbedUpdateEnvelope` | Edit existing envelopes |
|
||||
|
||||
---
|
||||
|
||||
## Presign Tokens
|
||||
|
||||
All authoring components require a **presign token** for authentication. See the [Authoring overview](/docs/developers/embedding/authoring) for details on obtaining presign tokens.
|
||||
|
||||
<Callout type="warn">
|
||||
A presigned token is NOT an API token
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Creating Envelopes
|
||||
|
||||
Use `EmbedCreateEnvelope` to embed envelope creation. The `type` prop determines whether the envelope is created as a document or template.
|
||||
|
||||
```jsx
|
||||
import { EmbedCreateEnvelope } from '@documenso/embed-react';
|
||||
|
||||
const EnvelopeCreator = ({ presignToken }) => {
|
||||
return (
|
||||
<div style={{ height: '800px', width: '100%' }}>
|
||||
<EmbedCreateEnvelope
|
||||
presignToken={presignToken}
|
||||
type="DOCUMENT"
|
||||
externalId="order-12345"
|
||||
onEnvelopeCreated={(data) => {
|
||||
console.log('Envelope created:', data.envelopeId);
|
||||
console.log('External ID:', data.externalId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
To create a template instead of a document, set `type` to `"TEMPLATE"`:
|
||||
|
||||
```jsx
|
||||
<EmbedCreateEnvelope
|
||||
presignToken={presignToken}
|
||||
type="TEMPLATE"
|
||||
externalId="template-12345"
|
||||
onEnvelopeCreated={(data) => {
|
||||
console.log('Template envelope created:', data.envelopeId);
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Editing Envelopes
|
||||
|
||||
Use `EmbedUpdateEnvelope` to embed envelope editing:
|
||||
|
||||
```jsx
|
||||
import { EmbedUpdateEnvelope } from '@documenso/embed-react';
|
||||
|
||||
const EnvelopeEditor = ({ presignToken, envelopeId }) => {
|
||||
return (
|
||||
<div style={{ height: '800px', width: '100%' }}>
|
||||
<EmbedUpdateEnvelope
|
||||
presignToken={presignToken}
|
||||
envelopeId={envelopeId}
|
||||
externalId="order-12345"
|
||||
onEnvelopeUpdated={(data) => {
|
||||
console.log('Envelope updated:', data.envelopeId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Props
|
||||
|
||||
### All V2 Authoring Components
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
| ------------------ | --------- | -------- | -------------------------------------------------------- |
|
||||
| `presignToken` | `string` | Yes | Authentication token from your backend |
|
||||
| `externalId` | `string` | No | Your reference ID to link with the envelope |
|
||||
| `host` | `string` | No | Custom host URL. Defaults to `https://app.documenso.com` |
|
||||
| `css` | `string` | No | Custom CSS string (Platform Plan) |
|
||||
| `cssVars` | `object` | No | [CSS variable](/docs/developers/embedding/css-variables) overrides (Platform Plan) |
|
||||
| `darkModeDisabled` | `boolean` | No | Disable dark mode (Platform Plan) |
|
||||
| `className` | `string` | No | CSS class for the iframe |
|
||||
| `features` | `object` | No | Feature toggles for the authoring experience |
|
||||
|
||||
### Create Component Only
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
| ---------- | ------------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `type` | `"DOCUMENT"` \| `"TEMPLATE"` | Yes | Whether to create a document or template envelope |
|
||||
| `folderId` | `string` | No | The ID of the folder to create the envelope in. If not provided, the envelope is created in the root folder. The folder must match the envelope type and team. |
|
||||
|
||||
### Update Component Only
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
| ------------ | -------- | -------- | ---------------------------- |
|
||||
| `envelopeId` | `string` | Yes | The envelope ID to edit |
|
||||
|
||||
---
|
||||
|
||||
## Feature Toggles
|
||||
|
||||
V2 authoring provides rich, structured feature toggles organized into sections. Pass a partial configuration to customize the authoring experience — any omitted fields will use their defaults.
|
||||
|
||||
```jsx
|
||||
<EmbedCreateEnvelope
|
||||
presignToken={presignToken}
|
||||
type="DOCUMENT"
|
||||
features={{
|
||||
general: {
|
||||
allowConfigureEnvelopeTitle: false,
|
||||
allowUploadAndRecipientStep: false,
|
||||
allowAddFieldsStep: false,
|
||||
allowPreviewStep: false,
|
||||
},
|
||||
settings: {
|
||||
allowConfigureSignatureTypes: false,
|
||||
allowConfigureLanguage: false,
|
||||
allowConfigureDateFormat: false,
|
||||
},
|
||||
recipients: {
|
||||
allowApproverRole: false,
|
||||
allowViewerRole: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### General
|
||||
|
||||
Controls the overall authoring flow and UI:
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
| ------------------------------- | --------- | ------- | ------------------------------------------------ |
|
||||
| `allowConfigureEnvelopeTitle` | `boolean` | `true` | Allow editing the envelope title |
|
||||
| `allowUploadAndRecipientStep` | `boolean` | `true` | Show the upload and recipient configuration step |
|
||||
| `allowAddFieldsStep` | `boolean` | `true` | Show the add fields step |
|
||||
| `allowPreviewStep` | `boolean` | `true` | Show the preview step |
|
||||
| `minimizeLeftSidebar` | `boolean` | `true` | Minimize the left sidebar by default |
|
||||
|
||||
### Settings
|
||||
|
||||
Controls envelope configuration options. Set to `null` to hide envelope settings entirely.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
| ----------------------------------- | --------- | ------- | ----------------------------------------- |
|
||||
| `allowConfigureSignatureTypes` | `boolean` | `true` | Allow configuring signature types |
|
||||
| `allowConfigureLanguage` | `boolean` | `true` | Allow configuring the language |
|
||||
| `allowConfigureDateFormat` | `boolean` | `true` | Allow configuring the date format |
|
||||
| `allowConfigureTimezone` | `boolean` | `true` | Allow configuring the timezone |
|
||||
| `allowConfigureRedirectUrl` | `boolean` | `true` | Allow configuring a redirect URL |
|
||||
| `allowConfigureDistribution` | `boolean` | `true` | Allow configuring distribution settings |
|
||||
| `allowConfigureExpirationPeriod` | `boolean` | `true` | Allow configuring the expiration period |
|
||||
| `allowConfigureEmailSender` | `boolean` | `true` | Allow configuring the email sender |
|
||||
| `allowConfigureEmailReplyTo` | `boolean` | `true` | Allow configuring the email reply-to |
|
||||
|
||||
### Actions
|
||||
|
||||
Controls available actions during authoring:
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
| ------------------ | --------- | ------- | ------------------------ |
|
||||
| `allowAttachments` | `boolean` | `true` | Allow adding attachments |
|
||||
|
||||
### Envelope Items
|
||||
|
||||
Controls how envelope items (individual files within the envelope) can be managed. Set to `null` to prevent any item modifications.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
| --------------------- | --------- | ------- | ------------------------------------ |
|
||||
| `allowConfigureTitle` | `boolean` | `true` | Allow editing item titles |
|
||||
| `allowConfigureOrder` | `boolean` | `true` | Allow reordering items |
|
||||
| `allowUpload` | `boolean` | `true` | Allow uploading new items |
|
||||
| `allowDelete` | `boolean` | `true` | Allow deleting items |
|
||||
|
||||
### Recipients
|
||||
|
||||
Controls recipient configuration options. Set to `null` to prevent any recipient modifications.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
| --------------------------------- | --------- | ------- | ---------------------------------------- |
|
||||
| `allowConfigureSigningOrder` | `boolean` | `true` | Allow configuring the signing order |
|
||||
| `allowConfigureDictateNextSigner` | `boolean` | `true` | Allow configuring dictate next signer |
|
||||
| `allowApproverRole` | `boolean` | `true` | Allow the approver recipient role |
|
||||
| `allowViewerRole` | `boolean` | `true` | Allow the viewer recipient role |
|
||||
| `allowCCerRole` | `boolean` | `true` | Allow the CC recipient role |
|
||||
| `allowAssistantRole` | `boolean` | `true` | Allow the assistant recipient role |
|
||||
|
||||
### Disabling Steps
|
||||
|
||||
You can also disable entire steps of the authoring flow. This allows you to skip steps that are not relevant to your use case:
|
||||
|
||||
```jsx
|
||||
<EmbedCreateEnvelope
|
||||
presignToken={presignToken}
|
||||
type="DOCUMENT"
|
||||
features={{
|
||||
general: {
|
||||
allowUploadAndRecipientStep: false, // Skip the upload and recipient step
|
||||
allowAddFieldsStep: false, // Skip the add fields step
|
||||
allowPreviewStep: false, // Skip the preview step
|
||||
},
|
||||
settings: null, // Hide all envelope settings
|
||||
envelopeItems: null, // Prevent item modifications
|
||||
recipients: null, // Prevent recipient modifications
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event Callbacks
|
||||
|
||||
### `onEnvelopeCreated`
|
||||
|
||||
Fired when an envelope is successfully created:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------ | ---------------- | --------------------------------------- |
|
||||
| `envelopeId` | `string` | The ID of the created envelope |
|
||||
| `externalId` | `string \| null` | Your external reference ID, if provided |
|
||||
|
||||
### `onEnvelopeUpdated`
|
||||
|
||||
Fired when an envelope is successfully updated:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------ | ---------------- | --------------------------------------- |
|
||||
| `envelopeId` | `string` | The ID of the updated envelope |
|
||||
| `externalId` | `string \| null` | Your external reference ID, if provided |
|
||||
|
||||
---
|
||||
|
||||
## Complete Integration Example
|
||||
|
||||
This example shows a full workflow where users create an envelope and then edit it:
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
|
||||
import { EmbedCreateEnvelope, EmbedUpdateEnvelope } from '@documenso/embed-react';
|
||||
|
||||
const EnvelopeManager = ({ presignToken }) => {
|
||||
const [envelopeId, setEnvelopeId] = useState(null);
|
||||
const [mode, setMode] = useState('create');
|
||||
|
||||
if (mode === 'success') {
|
||||
return (
|
||||
<div>
|
||||
<h2>Envelope updated successfully</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEnvelopeId(null);
|
||||
setMode('create');
|
||||
}}
|
||||
>
|
||||
Create Another Envelope
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === 'edit' && envelopeId) {
|
||||
return (
|
||||
<div style={{ height: '800px', width: '100%' }}>
|
||||
<button onClick={() => setMode('create')}>Back to Create</button>
|
||||
<EmbedUpdateEnvelope
|
||||
presignToken={presignToken}
|
||||
envelopeId={envelopeId}
|
||||
onEnvelopeUpdated={(data) => {
|
||||
console.log('Envelope updated:', data.envelopeId);
|
||||
setMode('success');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '800px', width: '100%' }}>
|
||||
<EmbedCreateEnvelope
|
||||
presignToken={presignToken}
|
||||
type="DOCUMENT"
|
||||
features={{
|
||||
general: {
|
||||
allowConfigureEnvelopeTitle: false,
|
||||
},
|
||||
settings: {
|
||||
allowConfigureSignatureTypes: false,
|
||||
allowConfigureLanguage: false,
|
||||
},
|
||||
}}
|
||||
onEnvelopeCreated={(data) => {
|
||||
console.log('Envelope created:', data.envelopeId);
|
||||
setEnvelopeId(data.envelopeId);
|
||||
setMode('edit');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Authoring Overview](/docs/developers/embedding/authoring) - V1 vs V2 comparison and presign tokens
|
||||
- [V1 Authoring](/docs/developers/embedding/authoring/v1) - V1 document and template authoring
|
||||
- [Embedding Overview](/docs/developers/embedding) - Signing embed concepts and props
|
||||
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
|
||||
@@ -59,6 +59,7 @@ Use the `cssVars` prop on any embed component to override default colors, spacin
|
||||
| `destructiveForeground` | Destructive/danger text color |
|
||||
| `ring` | Focus ring color |
|
||||
| `warning` | Warning/alert color |
|
||||
| `envelopeEditorBackground` | Envelope editor background color. _V2 Envelope Editor only._ |
|
||||
|
||||
### Spacing
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ All webhook events share a common structure:
|
||||
{
|
||||
"event": "DOCUMENT_COMPLETED",
|
||||
"payload": {
|
||||
// Document data with recipients
|
||||
// Document or template data with recipients
|
||||
},
|
||||
"createdAt": "2024-04-22T11:52:18.277Z",
|
||||
"webhookEndpoint": "https://your-endpoint.com/webhook"
|
||||
@@ -33,14 +33,13 @@ All webhook events share a common structure:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------------- | --------- | ------------------------------------------------------ |
|
||||
| `id` | number | Document ID |
|
||||
| `id` | number | Document or template ID |
|
||||
| `externalId` | string? | External identifier for integration |
|
||||
| `userId` | number | Owner's user ID |
|
||||
| `authOptions` | object? | Document-level authentication options |
|
||||
| `formValues` | object? | PDF form values associated with the document |
|
||||
| `title` | string | Document title |
|
||||
| `title` | string | Document or template title |
|
||||
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED` |
|
||||
| `documentDataId` | string | Reference to the document's PDF data |
|
||||
| `visibility` | string | Document visibility setting |
|
||||
| `createdAt` | datetime | Document creation timestamp |
|
||||
| `updatedAt` | datetime | Last modification timestamp |
|
||||
@@ -50,45 +49,50 @@ All webhook events share a common structure:
|
||||
| `templateId` | number? | Template ID if created from a template |
|
||||
| `source` | string | Source: `DOCUMENT` or `TEMPLATE` |
|
||||
| `documentMeta` | object | Document metadata (subject, message, signing options) |
|
||||
| `Recipient` | array | List of recipient objects |
|
||||
| `recipients` | array | List of recipient objects |
|
||||
| `Recipient` | array | List of recipient objects (legacy, same as recipients) |
|
||||
|
||||
### Document Metadata Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------------------- | ------- | --------------------------------------- |
|
||||
| `id` | string | Metadata record identifier |
|
||||
| `subject` | string? | Email subject line |
|
||||
| `message` | string? | Email message body |
|
||||
| `timezone` | string | Timezone for date display |
|
||||
| `password` | string? | Document access password (if set) |
|
||||
| `dateFormat` | string | Date format string |
|
||||
| `redirectUrl` | string? | URL to redirect after signing |
|
||||
| `signingOrder` | string | `PARALLEL` or `SEQUENTIAL` |
|
||||
| `typedSignatureEnabled` | boolean | Whether typed signatures are allowed |
|
||||
| `language` | string | Document language code |
|
||||
| `distributionMethod` | string | How document is distributed |
|
||||
| `emailSettings` | object? | Custom email settings for this document |
|
||||
| Field | Type | Description |
|
||||
| -------------------------- | ------- | --------------------------------------- |
|
||||
| `id` | string | Metadata record identifier |
|
||||
| `subject` | string? | Email subject line |
|
||||
| `message` | string? | Email message body |
|
||||
| `timezone` | string | Timezone for date display |
|
||||
| `password` | string? | Document access password (if set) |
|
||||
| `dateFormat` | string | Date format string |
|
||||
| `redirectUrl` | string? | URL to redirect after signing |
|
||||
| `signingOrder` | string | `PARALLEL` or `SEQUENTIAL` |
|
||||
| `allowDictateNextSigner` | boolean | Whether signers can choose the next signer |
|
||||
| `typedSignatureEnabled` | boolean | Whether typed signatures are allowed |
|
||||
| `uploadSignatureEnabled` | boolean | Whether uploaded signatures are allowed |
|
||||
| `drawSignatureEnabled` | boolean | Whether drawn signatures are allowed |
|
||||
| `language` | string | Document language code |
|
||||
| `distributionMethod` | string | How document is distributed |
|
||||
| `emailSettings` | object? | Custom email settings for this document |
|
||||
|
||||
### Recipient Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------------- | --------- | ------------------------------------------ |
|
||||
| `id` | number | Recipient ID |
|
||||
| `documentId` | number | Parent document ID |
|
||||
| `templateId` | number? | Template ID if created from a template |
|
||||
| `email` | string | Recipient email address |
|
||||
| `name` | string | Recipient name |
|
||||
| `token` | string | Unique signing token |
|
||||
| `documentDeletedAt` | datetime? | When the document was deleted (if deleted) |
|
||||
| `expired` | boolean? | Whether the recipient's link has expired |
|
||||
| `signedAt` | datetime? | When recipient signed |
|
||||
| `authOptions` | object? | Per-recipient authentication options |
|
||||
| `role` | string | Role: `SIGNER`, `VIEWER`, `APPROVER`, `CC` |
|
||||
| `signingOrder` | number? | Position in signing sequence |
|
||||
| `readStatus` | string | `NOT_OPENED` or `OPENED` |
|
||||
| `signingStatus` | string | `NOT_SIGNED`, `SIGNED`, or `REJECTED` |
|
||||
| `sendStatus` | string | `NOT_SENT` or `SENT` |
|
||||
| `rejectionReason` | string? | Reason if recipient rejected |
|
||||
| Field | Type | Description |
|
||||
| ---------------------- | --------- | ------------------------------------------ |
|
||||
| `id` | number | Recipient ID |
|
||||
| `documentId` | number? | Parent document ID |
|
||||
| `templateId` | number? | Template ID if created from a template |
|
||||
| `email` | string | Recipient email address |
|
||||
| `name` | string | Recipient name |
|
||||
| `token` | string | Unique signing token |
|
||||
| `documentDeletedAt` | datetime? | When the recipient hid the document |
|
||||
| `expiresAt` | datetime? | When the recipient's signing link expires |
|
||||
| `expirationNotifiedAt` | datetime? | When the expiration notification was sent |
|
||||
| `signedAt` | datetime? | When recipient signed |
|
||||
| `authOptions` | object? | Per-recipient authentication options |
|
||||
| `role` | string | Role: `SIGNER`, `VIEWER`, `APPROVER`, `ASSISTANT`, `CC` |
|
||||
| `signingOrder` | number? | Position in signing sequence |
|
||||
| `readStatus` | string | `NOT_OPENED` or `OPENED` |
|
||||
| `signingStatus` | string | `NOT_SIGNED`, `SIGNED`, or `REJECTED` |
|
||||
| `sendStatus` | string | `NOT_SENT` or `SENT` |
|
||||
| `rejectionReason` | string? | Reason if recipient rejected |
|
||||
|
||||
---
|
||||
|
||||
@@ -98,7 +102,7 @@ These events track the document through its lifecycle.
|
||||
|
||||
### `document.created`
|
||||
|
||||
Triggered when a new document is uploaded.
|
||||
Triggered when a new document is created.
|
||||
|
||||
**Event name:** `DOCUMENT_CREATED`
|
||||
|
||||
@@ -114,7 +118,6 @@ Triggered when a new document is uploaded.
|
||||
"visibility": "EVERYONE",
|
||||
"title": "contract.pdf",
|
||||
"status": "DRAFT",
|
||||
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
|
||||
"createdAt": "2024-04-22T11:44:43.341Z",
|
||||
"updatedAt": "2024-04-22T11:44:43.341Z",
|
||||
"completedAt": null,
|
||||
@@ -131,11 +134,35 @@ Triggered when a new document is uploaded.
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"redirectUrl": null,
|
||||
"signingOrder": "PARALLEL",
|
||||
"allowDictateNextSigner": false,
|
||||
"typedSignatureEnabled": true,
|
||||
"uploadSignatureEnabled": true,
|
||||
"drawSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
},
|
||||
"recipients": [
|
||||
{
|
||||
"id": 52,
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"token": "vbT8hi3jKQmrFP_LN1WcS",
|
||||
"documentDeletedAt": null,
|
||||
"expiresAt": null,
|
||||
"expirationNotifiedAt": null,
|
||||
"signedAt": null,
|
||||
"authOptions": null,
|
||||
"signingOrder": 1,
|
||||
"rejectionReason": null,
|
||||
"role": "SIGNER",
|
||||
"readStatus": "NOT_OPENED",
|
||||
"signingStatus": "NOT_SIGNED",
|
||||
"sendStatus": "NOT_SENT"
|
||||
}
|
||||
],
|
||||
"Recipient": [
|
||||
{
|
||||
"id": 52,
|
||||
@@ -145,7 +172,8 @@ Triggered when a new document is uploaded.
|
||||
"name": "John Doe",
|
||||
"token": "vbT8hi3jKQmrFP_LN1WcS",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"expiresAt": null,
|
||||
"expirationNotifiedAt": null,
|
||||
"signedAt": null,
|
||||
"authOptions": null,
|
||||
"signingOrder": 1,
|
||||
@@ -182,7 +210,6 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
|
||||
"visibility": "EVERYONE",
|
||||
"title": "contract.pdf",
|
||||
"status": "PENDING",
|
||||
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
|
||||
"createdAt": "2024-04-22T11:44:43.341Z",
|
||||
"updatedAt": "2024-04-22T11:48:07.569Z",
|
||||
"completedAt": null,
|
||||
@@ -199,11 +226,35 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"redirectUrl": null,
|
||||
"signingOrder": "PARALLEL",
|
||||
"allowDictateNextSigner": false,
|
||||
"typedSignatureEnabled": true,
|
||||
"uploadSignatureEnabled": true,
|
||||
"drawSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
},
|
||||
"recipients": [
|
||||
{
|
||||
"id": 52,
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"token": "vbT8hi3jKQmrFP_LN1WcS",
|
||||
"documentDeletedAt": null,
|
||||
"expiresAt": null,
|
||||
"expirationNotifiedAt": null,
|
||||
"signedAt": null,
|
||||
"authOptions": null,
|
||||
"signingOrder": 1,
|
||||
"rejectionReason": null,
|
||||
"role": "SIGNER",
|
||||
"readStatus": "NOT_OPENED",
|
||||
"signingStatus": "NOT_SIGNED",
|
||||
"sendStatus": "SENT"
|
||||
}
|
||||
],
|
||||
"Recipient": [
|
||||
{
|
||||
"id": 52,
|
||||
@@ -213,7 +264,8 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
|
||||
"name": "John Doe",
|
||||
"token": "vbT8hi3jKQmrFP_LN1WcS",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"expiresAt": null,
|
||||
"expirationNotifiedAt": null,
|
||||
"signedAt": null,
|
||||
"authOptions": null,
|
||||
"signingOrder": 1,
|
||||
@@ -230,6 +282,106 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
|
||||
}
|
||||
```
|
||||
|
||||
### `document.opened`
|
||||
|
||||
Triggered when a recipient opens the document for the first time.
|
||||
|
||||
**Event name:** `DOCUMENT_OPENED`
|
||||
|
||||
The recipient's `readStatus` changes to `OPENED`.
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "DOCUMENT_OPENED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"status": "PENDING",
|
||||
"title": "contract.pdf",
|
||||
"source": "DOCUMENT",
|
||||
"recipients": [
|
||||
{
|
||||
"id": 52,
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"role": "SIGNER",
|
||||
"readStatus": "OPENED",
|
||||
"signingStatus": "NOT_SIGNED",
|
||||
"sendStatus": "SENT"
|
||||
}
|
||||
]
|
||||
},
|
||||
"createdAt": "2024-04-22T11:50:26.174Z",
|
||||
"webhookEndpoint": "https://your-endpoint.com/webhook"
|
||||
}
|
||||
```
|
||||
|
||||
### `document.signed`
|
||||
|
||||
Triggered when a recipient signs the document. This fires for each individual signature, not just when the document is fully completed.
|
||||
|
||||
**Event name:** `DOCUMENT_SIGNED`
|
||||
|
||||
The recipient's `signingStatus` changes to `SIGNED` and `signedAt` is populated.
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "DOCUMENT_SIGNED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"status": "COMPLETED",
|
||||
"title": "contract.pdf",
|
||||
"source": "DOCUMENT",
|
||||
"completedAt": "2024-04-22T11:52:05.707Z",
|
||||
"recipients": [
|
||||
{
|
||||
"id": 51,
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"role": "SIGNER",
|
||||
"signedAt": "2024-04-22T11:52:05.688Z",
|
||||
"readStatus": "OPENED",
|
||||
"signingStatus": "SIGNED",
|
||||
"sendStatus": "SENT"
|
||||
}
|
||||
]
|
||||
},
|
||||
"createdAt": "2024-04-22T11:52:18.577Z",
|
||||
"webhookEndpoint": "https://your-endpoint.com/webhook"
|
||||
}
|
||||
```
|
||||
|
||||
### `document.recipient.completed`
|
||||
|
||||
Triggered when an individual recipient completes their required action (signing, approving, or viewing). This is useful for tracking per-recipient progress in documents with multiple recipients.
|
||||
|
||||
**Event name:** `DOCUMENT_RECIPIENT_COMPLETED`
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "DOCUMENT_RECIPIENT_COMPLETED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"status": "PENDING",
|
||||
"title": "contract.pdf",
|
||||
"source": "DOCUMENT",
|
||||
"recipients": [
|
||||
{
|
||||
"id": 52,
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"role": "SIGNER",
|
||||
"signedAt": "2024-04-22T11:52:05.688Z",
|
||||
"readStatus": "OPENED",
|
||||
"signingStatus": "SIGNED",
|
||||
"sendStatus": "SENT"
|
||||
}
|
||||
]
|
||||
},
|
||||
"createdAt": "2024-04-22T11:52:06.000Z",
|
||||
"webhookEndpoint": "https://your-endpoint.com/webhook"
|
||||
}
|
||||
```
|
||||
|
||||
### `document.completed`
|
||||
|
||||
Triggered when all recipients have completed their required actions.
|
||||
@@ -250,7 +402,6 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
||||
"visibility": "EVERYONE",
|
||||
"title": "contract.pdf",
|
||||
"status": "COMPLETED",
|
||||
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
|
||||
"createdAt": "2024-04-22T11:44:43.341Z",
|
||||
"updatedAt": "2024-04-22T11:52:05.708Z",
|
||||
"completedAt": "2024-04-22T11:52:05.707Z",
|
||||
@@ -267,12 +418,15 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"redirectUrl": null,
|
||||
"signingOrder": "PARALLEL",
|
||||
"allowDictateNextSigner": false,
|
||||
"typedSignatureEnabled": true,
|
||||
"uploadSignatureEnabled": true,
|
||||
"drawSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
},
|
||||
"Recipient": [
|
||||
"recipients": [
|
||||
{
|
||||
"id": 50,
|
||||
"documentId": 10,
|
||||
@@ -281,7 +435,8 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
||||
"name": "Jane Smith",
|
||||
"token": "vbT8hi3jKQmrFP_LN1WcS",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"expiresAt": null,
|
||||
"expirationNotifiedAt": null,
|
||||
"signedAt": "2024-04-22T11:51:10.055Z",
|
||||
"authOptions": {
|
||||
"accessAuth": null,
|
||||
@@ -302,7 +457,54 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
||||
"name": "John Doe",
|
||||
"token": "HkrptwS42ZBXdRKj1TyUo",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"expiresAt": null,
|
||||
"expirationNotifiedAt": null,
|
||||
"signedAt": "2024-04-22T11:52:05.688Z",
|
||||
"authOptions": {
|
||||
"accessAuth": null,
|
||||
"actionAuth": null
|
||||
},
|
||||
"signingOrder": 2,
|
||||
"rejectionReason": null,
|
||||
"role": "SIGNER",
|
||||
"readStatus": "OPENED",
|
||||
"signingStatus": "SIGNED",
|
||||
"sendStatus": "SENT"
|
||||
}
|
||||
],
|
||||
"Recipient": [
|
||||
{
|
||||
"id": 50,
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "reviewer@example.com",
|
||||
"name": "Jane Smith",
|
||||
"token": "vbT8hi3jKQmrFP_LN1WcS",
|
||||
"documentDeletedAt": null,
|
||||
"expiresAt": null,
|
||||
"expirationNotifiedAt": null,
|
||||
"signedAt": "2024-04-22T11:51:10.055Z",
|
||||
"authOptions": {
|
||||
"accessAuth": null,
|
||||
"actionAuth": null
|
||||
},
|
||||
"signingOrder": 1,
|
||||
"rejectionReason": null,
|
||||
"role": "VIEWER",
|
||||
"readStatus": "OPENED",
|
||||
"signingStatus": "SIGNED",
|
||||
"sendStatus": "SENT"
|
||||
},
|
||||
{
|
||||
"id": 51,
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"token": "HkrptwS42ZBXdRKj1TyUo",
|
||||
"documentDeletedAt": null,
|
||||
"expiresAt": null,
|
||||
"expirationNotifiedAt": null,
|
||||
"signedAt": "2024-04-22T11:52:05.688Z",
|
||||
"authOptions": {
|
||||
"accessAuth": null,
|
||||
@@ -335,53 +537,17 @@ The recipient's `signingStatus` changes to `REJECTED` and `rejectionReason` cont
|
||||
"event": "DOCUMENT_REJECTED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"externalId": null,
|
||||
"userId": 1,
|
||||
"authOptions": null,
|
||||
"formValues": null,
|
||||
"visibility": "EVERYONE",
|
||||
"title": "contract.pdf",
|
||||
"status": "PENDING",
|
||||
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
|
||||
"createdAt": "2024-04-22T11:44:43.341Z",
|
||||
"updatedAt": "2024-04-22T11:48:07.569Z",
|
||||
"completedAt": null,
|
||||
"deletedAt": null,
|
||||
"teamId": null,
|
||||
"templateId": null,
|
||||
"title": "contract.pdf",
|
||||
"source": "DOCUMENT",
|
||||
"documentMeta": {
|
||||
"id": "doc_meta_123",
|
||||
"subject": "Please sign this document",
|
||||
"message": "Hello, please review and sign this document.",
|
||||
"timezone": "UTC",
|
||||
"password": null,
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"redirectUrl": null,
|
||||
"signingOrder": "PARALLEL",
|
||||
"typedSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
},
|
||||
"Recipient": [
|
||||
"recipients": [
|
||||
{
|
||||
"id": 52,
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"token": "vbT8hi3jKQmrFP_LN1WcS",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"signedAt": "2024-04-22T11:48:07.569Z",
|
||||
"authOptions": {
|
||||
"accessAuth": null,
|
||||
"actionAuth": null
|
||||
},
|
||||
"signingOrder": 1,
|
||||
"rejectionReason": "I do not agree with the terms",
|
||||
"role": "SIGNER",
|
||||
"signedAt": "2024-04-22T11:48:07.569Z",
|
||||
"rejectionReason": "I do not agree with the terms",
|
||||
"readStatus": "OPENED",
|
||||
"signingStatus": "REJECTED",
|
||||
"sendStatus": "SENT"
|
||||
@@ -395,7 +561,9 @@ The recipient's `signingStatus` changes to `REJECTED` and `rejectionReason` cont
|
||||
|
||||
### `document.cancelled`
|
||||
|
||||
Triggered when the document owner cancels a pending document.
|
||||
Triggered when the document owner or a team member deletes a document. Draft and pending documents are hard-deleted, while completed documents are soft-deleted.
|
||||
|
||||
This event is **not** triggered when a recipient hides a document from their inbox.
|
||||
|
||||
**Event name:** `DOCUMENT_CANCELLED`
|
||||
|
||||
@@ -411,7 +579,6 @@ Triggered when the document owner cancels a pending document.
|
||||
"visibility": "EVERYONE",
|
||||
"title": "contract.pdf",
|
||||
"status": "PENDING",
|
||||
"documentDataId": "cm6exvn93006hi02ru90a265a",
|
||||
"createdAt": "2025-01-27T11:02:14.393Z",
|
||||
"updatedAt": "2025-01-27T11:03:16.387Z",
|
||||
"completedAt": null,
|
||||
@@ -428,11 +595,35 @@ Triggered when the document owner cancels a pending document.
|
||||
"dateFormat": "yyyy-MM-dd hh:mm a",
|
||||
"redirectUrl": "",
|
||||
"signingOrder": "PARALLEL",
|
||||
"allowDictateNextSigner": false,
|
||||
"typedSignatureEnabled": true,
|
||||
"uploadSignatureEnabled": true,
|
||||
"drawSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
},
|
||||
"recipients": [
|
||||
{
|
||||
"id": 7,
|
||||
"documentId": 7,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"token": "XkKx1HCs6Znm2UBJA2j6o",
|
||||
"documentDeletedAt": null,
|
||||
"expiresAt": null,
|
||||
"expirationNotifiedAt": null,
|
||||
"signedAt": null,
|
||||
"authOptions": { "accessAuth": null, "actionAuth": null },
|
||||
"signingOrder": 1,
|
||||
"rejectionReason": null,
|
||||
"role": "SIGNER",
|
||||
"readStatus": "NOT_OPENED",
|
||||
"signingStatus": "NOT_SIGNED",
|
||||
"sendStatus": "SENT"
|
||||
}
|
||||
],
|
||||
"Recipient": [
|
||||
{
|
||||
"id": 7,
|
||||
@@ -442,7 +633,8 @@ Triggered when the document owner cancels a pending document.
|
||||
"name": "John Doe",
|
||||
"token": "XkKx1HCs6Znm2UBJA2j6o",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"expiresAt": null,
|
||||
"expirationNotifiedAt": null,
|
||||
"signedAt": null,
|
||||
"authOptions": { "accessAuth": null, "actionAuth": null },
|
||||
"signingOrder": 1,
|
||||
@@ -459,147 +651,127 @@ Triggered when the document owner cancels a pending document.
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
### `document.reminder.sent`
|
||||
|
||||
## Recipient Events
|
||||
Triggered when a reminder email is sent to a recipient who has not yet completed their action.
|
||||
|
||||
Recipient events track individual signer actions. These events use the same payload structure as document events, but focus on a specific recipient's action.
|
||||
|
||||
### `document.opened`
|
||||
|
||||
Triggered when a recipient opens the document for the first time.
|
||||
|
||||
**Event name:** `DOCUMENT_OPENED`
|
||||
|
||||
The recipient's `readStatus` changes to `OPENED`.
|
||||
**Event name:** `DOCUMENT_REMINDER_SENT`
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "DOCUMENT_OPENED",
|
||||
"event": "DOCUMENT_REMINDER_SENT",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"externalId": null,
|
||||
"userId": 1,
|
||||
"authOptions": null,
|
||||
"formValues": null,
|
||||
"visibility": "EVERYONE",
|
||||
"title": "contract.pdf",
|
||||
"status": "PENDING",
|
||||
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
|
||||
"createdAt": "2024-04-22T11:44:43.341Z",
|
||||
"updatedAt": "2024-04-22T11:48:07.569Z",
|
||||
"completedAt": null,
|
||||
"deletedAt": null,
|
||||
"teamId": null,
|
||||
"templateId": null,
|
||||
"title": "contract.pdf",
|
||||
"source": "DOCUMENT",
|
||||
"documentMeta": {
|
||||
"id": "doc_meta_123",
|
||||
"subject": "Please sign this document",
|
||||
"message": "Hello, please review and sign this document.",
|
||||
"timezone": "UTC",
|
||||
"password": null,
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"redirectUrl": null,
|
||||
"signingOrder": "PARALLEL",
|
||||
"typedSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
},
|
||||
"Recipient": [
|
||||
"recipients": [
|
||||
{
|
||||
"id": 52,
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"token": "vbT8hi3jKQmrFP_LN1WcS",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"signedAt": null,
|
||||
"authOptions": null,
|
||||
"signingOrder": 1,
|
||||
"rejectionReason": null,
|
||||
"role": "SIGNER",
|
||||
"readStatus": "OPENED",
|
||||
"readStatus": "NOT_OPENED",
|
||||
"signingStatus": "NOT_SIGNED",
|
||||
"sendStatus": "SENT"
|
||||
}
|
||||
]
|
||||
},
|
||||
"createdAt": "2024-04-22T11:50:26.174Z",
|
||||
"createdAt": "2024-04-23T09:00:00.000Z",
|
||||
"webhookEndpoint": "https://your-endpoint.com/webhook"
|
||||
}
|
||||
```
|
||||
|
||||
### `document.signed`
|
||||
---
|
||||
|
||||
Triggered when a recipient signs the document.
|
||||
## Template Events
|
||||
|
||||
**Event name:** `DOCUMENT_SIGNED`
|
||||
Template events track changes to reusable document templates. Template payloads use the same structure as document payloads, with `source` set to `TEMPLATE` and `templateId` populated.
|
||||
|
||||
The recipient's `signingStatus` changes to `SIGNED` and `signedAt` is populated.
|
||||
### `template.created`
|
||||
|
||||
Triggered when a new template is created.
|
||||
|
||||
**Event name:** `TEMPLATE_CREATED`
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "DOCUMENT_SIGNED",
|
||||
"event": "TEMPLATE_CREATED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"externalId": null,
|
||||
"userId": 1,
|
||||
"authOptions": null,
|
||||
"formValues": null,
|
||||
"visibility": "EVERYONE",
|
||||
"title": "contract.pdf",
|
||||
"status": "COMPLETED",
|
||||
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
|
||||
"createdAt": "2024-04-22T11:44:43.341Z",
|
||||
"updatedAt": "2024-04-22T11:52:05.708Z",
|
||||
"completedAt": "2024-04-22T11:52:05.707Z",
|
||||
"deletedAt": null,
|
||||
"teamId": null,
|
||||
"templateId": null,
|
||||
"source": "DOCUMENT",
|
||||
"documentMeta": {
|
||||
"id": "doc_meta_123",
|
||||
"subject": "Please sign this document",
|
||||
"message": "Hello, please review and sign this document.",
|
||||
"timezone": "UTC",
|
||||
"password": null,
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"redirectUrl": null,
|
||||
"signingOrder": "PARALLEL",
|
||||
"typedSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
},
|
||||
"Recipient": [
|
||||
{
|
||||
"id": 51,
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"token": "HkrptwS42ZBXdRKj1TyUo",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"signedAt": "2024-04-22T11:52:05.688Z",
|
||||
"authOptions": {
|
||||
"accessAuth": null,
|
||||
"actionAuth": null
|
||||
},
|
||||
"signingOrder": 1,
|
||||
"rejectionReason": null,
|
||||
"role": "SIGNER",
|
||||
"readStatus": "OPENED",
|
||||
"signingStatus": "SIGNED",
|
||||
"sendStatus": "SENT"
|
||||
}
|
||||
]
|
||||
"title": "My Template",
|
||||
"status": "DRAFT",
|
||||
"templateId": 10,
|
||||
"source": "TEMPLATE",
|
||||
"recipients": []
|
||||
},
|
||||
"createdAt": "2024-04-22T11:52:18.577Z",
|
||||
"createdAt": "2024-04-22T11:44:44.779Z",
|
||||
"webhookEndpoint": "https://your-endpoint.com/webhook"
|
||||
}
|
||||
```
|
||||
|
||||
### `template.updated`
|
||||
|
||||
Triggered when a template's settings, recipients, or fields are modified.
|
||||
|
||||
**Event name:** `TEMPLATE_UPDATED`
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "TEMPLATE_UPDATED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"title": "My Updated Template",
|
||||
"status": "DRAFT",
|
||||
"templateId": 10,
|
||||
"source": "TEMPLATE",
|
||||
"recipients": []
|
||||
},
|
||||
"createdAt": "2024-04-22T12:00:00.000Z",
|
||||
"webhookEndpoint": "https://your-endpoint.com/webhook"
|
||||
}
|
||||
```
|
||||
|
||||
### `template.deleted`
|
||||
|
||||
Triggered when a template is deleted.
|
||||
|
||||
**Event name:** `TEMPLATE_DELETED`
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "TEMPLATE_DELETED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"title": "Deleted Template",
|
||||
"status": "DRAFT",
|
||||
"templateId": 10,
|
||||
"source": "TEMPLATE",
|
||||
"recipients": []
|
||||
},
|
||||
"createdAt": "2024-04-22T13:00:00.000Z",
|
||||
"webhookEndpoint": "https://your-endpoint.com/webhook"
|
||||
}
|
||||
```
|
||||
|
||||
### `template.used`
|
||||
|
||||
Triggered when a document is created from a template. This event fires alongside `document.created`, giving you a way to specifically track template usage.
|
||||
|
||||
**Event name:** `TEMPLATE_USED`
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "TEMPLATE_USED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"title": "Document from Template",
|
||||
"status": "DRAFT",
|
||||
"templateId": 10,
|
||||
"source": "TEMPLATE",
|
||||
"recipients": []
|
||||
},
|
||||
"createdAt": "2024-04-22T14:00:00.000Z",
|
||||
"webhookEndpoint": "https://your-endpoint.com/webhook"
|
||||
}
|
||||
```
|
||||
@@ -608,15 +780,28 @@ The recipient's `signingStatus` changes to `SIGNED` and `signedAt` is populated.
|
||||
|
||||
## Event Summary
|
||||
|
||||
| Event | Trigger | Key Changes |
|
||||
| -------------------- | ------------------------------- | ------------------------------------------------------------ |
|
||||
| `DOCUMENT_CREATED` | Document uploaded | `status: "DRAFT"` |
|
||||
| `DOCUMENT_SENT` | Document sent to recipients | `status: "PENDING"`, recipients `sendStatus: "SENT"` |
|
||||
| `DOCUMENT_OPENED` | Recipient opens document | Recipient `readStatus: "OPENED"` |
|
||||
| `DOCUMENT_SIGNED` | Recipient signs document | Recipient `signingStatus: "SIGNED"`, `signedAt` set |
|
||||
| `DOCUMENT_COMPLETED` | All recipients complete actions | `status: "COMPLETED"`, `completedAt` set |
|
||||
| `DOCUMENT_REJECTED` | Recipient rejects document | Recipient `signingStatus: "REJECTED"`, `rejectionReason` set |
|
||||
| `DOCUMENT_CANCELLED` | Owner cancels document | Document cancelled while pending |
|
||||
### Document Events
|
||||
|
||||
| Event | Trigger | Key Changes |
|
||||
| ---------------------------- | ------------------------------------------- | ------------------------------------------------------------ |
|
||||
| `DOCUMENT_CREATED` | Document uploaded or created from template | `status: "DRAFT"` |
|
||||
| `DOCUMENT_SENT` | Document sent to recipients | `status: "PENDING"`, recipients `sendStatus: "SENT"` |
|
||||
| `DOCUMENT_OPENED` | Recipient opens document for the first time | Recipient `readStatus: "OPENED"` |
|
||||
| `DOCUMENT_SIGNED` | Recipient signs document | Recipient `signingStatus: "SIGNED"`, `signedAt` set |
|
||||
| `DOCUMENT_RECIPIENT_COMPLETED` | Recipient completes their action | Recipient `signingStatus: "SIGNED"`, `signedAt` set |
|
||||
| `DOCUMENT_COMPLETED` | All recipients complete actions | `status: "COMPLETED"`, `completedAt` set |
|
||||
| `DOCUMENT_REJECTED` | Recipient rejects document | Recipient `signingStatus: "REJECTED"`, `rejectionReason` set |
|
||||
| `DOCUMENT_CANCELLED` | Owner or team member deletes document | Document cancelled or deleted |
|
||||
| `DOCUMENT_REMINDER_SENT` | Reminder email sent to recipient | No status changes |
|
||||
|
||||
### Template Events
|
||||
|
||||
| Event | Trigger | Key Changes |
|
||||
| ------------------ | ------------------------------------ | ------------------------- |
|
||||
| `TEMPLATE_CREATED` | New template created | `source: "TEMPLATE"` |
|
||||
| `TEMPLATE_UPDATED` | Template settings or fields modified | `source: "TEMPLATE"` |
|
||||
| `TEMPLATE_DELETED` | Template deleted | `source: "TEMPLATE"` |
|
||||
| `TEMPLATE_USED` | Document created from template | `source: "TEMPLATE"` |
|
||||
|
||||
---
|
||||
|
||||
@@ -652,19 +837,22 @@ app.post('/webhook', (req, res) => {
|
||||
|
||||
switch (event) {
|
||||
case 'DOCUMENT_COMPLETED':
|
||||
// Handle completed document
|
||||
console.log(`Document ${payload.id} completed`);
|
||||
break;
|
||||
case 'DOCUMENT_RECIPIENT_COMPLETED':
|
||||
const signer = payload.recipients.find((r) => r.signingStatus === 'SIGNED');
|
||||
console.log(`${signer?.name} completed their action on document ${payload.id}`);
|
||||
break;
|
||||
case 'DOCUMENT_SIGNED':
|
||||
// Handle signature
|
||||
const signer = payload.Recipient.find((r) => r.signingStatus === 'SIGNED');
|
||||
console.log(`${signer?.name} signed document ${payload.id}`);
|
||||
console.log(`Signature added to document ${payload.id}`);
|
||||
break;
|
||||
case 'DOCUMENT_REJECTED':
|
||||
// Handle rejection
|
||||
const rejecter = payload.Recipient.find((r) => r.signingStatus === 'REJECTED');
|
||||
const rejecter = payload.recipients.find((r) => r.signingStatus === 'REJECTED');
|
||||
console.log(`${rejecter?.name} rejected: ${rejecter?.rejectionReason}`);
|
||||
break;
|
||||
case 'TEMPLATE_USED':
|
||||
console.log(`Template ${payload.templateId} used to create document ${payload.id}`);
|
||||
break;
|
||||
}
|
||||
|
||||
res.status(200).send('OK');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Webhooks
|
||||
description: Receive real-time notifications when documents are signed, completed, or updated.
|
||||
description: Receive real-time notifications for document and template events.
|
||||
---
|
||||
|
||||
## How Webhooks Work
|
||||
@@ -9,6 +9,8 @@ description: Receive real-time notifications when documents are signed, complete
|
||||
2. When an event occurs, Documenso sends an HTTP POST to your URL
|
||||
3. Your application processes the event and responds with 200 OK
|
||||
|
||||
Documenso supports webhook events for the full document lifecycle (created, sent, opened, signed, completed, rejected, cancelled) as well as template events (created, updated, deleted, used).
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
@@ -41,7 +43,15 @@ description: Receive real-time notifications when documents are signed, complete
|
||||
"payload": {
|
||||
"id": 123,
|
||||
"title": "Contract",
|
||||
"status": "COMPLETED"
|
||||
"status": "COMPLETED",
|
||||
"completedAt": "2024-01-15T10:30:00.000Z",
|
||||
"recipients": [
|
||||
{
|
||||
"id": 1,
|
||||
"email": "signer@example.com",
|
||||
"signingStatus": "SIGNED"
|
||||
}
|
||||
]
|
||||
},
|
||||
"createdAt": "2024-01-15T10:30:00.000Z",
|
||||
"webhookEndpoint": "https://your-endpoint.com/webhook"
|
||||
|
||||
@@ -220,11 +220,17 @@ When creating a webhook, you can subscribe to one or more events:
|
||||
| ----- | ------- |
|
||||
| `DOCUMENT_CREATED` | A new document is created |
|
||||
| `DOCUMENT_SENT` | A document is sent to recipients |
|
||||
| `DOCUMENT_OPENED` | A recipient opens the document |
|
||||
| `DOCUMENT_OPENED` | A recipient opens the document for the first time |
|
||||
| `DOCUMENT_SIGNED` | A recipient signs the document |
|
||||
| `DOCUMENT_COMPLETED` | All recipients have signed the document |
|
||||
| `DOCUMENT_RECIPIENT_COMPLETED` | A recipient completes their required action |
|
||||
| `DOCUMENT_COMPLETED` | All recipients have completed their actions |
|
||||
| `DOCUMENT_REJECTED` | A recipient rejects the document |
|
||||
| `DOCUMENT_CANCELLED` | The document owner cancels the document |
|
||||
| `DOCUMENT_CANCELLED` | The document owner deletes the document |
|
||||
| `DOCUMENT_REMINDER_SENT` | A reminder email is sent to a recipient |
|
||||
| `TEMPLATE_CREATED` | A new template is created |
|
||||
| `TEMPLATE_UPDATED` | A template is modified |
|
||||
| `TEMPLATE_DELETED` | A template is deleted |
|
||||
| `TEMPLATE_USED` | A document is created from a template |
|
||||
|
||||
You can subscribe to all events or select specific ones based on your needs. For example, if you only need to know when documents are fully signed, subscribe only to `DOCUMENT_COMPLETED`.
|
||||
|
||||
|
||||
@@ -250,9 +250,15 @@ const validEvents = [
|
||||
'DOCUMENT_SENT',
|
||||
'DOCUMENT_OPENED',
|
||||
'DOCUMENT_SIGNED',
|
||||
'DOCUMENT_RECIPIENT_COMPLETED',
|
||||
'DOCUMENT_COMPLETED',
|
||||
'DOCUMENT_REJECTED',
|
||||
'DOCUMENT_CANCELLED',
|
||||
'DOCUMENT_REMINDER_SENT',
|
||||
'TEMPLATE_CREATED',
|
||||
'TEMPLATE_UPDATED',
|
||||
'TEMPLATE_DELETED',
|
||||
'TEMPLATE_USED',
|
||||
];
|
||||
|
||||
if (!validEvents.includes(event)) {
|
||||
|
||||
@@ -224,11 +224,31 @@ For detailed certificate setup, see [Signing Certificate](/docs/self-hosting/con
|
||||
|
||||
## Feature Flags
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ------------------------------------- | ----------------------------------------------- | ------- |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public user registration | `false` |
|
||||
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | |
|
||||
| `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` |
|
||||
| Variable | Description | Default |
|
||||
| ------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public user registration entirely | `false` |
|
||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`) | |
|
||||
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | |
|
||||
| `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` |
|
||||
|
||||
### Signup Restrictions
|
||||
|
||||
You can control who is allowed to create accounts on your instance using two environment variables:
|
||||
|
||||
- **`NEXT_PUBLIC_DISABLE_SIGNUP`**: Set to `true` to block all new signups. Existing users can still sign in. This applies to both email/password and OAuth signups.
|
||||
- **`NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS`**: Restrict signups to specific email domains. When set, only users whose email address matches one of the listed domains can create an account. Leave empty to allow all domains.
|
||||
|
||||
Both restrictions apply to email/password registration and OAuth (Google, Microsoft, OIDC). If a user attempts to sign up via OAuth with a disallowed domain, they are redirected to the sign-in page with an error.
|
||||
|
||||
When both variables are set, `NEXT_PUBLIC_DISABLE_SIGNUP` takes precedence. Signups are blocked regardless of the domain list.
|
||||
|
||||
```bash
|
||||
# Allow signups only from specific domains
|
||||
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org"
|
||||
|
||||
# Or disable signups entirely
|
||||
NEXT_PUBLIC_DISABLE_SIGNUP="true"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -271,6 +291,8 @@ Documenso uses a PostgreSQL-based job queue by default. Jobs (email delivery, do
|
||||
| ----------------------------- | -------------------------------------------- | ------- |
|
||||
| `DOCUMENSO_DISABLE_TELEMETRY` | Set to `true` to disable anonymous telemetry | `false` |
|
||||
|
||||
Telemetry also auto-disables when `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` is configured.
|
||||
|
||||
Telemetry collects only: app version, installation ID, and node ID. No personal data is collected.
|
||||
|
||||
---
|
||||
@@ -326,6 +348,10 @@ NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@example.com"
|
||||
|
||||
# Signing (certificate must be configured)
|
||||
NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password"
|
||||
|
||||
# Signup restrictions (optional)
|
||||
# NEXT_PUBLIC_DISABLE_SIGNUP="true"
|
||||
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -154,8 +154,9 @@ PORT=3000
|
||||
# Signing certificate (see Signing Certificate section)
|
||||
NEXT_PRIVATE_SIGNING_PASSPHRASE=your-certificate-password
|
||||
|
||||
# Disable public signups
|
||||
# Signup restrictions (optional)
|
||||
NEXT_PUBLIC_DISABLE_SIGNUP=false
|
||||
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=example.com,acme.org
|
||||
```
|
||||
|
||||
<Callout type="info">Generate secure secrets using: `openssl rand -base64 32`</Callout>
|
||||
@@ -251,7 +252,8 @@ Navigate to the signup page and create your account. Verify your email address
|
||||
<Callout type="info">
|
||||
All accounts created through signup are regular user accounts. Admin access must be granted
|
||||
directly in the database. Once your accounts are set up, consider disabling public signups by
|
||||
setting `NEXT_PUBLIC_DISABLE_SIGNUP=true`.
|
||||
setting `NEXT_PUBLIC_DISABLE_SIGNUP=true`, or restrict signups to specific email domains with
|
||||
`NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS`.
|
||||
</Callout>
|
||||
|
||||
## Managing Services
|
||||
|
||||
@@ -101,6 +101,7 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | Base64-encoded `.p12` certificate (alternative to file path) | - |
|
||||
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | Document storage: `database` or `s3` | `database` |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public signups | `false` |
|
||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
|
||||
|
||||
For the complete list, see [Environment Variables](/docs/self-hosting/configuration/environment).
|
||||
|
||||
|
||||
@@ -153,8 +153,9 @@ NEXT_PRIVATE_SMTP_FROM_ADDRESS=noreply@yourdomain.com
|
||||
| Variable | Description | Default |
|
||||
| --------------------------------- | ---------------------------------- | ------- |
|
||||
| `PORT` | Application port | `3000` |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public signups | `false` |
|
||||
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for signing certificate | - |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public signups | `false` |
|
||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
|
||||
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for signing certificate | - |
|
||||
| `DOCUMENSO_DISABLE_TELEMETRY` | Disable anonymous telemetry | `false` |
|
||||
|
||||
</Step>
|
||||
|
||||
@@ -22,6 +22,37 @@ Organisation members have different permission levels that determine what they c
|
||||
organisation.
|
||||
</Callout>
|
||||
|
||||
## Transferring Organisation Ownership
|
||||
|
||||
Organisation ownership cannot be transferred through the regular organisation settings. Only a Documenso instance administrator can transfer ownership through the admin panel.
|
||||
|
||||
If you are using Documenso Cloud, contact support to request an ownership transfer. If you are self-hosting, an instance administrator can follow the steps below.
|
||||
|
||||
The target user must already be a member of the organisation.
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
Navigate to **Admin > Organisations** and select the organisation.
|
||||
</Step>
|
||||
<Step>
|
||||
In the **Organisation Members** table, find the target member and click **Update role**.
|
||||
</Step>
|
||||
<Step>
|
||||
Select **Owner** from the role dropdown and click **Update**.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
After the transfer:
|
||||
|
||||
- The new owner is promoted to Admin if they previously held a lower role (Manager or Member).
|
||||
- The previous owner retains their Admin role and remains a member of the organisation.
|
||||
- Only one user can be the owner at a time.
|
||||
|
||||
<Callout type="warn">
|
||||
The current owner cannot be demoted below Admin. Transfer ownership to another member first.
|
||||
</Callout>
|
||||
|
||||
## Team Member Roles
|
||||
|
||||
Teams have three roles with different permission levels:
|
||||
|
||||
@@ -135,7 +135,9 @@ Additional options that apply to all documents created from this template:
|
||||
|
||||
## Template Visibility
|
||||
|
||||
All templates are created in a team context. Team members can see, edit, delete, and use the templates in that team. See [Organisations](/docs/users/organisations) to learn about creating and managing organisations.
|
||||
All templates are created in a team context. By default, templates are **Private** and only visible to members of the owning team.
|
||||
|
||||
If your organisation has multiple teams, you can set a template's type to **Organisation** to share it across all teams. See [Organisation Templates](/docs/users/templates/organisation-templates) for details.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ description: Create reusable document templates for common signing workflows.
|
||||
description="Create documents from your templates."
|
||||
href="/docs/users/templates/use"
|
||||
/>
|
||||
<Card
|
||||
title="Organisation Templates"
|
||||
description="Share templates across all teams in your organisation."
|
||||
href="/docs/users/templates/organisation-templates"
|
||||
/>
|
||||
</Cards>
|
||||
|
||||
---
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "Templates",
|
||||
"pages": ["create", "use"]
|
||||
"pages": ["create", "use", "organisation-templates"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
title: Organisation Templates
|
||||
description: Share templates across all teams in your organisation so any team can create documents from them.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
## Overview
|
||||
|
||||
Organisation templates are templates shared across all teams within the same organisation. Any team in the organisation can browse and use them to create documents, but only the owning team can edit or delete them.
|
||||
|
||||
This is useful when you have standardised documents that multiple teams need to use, such as company-wide NDAs, onboarding agreements, or compliance forms.
|
||||
|
||||
## Requirements
|
||||
|
||||
The Organisation template type is available when your organisation has **two or more teams**. If your organisation has only one team, the option does not appear.
|
||||
|
||||
## Template Types
|
||||
|
||||
| Type | Who can see it | Who can edit it | Who can use it |
|
||||
| ---------------- | ------------------------- | ----------------- | --------------------------- |
|
||||
| **Private** | Members of the owning team | Owning team | Owning team |
|
||||
| **Organisation** | All teams in the org | Owning team only | All teams in the org |
|
||||
| **Public** | Anyone with the link | Owning team | Anyone via direct link |
|
||||
|
||||
## Set a Template as Organisation
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
### Open template settings
|
||||
|
||||
Navigate to **Templates**, open the template you want to share, and click **Edit Template** to open the editor. Then open the settings dialog.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Change the template type
|
||||
|
||||
In the **Template type** dropdown, select **Organisation**. This option only appears if your organisation has at least two teams.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Save
|
||||
|
||||
Click **Save** to apply the change. The template is now visible to all teams in your organisation.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
You can also set the template type to Organisation when creating a new template. The type dropdown appears in the template settings step.
|
||||
|
||||
## Browse Organisation Templates
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
### Open the templates page
|
||||
|
||||
Navigate to **Templates** in the sidebar.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Switch to the Organisation tab
|
||||
|
||||
Click the **Organisation** tab above the template list. This tab only appears for non-personal organisations.
|
||||
|
||||
The Organisation tab shows all organisation templates from every team in your organisation, including your own.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
Templates from other teams display the owning team's name next to the template type.
|
||||
|
||||
## Use an Organisation Template
|
||||
|
||||
Any team member in the organisation can create documents from an organisation template, even if the template belongs to a different team.
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
Find the template in the **Organisation** tab or click through from the template detail page.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
Click **Use Template** and fill in the recipient details. The document is created under your team, not the template's owning team.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
See [Use Templates](/docs/users/templates/use) for details on creating documents from templates.
|
||||
|
||||
## Editing and Permissions
|
||||
|
||||
Only members of the team that owns the template can edit or delete it. When viewing an organisation template from another team:
|
||||
|
||||
- The **Edit Template**, **Direct Link**, and **Bulk Send** controls are hidden
|
||||
- The recipients section is read-only
|
||||
- The **Use Template** button is available
|
||||
|
||||
To modify a template owned by another team, contact that team's members or ask an organisation admin to make changes.
|
||||
|
||||
## Visibility
|
||||
|
||||
Organisation templates respect the same visibility settings as other templates. A template's visibility determines which team roles can access it:
|
||||
|
||||
| Visibility | Who can access |
|
||||
| --------------------- | --------------------------------------- |
|
||||
| **Everyone** | All team members (Admin, Manager, Member) |
|
||||
| **Manager and above** | Admins and Managers only |
|
||||
| **Admin** | Admins only |
|
||||
|
||||
This applies to both the owning team and other teams in the organisation. A Member-role user on any team cannot see an organisation template set to Admin visibility.
|
||||
|
||||
## Reverting to Private
|
||||
|
||||
To stop sharing a template across the organisation, change the template type back to **Private** in the template settings. The template will only be visible to the owning team. Documents already created from the template are not affected.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Create Templates](/docs/users/templates/create) - Build reusable templates
|
||||
- [Use Templates](/docs/users/templates/use) - Create documents from templates
|
||||
- [Organisations](/docs/users/organisations) - Managing organisations and teams
|
||||
@@ -12,6 +12,7 @@ export async function GET(request: Request) {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ export async function GET(request: Request) {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ export async function GET(request: Request) {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ export async function GET(request: Request) {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ export async function GET(request: Request) {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ export async function GET(request: Request) {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ export async function GET(request: Request) {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
export interface TransformedData {
|
||||
export type TransformedData = {
|
||||
labels: string[];
|
||||
datasets: Array<{
|
||||
label: string;
|
||||
data: number[];
|
||||
}>;
|
||||
}
|
||||
};
|
||||
|
||||
export function addZeroMonth(transformedData: TransformedData): TransformedData {
|
||||
const result = {
|
||||
const FORMAT = 'MMM yyyy';
|
||||
|
||||
export const addZeroMonth = (
|
||||
transformedData: TransformedData,
|
||||
isCumulative = false,
|
||||
): TransformedData => {
|
||||
const result: TransformedData = {
|
||||
labels: [...transformedData.labels],
|
||||
datasets: transformedData.datasets.map((dataset) => ({
|
||||
label: dataset.label,
|
||||
@@ -21,34 +26,28 @@ export function addZeroMonth(transformedData: TransformedData): TransformedData
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.datasets.every((dataset) => dataset.data[0] === 0)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
let firstMonth = DateTime.fromFormat(result.labels[0], 'MMM yyyy');
|
||||
if (!result.datasets.every((dataset) => dataset.data[0] === 0)) {
|
||||
const firstMonth = DateTime.fromFormat(result.labels[0], FORMAT);
|
||||
if (!firstMonth.isValid) {
|
||||
const formats = ['MMM yyyy', 'MMMM yyyy', 'MM/yyyy', 'yyyy-MM'];
|
||||
|
||||
for (const format of formats) {
|
||||
firstMonth = DateTime.fromFormat(result.labels[0], format);
|
||||
if (firstMonth.isValid) break;
|
||||
}
|
||||
|
||||
if (!firstMonth.isValid) {
|
||||
console.warn(`Could not parse date: "${result.labels[0]}"`);
|
||||
return transformedData;
|
||||
}
|
||||
console.warn(`Could not parse date: "${result.labels[0]}"`);
|
||||
return transformedData;
|
||||
}
|
||||
|
||||
const zeroMonth = firstMonth.minus({ months: 1 }).toFormat('MMM yyyy');
|
||||
result.labels.unshift(zeroMonth);
|
||||
result.labels.unshift(firstMonth.minus({ months: 1 }).toFormat(FORMAT));
|
||||
result.datasets.forEach((dataset) => {
|
||||
dataset.data.unshift(0);
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
return transformedData;
|
||||
}
|
||||
}
|
||||
|
||||
const now = DateTime.now().startOf('month');
|
||||
const lastMonth = DateTime.fromFormat(result.labels[result.labels.length - 1], FORMAT);
|
||||
|
||||
if (lastMonth.isValid && lastMonth.startOf('month') < now) {
|
||||
result.labels.push(now.toFormat(FORMAT));
|
||||
result.datasets.forEach((dataset) => {
|
||||
dataset.data.push(isCumulative ? dataset.data[dataset.data.length - 1] : 0);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -21,8 +21,7 @@ export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative'
|
||||
.where(() => sql`"Envelope"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
|
||||
.where(() => sql`"Envelope"."type" = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`)
|
||||
.groupBy('month')
|
||||
.orderBy('month', 'desc')
|
||||
.limit(12);
|
||||
.orderBy('month', 'desc');
|
||||
|
||||
const result = await qb.execute();
|
||||
|
||||
@@ -38,7 +37,7 @@ export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative'
|
||||
],
|
||||
};
|
||||
|
||||
return addZeroMonth(transformedData);
|
||||
return addZeroMonth(transformedData, type === 'cumulative');
|
||||
};
|
||||
|
||||
export type GetCompletedDocumentsMonthlyResult = Awaited<
|
||||
|
||||
@@ -36,7 +36,7 @@ export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' =
|
||||
],
|
||||
};
|
||||
|
||||
return addZeroMonth(transformedData);
|
||||
return addZeroMonth(transformedData, type === 'cumulative');
|
||||
};
|
||||
|
||||
export type GetSignerConversionMonthlyResult = Awaited<
|
||||
|
||||
@@ -17,8 +17,7 @@ export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count
|
||||
.as('cume_count'),
|
||||
])
|
||||
.groupBy('month')
|
||||
.orderBy('month', 'desc')
|
||||
.limit(12);
|
||||
.orderBy('month', 'desc');
|
||||
|
||||
const result = await qb.execute();
|
||||
|
||||
@@ -34,7 +33,7 @@ export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count
|
||||
],
|
||||
};
|
||||
|
||||
return addZeroMonth(transformedData);
|
||||
return addZeroMonth(transformedData, type === 'cumulative');
|
||||
};
|
||||
|
||||
export type GetUserMonthlyGrowthResult = Awaited<ReturnType<typeof getUserMonthlyGrowth>>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { addZeroMonth } from './add-zero-month';
|
||||
import { type TransformedData, addZeroMonth } from './add-zero-month';
|
||||
|
||||
type MetricKeys = {
|
||||
stars: number;
|
||||
@@ -14,14 +14,6 @@ type DataEntry = {
|
||||
[key: string]: MetricKeys;
|
||||
};
|
||||
|
||||
type TransformData = {
|
||||
labels: string[];
|
||||
datasets: {
|
||||
label: string;
|
||||
data: number[];
|
||||
}[];
|
||||
};
|
||||
|
||||
type MetricKey = keyof MetricKeys;
|
||||
|
||||
const FRIENDLY_METRIC_NAMES: { [key in MetricKey]: string } = {
|
||||
@@ -38,7 +30,7 @@ export function transformData({
|
||||
}: {
|
||||
data: DataEntry;
|
||||
metric: MetricKey;
|
||||
}): TransformData {
|
||||
}): TransformedData {
|
||||
try {
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return {
|
||||
@@ -103,7 +95,7 @@ export function transformData({
|
||||
],
|
||||
};
|
||||
|
||||
return addZeroMonth(transformedData);
|
||||
return addZeroMonth(transformedData, true);
|
||||
} catch (error) {
|
||||
return {
|
||||
labels: [],
|
||||
@@ -111,6 +103,3 @@ export function transformData({
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// To be on the safer side
|
||||
export const transformRepoStats = transformData;
|
||||
|
||||
@@ -57,7 +57,7 @@ export const ClaimCreateDialog = ({ licenseFlags }: ClaimCreateDialogProps) => {
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="scrollbar-hidden max-h-[90vh] overflow-y-auto sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Create Subscription Claim</Trans>
|
||||
|
||||
@@ -53,7 +53,7 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD
|
||||
{trigger}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="scrollbar-hidden max-h-[90vh] overflow-y-auto sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Update Subscription Claim</Trans>
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
@@ -38,19 +37,6 @@ export const DocumentDuplicateDialog = ({
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
|
||||
trpcReact.envelope.item.getManyByToken.useQuery(
|
||||
{
|
||||
envelopeId: id,
|
||||
access: token ? { type: 'recipient', token } : { type: 'user' },
|
||||
},
|
||||
{
|
||||
enabled: open,
|
||||
},
|
||||
);
|
||||
|
||||
const envelopeItems = envelopeItemsPayload?.data || [];
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
|
||||
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
|
||||
@@ -88,22 +74,6 @@ export const DocumentDuplicateDialog = ({
|
||||
<Trans>Duplicate</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{isLoadingEnvelopeItems || !envelopeItems || envelopeItems.length === 0 ? (
|
||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||
<Trans>Loading Document...</Trans>
|
||||
</h1>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
|
||||
<PDFViewerLazy
|
||||
key={envelopeItems[0].id}
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={undefined}
|
||||
version="original"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 || [];
|
||||
|
||||
|
||||
@@ -5,18 +5,18 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { EnvelopeItem, FieldType } from '@prisma/client';
|
||||
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { base64 } from '@scure/base';
|
||||
import { ChevronsUpDown } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR, getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
|
||||
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 { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
|
||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { FieldItem } from '@documenso/ui/primitives/document-flow/field-item';
|
||||
@@ -24,14 +24,15 @@ import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/type
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { FieldSelector } from '@documenso/ui/primitives/field-selector';
|
||||
import { Form } from '@documenso/ui/primitives/form/form';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { FieldAdvancedSettingsDrawer } from '~/components/embed/authoring/field-advanced-settings-drawer';
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
|
||||
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
|
||||
import type { TConfigureFieldsFormSchema } from './configure-fields-view.types';
|
||||
import { FieldAdvancedSettingsDrawer } from './field-advanced-settings-drawer';
|
||||
|
||||
const MIN_HEIGHT_PX = 12;
|
||||
const MIN_WIDTH_PX = 36;
|
||||
@@ -42,7 +43,7 @@ const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
|
||||
export type ConfigureFieldsViewProps = {
|
||||
configData: TConfigureEmbedFormSchema;
|
||||
presignToken?: string | undefined;
|
||||
envelopeItem?: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
|
||||
envelopeItem?: Pick<EnvelopeItem, 'id' | 'envelopeId' | 'documentDataId'>;
|
||||
defaultValues?: Partial<TConfigureFieldsFormSchema>;
|
||||
onBack?: (data: TConfigureFieldsFormSchema) => void;
|
||||
onSubmit: (data: TConfigureFieldsFormSchema) => void;
|
||||
@@ -86,23 +87,22 @@ export const ConfigureFieldsView = ({
|
||||
|
||||
const normalizedDocumentData = useMemo(() => {
|
||||
if (envelopeItem) {
|
||||
return undefined;
|
||||
return getDocumentDataUrlForPdfViewer({
|
||||
envelopeId: envelopeItem.envelopeId,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
documentDataId: envelopeItem.documentDataId,
|
||||
version: 'current',
|
||||
token: undefined,
|
||||
presignToken,
|
||||
});
|
||||
}
|
||||
|
||||
if (!configData.documentData) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return base64.encode(configData.documentData.data);
|
||||
}, [configData.documentData]);
|
||||
|
||||
const normalizedEnvelopeItem = useMemo(() => {
|
||||
if (envelopeItem) {
|
||||
return envelopeItem;
|
||||
}
|
||||
|
||||
return { id: '', envelopeId: '' };
|
||||
}, [envelopeItem]);
|
||||
return configData.documentData.data;
|
||||
}, [configData.documentData, envelopeItem, presignToken]);
|
||||
|
||||
const recipients = useMemo(() => {
|
||||
return configData.signers.map<Recipient>((signer, index) => ({
|
||||
@@ -155,9 +155,7 @@ export const ConfigureFieldsView = ({
|
||||
});
|
||||
|
||||
const selectedRecipientIndex = recipients.findIndex((r) => r.id === selectedRecipient?.id);
|
||||
const selectedRecipientStyles = useRecipientColors(
|
||||
selectedRecipientIndex === -1 ? 0 : selectedRecipientIndex,
|
||||
);
|
||||
const selectedRecipientStyles = getRecipientColorStyles(selectedRecipientIndex);
|
||||
|
||||
const form = useForm<TConfigureFieldsFormSchema>({
|
||||
defaultValues: {
|
||||
@@ -179,8 +177,6 @@ export const ConfigureFieldsView = ({
|
||||
name: 'fields',
|
||||
});
|
||||
|
||||
const highestPageNumber = Math.max(...localFields.map((field) => field.pageNumber));
|
||||
|
||||
const onFieldCopy = useCallback(
|
||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
||||
const { duplicate = false, duplicateAll = false } = options ?? {};
|
||||
@@ -205,13 +201,15 @@ export const ConfigureFieldsView = ({
|
||||
}
|
||||
|
||||
if (duplicateAll) {
|
||||
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
|
||||
const totalPages = getPdfPagesCount();
|
||||
|
||||
pages.forEach((_, index) => {
|
||||
const pageNumber = index + 1;
|
||||
if (totalPages < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
|
||||
if (pageNumber === lastActiveField.pageNumber) {
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
const newField: TConfigureFieldsFormSchema['fields'][0] = {
|
||||
@@ -224,7 +222,7 @@ export const ConfigureFieldsView = ({
|
||||
};
|
||||
|
||||
append(newField);
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -548,17 +546,11 @@ export const ConfigureFieldsView = ({
|
||||
|
||||
<Form {...form}>
|
||||
<div>
|
||||
<PDFViewerLazy
|
||||
presignToken={presignToken}
|
||||
overrideData={normalizedDocumentData}
|
||||
envelopeItem={normalizedEnvelopeItem}
|
||||
token={undefined}
|
||||
version="signed"
|
||||
/>
|
||||
{normalizedDocumentData && (
|
||||
<PDFViewerLazy data={normalizedDocumentData} scrollParentRef="window" />
|
||||
)}
|
||||
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||
>
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{localFields.map((field, index) => {
|
||||
const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
|
||||
|
||||
|
||||
@@ -19,11 +19,13 @@ 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 { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
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';
|
||||
import type {
|
||||
@@ -35,12 +37,11 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||
import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-schema';
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
import { injectCss } from '~/utils/css-vars';
|
||||
|
||||
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
|
||||
@@ -54,7 +55,7 @@ export type EmbedDirectTemplateClientPageProps = {
|
||||
token: string;
|
||||
envelopeId: string;
|
||||
updatedAt: Date;
|
||||
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
|
||||
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId' | 'documentDataId'>[];
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | null;
|
||||
@@ -97,12 +98,10 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields);
|
||||
|
||||
const [pendingFields, _completedFields] = [
|
||||
localFields.filter((field) => isFieldUnsignedAndRequired(field)),
|
||||
sortFieldsByPosition(localFields.filter((field) => isFieldUnsignedAndRequired(field))),
|
||||
localFields.filter((field) => field.inserted),
|
||||
];
|
||||
|
||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
||||
|
||||
const hasSignatureField = localFields.some((field) => isSignatureFieldType(field.type));
|
||||
|
||||
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
||||
@@ -342,9 +341,15 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
{/* Viewer */}
|
||||
<div className="flex-1">
|
||||
<PDFViewerLazy
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={recipient.token}
|
||||
version="signed"
|
||||
data={getDocumentDataUrlForPdfViewer({
|
||||
envelopeId: envelopeItems[0]?.envelopeId,
|
||||
envelopeItemId: envelopeItems[0]?.id,
|
||||
documentDataId: envelopeItems[0]?.documentDataId,
|
||||
version: 'current',
|
||||
token: recipient.token,
|
||||
presignToken: undefined,
|
||||
})}
|
||||
scrollParentRef="window"
|
||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
@@ -478,15 +483,15 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
||||
>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pendingFields[0].page}"]`}
|
||||
>
|
||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
</FieldToolTip>
|
||||
)}
|
||||
</ElementVisible>
|
||||
</ElementVisible>
|
||||
)}
|
||||
|
||||
{/* Fields */}
|
||||
<EmbedDocumentFields
|
||||
|
||||
@@ -50,10 +50,8 @@ export const EmbedDocumentFields = ({
|
||||
onSignField,
|
||||
onUnsignField,
|
||||
}: EmbedDocumentFieldsProps) => {
|
||||
const highestPageNumber = Math.max(...fields.map((field) => field.page));
|
||||
|
||||
return (
|
||||
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}>
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{fields.map((field) =>
|
||||
match(field.type)
|
||||
.with(FieldType.SIGNATURE, () => (
|
||||
|
||||
@@ -9,8 +9,10 @@ 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 { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
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';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -23,15 +25,14 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
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';
|
||||
@@ -45,7 +46,7 @@ export type EmbedSignDocumentV1ClientPageProps = {
|
||||
token: string;
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
|
||||
envelopeItems: (Pick<EnvelopeItem, 'id' | 'envelopeId'> & { documentData: { id: string } })[];
|
||||
recipient: RecipientWithFields;
|
||||
fields: Field[];
|
||||
completedFields: DocumentField[];
|
||||
@@ -73,7 +74,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { fullName, email, signature, setFullName, setSignature } =
|
||||
const { fullName, email, signature, setFullName, setEmail, setSignature } =
|
||||
useRequiredDocumentSigningContext();
|
||||
|
||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||
@@ -88,6 +89,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||
const [isEmailLocked, setIsEmailLocked] = useState(!!email);
|
||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||
const [_showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
|
||||
useState(false);
|
||||
@@ -100,14 +102,14 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
|
||||
|
||||
const [pendingFields, _completedFields] = [
|
||||
fields.filter(
|
||||
(field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field),
|
||||
sortFieldsByPosition(
|
||||
fields.filter(
|
||||
(field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field),
|
||||
),
|
||||
),
|
||||
fields.filter((field) => field.inserted),
|
||||
];
|
||||
|
||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
||||
|
||||
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
|
||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
|
||||
@@ -204,13 +206,19 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
try {
|
||||
const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
|
||||
|
||||
if (!isCompleted && data.name) {
|
||||
if (!isCompleted && data.name && !fullName) {
|
||||
setFullName(data.name);
|
||||
}
|
||||
|
||||
// Since a recipient can be provided a name we can lock it without requiring
|
||||
// a to be provided by the parent application, unlike direct templates.
|
||||
setIsNameLocked(!!data.lockName);
|
||||
|
||||
if (!isCompleted && data.email && !email) {
|
||||
setEmail(data.email);
|
||||
setIsEmailLocked(!!data.lockEmail);
|
||||
}
|
||||
|
||||
setAllowDocumentRejection(!!data.allowDocumentRejection);
|
||||
setShowOtherRecipientsCompletedFields(!!data.showOtherRecipientsCompletedFields);
|
||||
|
||||
@@ -288,9 +296,15 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
{/* Viewer */}
|
||||
<div className="embed--DocumentViewer flex-1">
|
||||
<PDFViewerLazy
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={token}
|
||||
version="signed"
|
||||
data={getDocumentDataUrlForPdfViewer({
|
||||
envelopeId: envelopeItems[0]?.envelopeId,
|
||||
envelopeItemId: envelopeItems[0]?.id,
|
||||
documentDataId: envelopeItems[0]?.documentData.id,
|
||||
version: 'current',
|
||||
token: token,
|
||||
presignToken: undefined,
|
||||
})}
|
||||
scrollParentRef="window"
|
||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
@@ -442,7 +456,8 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
id="email"
|
||||
className="mt-2 bg-background"
|
||||
value={email}
|
||||
disabled
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isEmailLocked}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -491,15 +506,15 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
||||
>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pendingFields[0].page}"]`}
|
||||
>
|
||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
</FieldToolTip>
|
||||
)}
|
||||
</ElementVisible>
|
||||
</ElementVisible>
|
||||
)}
|
||||
|
||||
{/* Fields */}
|
||||
<EmbedDocumentFields fields={fields} metadata={metadata} />
|
||||
|
||||
@@ -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';
|
||||
@@ -26,7 +26,7 @@ export const EmbedSignDocumentV2ClientPage = ({
|
||||
}: EmbedSignDocumentV2ClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { envelope, recipient, envelopeData, setFullName, setEmail, fullName } =
|
||||
const { envelope, recipient, envelopeData, setFullName, setEmail, fullName, email } =
|
||||
useRequiredEnvelopeSigningContext();
|
||||
|
||||
const { isCompleted, isRejected, recipientSignature } = envelopeData;
|
||||
@@ -36,7 +36,9 @@ export const EmbedSignDocumentV2ClientPage = ({
|
||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||
const [isEmailLocked, setIsEmailLocked] = useState(envelope.type === EnvelopeType.DOCUMENT);
|
||||
const [isEmailLocked, setIsEmailLocked] = useState(
|
||||
envelope.type === EnvelopeType.DOCUMENT && !!email,
|
||||
);
|
||||
|
||||
const onDocumentCompleted = (data: {
|
||||
token: string;
|
||||
@@ -128,19 +130,22 @@ export const EmbedSignDocumentV2ClientPage = ({
|
||||
const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
|
||||
|
||||
if (!isCompleted && data.name) {
|
||||
setFullName(data.name);
|
||||
// For documents, only use the hash name if the recipient doesn't already have one.
|
||||
// For templates, always allow the hash name to be used.
|
||||
if (envelope.type === EnvelopeType.TEMPLATE || !fullName) {
|
||||
setFullName(data.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Since a recipient can be provided a name we can lock it without requiring
|
||||
// a to be provided by the parent application, unlike direct templates.
|
||||
setIsNameLocked(!!data.lockName);
|
||||
|
||||
if (envelope.type === EnvelopeType.TEMPLATE) {
|
||||
if (!isCompleted && data.email) {
|
||||
if (!isCompleted && data.email) {
|
||||
// For documents, only use the hash email if the recipient doesn't already have one.
|
||||
// For templates, always allow the hash email to be used.
|
||||
if (envelope.type === EnvelopeType.TEMPLATE || !email) {
|
||||
setEmail(data.email);
|
||||
}
|
||||
|
||||
if (data.email) {
|
||||
setIsEmailLocked(!!data.lockEmail);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ 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 { 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';
|
||||
import type {
|
||||
@@ -22,10 +24,11 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
|
||||
import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider';
|
||||
import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog';
|
||||
import { EmbedDocumentFields } from '../embed-document-fields';
|
||||
@@ -87,14 +90,14 @@ export const MultiSignDocumentSigningView = ({
|
||||
const hasSignatureField = document?.fields.some((field) => isSignatureFieldType(field.type));
|
||||
|
||||
const [pendingFields, completedFields] = [
|
||||
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ??
|
||||
[],
|
||||
sortFieldsByPosition(
|
||||
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ??
|
||||
[],
|
||||
),
|
||||
document?.fields.filter((field) => field.recipient.signingStatus === SigningStatus.SIGNED) ??
|
||||
[],
|
||||
];
|
||||
|
||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
||||
|
||||
const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? [];
|
||||
|
||||
const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => {
|
||||
@@ -178,8 +181,8 @@ export const MultiSignDocumentSigningView = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen overflow-hidden bg-background">
|
||||
<div id="document-field-portal-root" className="relative h-full w-full overflow-y-auto p-8">
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="relative h-full w-full p-8">
|
||||
{match({ isLoading, document })
|
||||
.with({ isLoading: true }, () => (
|
||||
<div className="flex min-h-[400px] w-full items-center justify-center">
|
||||
@@ -227,9 +230,15 @@ export const MultiSignDocumentSigningView = ({
|
||||
})}
|
||||
>
|
||||
<PDFViewerLazy
|
||||
envelopeItem={document.envelopeItems[0]}
|
||||
token={token}
|
||||
version="signed"
|
||||
data={getDocumentDataUrlForPdfViewer({
|
||||
envelopeId: document.envelopeId,
|
||||
envelopeItemId: document.envelopeItems[0]?.id,
|
||||
documentDataId: document.documentData.id,
|
||||
version: 'current',
|
||||
token,
|
||||
presignToken: undefined,
|
||||
})}
|
||||
scrollParentRef="window"
|
||||
onDocumentLoad={() => {
|
||||
setHasDocumentLoaded(true);
|
||||
onDocumentReady?.();
|
||||
@@ -360,13 +369,11 @@ export const MultiSignDocumentSigningView = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasDocumentLoaded && (
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
||||
>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
{hasDocumentLoaded && showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pendingFields[0].page}"]`}
|
||||
>
|
||||
<FieldToolTip
|
||||
key={pendingFields[0].id}
|
||||
field={pendingFields[0]}
|
||||
@@ -374,27 +381,27 @@ export const MultiSignDocumentSigningView = ({
|
||||
>
|
||||
<Trans>Click to insert field</Trans>
|
||||
</FieldToolTip>
|
||||
)}
|
||||
</ElementVisible>
|
||||
)}
|
||||
</ElementVisible>
|
||||
)}
|
||||
|
||||
{/* Fields */}
|
||||
{hasDocumentLoaded && (
|
||||
<EmbedDocumentFields
|
||||
fields={pendingFields}
|
||||
metadata={document.documentMeta}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
)}
|
||||
{/* Fields */}
|
||||
{hasDocumentLoaded && (
|
||||
<EmbedDocumentFields
|
||||
fields={pendingFields}
|
||||
metadata={document.documentMeta}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Completed fields */}
|
||||
{document.status !== DocumentStatus.COMPLETED && (
|
||||
<DocumentReadOnlyFields
|
||||
documentMeta={document.documentMeta ?? undefined}
|
||||
fields={completedFields}
|
||||
/>
|
||||
)}
|
||||
{/* Completed fields */}
|
||||
{document.status !== DocumentStatus.COMPLETED && (
|
||||
<DocumentReadOnlyFields
|
||||
documentMeta={document.documentMeta ?? undefined}
|
||||
fields={completedFields}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -96,7 +96,7 @@ export const EditorFieldDropdownForm = ({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
defaultValue: value.defaultValue,
|
||||
values: value.values || [{ value: 'Option 1' }],
|
||||
values: value.values || [{ value: t`Option 1` }],
|
||||
required: value.required || false,
|
||||
readOnly: value.readOnly || false,
|
||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||
@@ -110,13 +110,13 @@ export const EditorFieldDropdownForm = ({
|
||||
const addValue = () => {
|
||||
const currentValues = form.getValues('values') || [];
|
||||
|
||||
let newValue = 'New option';
|
||||
let newValue = t`New option`;
|
||||
|
||||
// Iterate to create a unique value
|
||||
for (let i = 0; i < currentValues.length; i++) {
|
||||
newValue = `New option ${i + 1}`;
|
||||
if (currentValues.some((item) => item.value === `New option ${i + 1}`)) {
|
||||
newValue = `New option ${i + 1}`;
|
||||
newValue = t`New option ${i + 1}`;
|
||||
if (currentValues.some((item) => item.value === t`New option ${i + 1}`)) {
|
||||
newValue = t`New option ${i + 1}`;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -79,7 +79,7 @@ export const EditorFieldRadioForm = ({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
label: value.label || '',
|
||||
values: value.values || [{ id: 1, checked: false, value: 'Default value' }],
|
||||
values: value.values || [{ id: 1, checked: false, value: t`Default value` }],
|
||||
required: value.required || false,
|
||||
readOnly: value.readOnly || false,
|
||||
direction: value.direction || 'vertical',
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -54,8 +54,8 @@ export const ZSignUpFormSchema = z
|
||||
},
|
||||
);
|
||||
|
||||
export const signupErrorMessages: Record<string, MessageDescriptor> = {
|
||||
SIGNUP_DISABLED: msg`Signups are disabled.`,
|
||||
export const SIGNUP_ERROR_MESSAGES: Record<string, MessageDescriptor> = {
|
||||
SIGNUP_DISABLED: msg`Signup is currently disabled or not available for your email domain.`,
|
||||
[AppErrorCode.ALREADY_EXISTS]: msg`User with this email already exists. Please use a different email address.`,
|
||||
[AppErrorCode.INVALID_REQUEST]: msg`We were unable to create your account. Please review the information you provided and try again.`,
|
||||
};
|
||||
@@ -130,7 +130,8 @@ export const SignUpForm = ({
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = signupErrorMessages[error.code] ?? signupErrorMessages.INVALID_REQUEST;
|
||||
const errorMessage =
|
||||
SIGNUP_ERROR_MESSAGES[error.code] ?? SIGNUP_ERROR_MESSAGES.INVALID_REQUEST;
|
||||
|
||||
toast({
|
||||
title: _(msg`An error occurred`),
|
||||
@@ -196,7 +197,7 @@ export const SignUpForm = ({
|
||||
|
||||
return (
|
||||
<div className={cn('flex justify-center gap-x-12', className)}>
|
||||
<div className="border-border relative hidden flex-1 overflow-hidden rounded-xl border xl:flex">
|
||||
<div className="relative hidden flex-1 overflow-hidden rounded-xl border border-border xl:flex">
|
||||
<div className="absolute -inset-8 -z-[2] backdrop-blur">
|
||||
<img
|
||||
src={communityCardsImage}
|
||||
@@ -205,17 +206,17 @@ export const SignUpForm = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-background/50 absolute -inset-8 -z-[1] backdrop-blur-[2px]" />
|
||||
<div className="absolute -inset-8 -z-[1] bg-background/50 backdrop-blur-[2px]" />
|
||||
|
||||
<div className="relative flex h-full w-full flex-col items-center justify-evenly">
|
||||
<div className="bg-background rounded-2xl border px-4 py-1 text-sm font-medium">
|
||||
<div className="rounded-2xl border bg-background px-4 py-1 text-sm font-medium">
|
||||
<Trans>User profiles are here!</Trans>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md">
|
||||
<UserProfileTimur
|
||||
rows={2}
|
||||
className="bg-background border-border rounded-2xl border shadow-md"
|
||||
className="rounded-2xl border border-border bg-background shadow-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -223,13 +224,13 @@ export const SignUpForm = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border dark:bg-background relative z-10 flex min-h-[min(850px,80vh)] w-full max-w-lg flex-col rounded-xl border bg-neutral-100 p-6">
|
||||
<div className="relative z-10 flex min-h-[min(850px,80vh)] w-full max-w-lg flex-col rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
|
||||
<div className="h-20">
|
||||
<h1 className="text-xl font-semibold md:text-2xl">
|
||||
<Trans>Create a new account</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs md:text-sm">
|
||||
<p className="mt-2 text-xs text-muted-foreground md:text-sm">
|
||||
<Trans>
|
||||
Create your account and start using state-of-the-art document signing. Open and
|
||||
beautiful signing is within your grasp.
|
||||
@@ -323,70 +324,62 @@ export const SignUpForm = ({
|
||||
/>
|
||||
|
||||
{hasSocialAuthEnabled && (
|
||||
<>
|
||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
<div className="bg-border h-px flex-1" />
|
||||
<span className="text-muted-foreground bg-transparent">
|
||||
<Trans>Or</Trans>
|
||||
</span>
|
||||
<div className="bg-border h-px flex-1" />
|
||||
</div>
|
||||
</>
|
||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="bg-transparent text-muted-foreground">
|
||||
<Trans>Or</Trans>
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isGoogleSSOEnabled && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant={'outline'}
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignUpWithGoogleClick}
|
||||
>
|
||||
<FcGoogle className="mr-2 h-5 w-5" />
|
||||
<Trans>Sign Up with Google</Trans>
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant={'outline'}
|
||||
className="border bg-background text-muted-foreground"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignUpWithGoogleClick}
|
||||
>
|
||||
<FcGoogle className="mr-2 h-5 w-5" />
|
||||
<Trans>Sign Up with Google</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isMicrosoftSSOEnabled && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant={'outline'}
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignUpWithMicrosoftClick}
|
||||
>
|
||||
<img
|
||||
className="mr-2 h-4 w-4"
|
||||
alt="Microsoft Logo"
|
||||
src={'/static/microsoft.svg'}
|
||||
/>
|
||||
<Trans>Sign Up with Microsoft</Trans>
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant={'outline'}
|
||||
className="border bg-background text-muted-foreground"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignUpWithMicrosoftClick}
|
||||
>
|
||||
<img
|
||||
className="mr-2 h-4 w-4"
|
||||
alt="Microsoft Logo"
|
||||
src={'/static/microsoft.svg'}
|
||||
/>
|
||||
<Trans>Sign Up with Microsoft</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isOIDCSSOEnabled && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant={'outline'}
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignUpWithOIDCClick}
|
||||
>
|
||||
<FaIdCardClip className="mr-2 h-5 w-5" />
|
||||
<Trans>Sign Up with OIDC</Trans>
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant={'outline'}
|
||||
className="border bg-background text-muted-foreground"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignUpWithOIDCClick}
|
||||
>
|
||||
<FaIdCardClip className="mr-2 h-5 w-5" />
|
||||
<Trans>Sign Up with OIDC</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground mt-4 text-sm">
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
Already have an account?{' '}
|
||||
<Link to="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
|
||||
@@ -406,7 +399,7 @@ export const SignUpForm = ({
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<p className="text-muted-foreground mt-6 text-xs">
|
||||
<p className="mt-6 text-xs text-muted-foreground">
|
||||
<Trans>
|
||||
By proceeding, you agree to our{' '}
|
||||
<Link
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CheckCircle2Icon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
KeyRoundIcon,
|
||||
Loader2Icon,
|
||||
RefreshCwIcon,
|
||||
@@ -32,6 +36,7 @@ type AdminLicenseCardProps = {
|
||||
|
||||
export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
|
||||
const { t, i18n } = useLingui();
|
||||
const [isLicenseKeyVisible, setIsLicenseKeyVisible] = useState(false);
|
||||
|
||||
const { license } = licenseData || {};
|
||||
|
||||
@@ -53,6 +58,7 @@ export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
<Trans>Invalid License Key</Trans>
|
||||
</p>
|
||||
{/* Don't need to hide invalid license keys. */}
|
||||
<p className="text-xs text-muted-foreground">{licenseData.requestedLicenseKey}</p>
|
||||
</>
|
||||
) : (
|
||||
@@ -135,7 +141,26 @@ export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
<Trans>License Key</Trans>
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{license.licenseKey}</p>
|
||||
<div className="mt-0.5 flex items-center gap-1">
|
||||
<p className="min-w-0 break-all text-xs text-muted-foreground">
|
||||
{isLicenseKeyVisible ? license.licenseKey : '•'.repeat(license.licenseKey.length)}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground"
|
||||
aria-label={isLicenseKeyVisible ? t`Hide license key` : t`Show license key`}
|
||||
onClick={() => setIsLicenseKeyVisible((prevState) => !prevState)}
|
||||
>
|
||||
{isLicenseKeyVisible ? (
|
||||
<EyeOffIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<EyeIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { keepPreviousData } from '@tanstack/react-query';
|
||||
import { CheckIcon, Loader, Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useNavigate } from 'react-router';
|
||||
@@ -65,14 +66,16 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
||||
const [pages, setPages] = useState<string[]>([]);
|
||||
|
||||
const debouncedSearch = useDebouncedValue(search, 200);
|
||||
const hasValidSearch = debouncedSearch.trim().length > 0;
|
||||
|
||||
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
|
||||
const { data: searchDocumentsData, isFetching: isFetchingDocuments } =
|
||||
trpcReact.document.search.useQuery(
|
||||
{
|
||||
query: debouncedSearch,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
enabled: open === true && hasValidSearch,
|
||||
placeholderData: keepPreviousData,
|
||||
// Do not batch this due to relatively long request time compared to
|
||||
// other queries which are generally batched with this.
|
||||
...SKIP_QUERY_BATCH_META,
|
||||
@@ -80,6 +83,19 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
||||
},
|
||||
);
|
||||
|
||||
const { data: searchTemplatesData, isFetching: isFetchingTemplates } =
|
||||
trpcReact.template.search.useQuery(
|
||||
{
|
||||
query: debouncedSearch,
|
||||
},
|
||||
{
|
||||
enabled: open === true && hasValidSearch,
|
||||
placeholderData: keepPreviousData,
|
||||
...SKIP_QUERY_BATCH_META,
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
},
|
||||
);
|
||||
|
||||
const teamUrl = useMemo(() => {
|
||||
let teamUrl = currentTeam?.url || null;
|
||||
|
||||
@@ -134,17 +150,23 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
||||
];
|
||||
}, [currentTeam, organisations]);
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
if (!searchDocumentsData) {
|
||||
return [];
|
||||
}
|
||||
const documentSearchResults =
|
||||
hasValidSearch && searchDocumentsData
|
||||
? searchDocumentsData.map((document) => ({
|
||||
label: document.title,
|
||||
path: document.path,
|
||||
value: document.value,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return searchDocumentsData.map((document) => ({
|
||||
label: document.title,
|
||||
path: document.path,
|
||||
value: document.value,
|
||||
}));
|
||||
}, [searchDocumentsData]);
|
||||
const templateSearchResults =
|
||||
hasValidSearch && searchTemplatesData
|
||||
? searchTemplatesData.map((template) => ({
|
||||
label: template.title,
|
||||
path: template.path,
|
||||
value: template.value,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const currentPage = pages[pages.length - 1];
|
||||
|
||||
@@ -222,19 +244,9 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
||||
/>
|
||||
|
||||
<CommandList>
|
||||
{isSearchingDocuments ? (
|
||||
<CommandEmpty>
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="animate-spin">
|
||||
<Loader />
|
||||
</span>
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
) : (
|
||||
<CommandEmpty>
|
||||
<Trans>No results found.</Trans>
|
||||
</CommandEmpty>
|
||||
)}
|
||||
<CommandEmpty>
|
||||
<Trans>No results found.</Trans>
|
||||
</CommandEmpty>
|
||||
|
||||
{!currentPage && (
|
||||
<>
|
||||
@@ -263,9 +275,27 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
{(isFetchingDocuments || documentSearchResults.length > 0) && (
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Your documents`)}>
|
||||
<Commands push={push} pages={searchResults} />
|
||||
{isFetchingDocuments ? (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<Commands push={push} pages={documentSearchResults} />
|
||||
)}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{(isFetchingTemplates || templateSearchResults.length > 0) && (
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Your templates`)}>
|
||||
{isFetchingTemplates ? (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<Commands push={push} pages={templateSearchResults} />
|
||||
)}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -23,7 +23,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { signupErrorMessages } from '~/components/forms/signup';
|
||||
import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup';
|
||||
|
||||
export type ClaimAccountProps = {
|
||||
defaultName: string;
|
||||
@@ -90,7 +90,8 @@ export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) =
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = signupErrorMessages[error.code] ?? signupErrorMessages.INVALID_REQUEST;
|
||||
const errorMessage =
|
||||
SIGNUP_ERROR_MESSAGES[error.code] ?? SIGNUP_ERROR_MESSAGES.INVALID_REQUEST;
|
||||
|
||||
toast({
|
||||
title: _(msg`An error occurred`),
|
||||
|
||||
@@ -9,16 +9,17 @@ 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 { 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';
|
||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider';
|
||||
import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider';
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
|
||||
import {
|
||||
DirectTemplateConfigureForm,
|
||||
@@ -153,9 +154,15 @@ export const DirectTemplatePageView = ({
|
||||
<CardContent className="p-2">
|
||||
<PDFViewerLazy
|
||||
key={template.id}
|
||||
envelopeItem={template.envelopeItems[0]}
|
||||
token={directTemplateRecipient.token}
|
||||
version="signed"
|
||||
data={getDocumentDataUrlForPdfViewer({
|
||||
envelopeId: template.envelopeId,
|
||||
envelopeItemId: template.envelopeItems[0]?.id,
|
||||
documentDataId: template.templateDocumentDataId,
|
||||
version: 'current',
|
||||
token: directTemplateRecipient.token,
|
||||
presignToken: undefined,
|
||||
})}
|
||||
scrollParentRef="window"
|
||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
@@ -82,8 +82,6 @@ export const DirectTemplateSigningForm = ({
|
||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const highestPageNumber = Math.max(...localFields.map((field) => field.page));
|
||||
|
||||
const fieldsRequiringValidation = useMemo(() => {
|
||||
return localFields.filter((field) => isFieldUnsignedAndRequired(field));
|
||||
}, [localFields]);
|
||||
@@ -250,9 +248,7 @@ export const DirectTemplateSigningForm = ({
|
||||
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
||||
|
||||
<DocumentFlowFormContainerContent>
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||
>
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{validateUninsertedFields && uninsertedFields[0] && (
|
||||
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
|
||||
+5
-1
@@ -117,7 +117,7 @@ export const DocumentSigningCompleteDialog = ({
|
||||
|
||||
const recipientForm = useForm<TDirectRecipientFormSchema>({
|
||||
resolver: zodResolver(ZDirectRecipientFormSchema),
|
||||
defaultValues: {
|
||||
values: {
|
||||
name: recipientPayload?.name ?? '',
|
||||
email: recipientPayload?.email ?? '',
|
||||
},
|
||||
@@ -157,6 +157,10 @@ export const DocumentSigningCompleteDialog = ({
|
||||
}
|
||||
|
||||
recipientOverridePayload = recipientForm.getValues();
|
||||
} else if (recipientPayload && recipientPayload.email && !recipient.email) {
|
||||
// Form is hidden because we have an email (e.g. from embed context),
|
||||
// but the DB recipient doesn't have one yet — send the override.
|
||||
recipientOverridePayload = recipientPayload;
|
||||
}
|
||||
|
||||
// Check if 2FA is required
|
||||
|
||||
+48
-52
@@ -9,7 +9,7 @@ import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||
import { RECIPIENT_COLOR_STYLES } from '@documenso/ui/lib/recipient-colors';
|
||||
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
|
||||
@@ -130,11 +130,8 @@ export const DocumentSigningFieldContainer = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('[container-type:size]')}>
|
||||
<FieldRootContainer
|
||||
color={
|
||||
field.fieldMeta?.readOnly ? RECIPIENT_COLOR_STYLES.readOnly : RECIPIENT_COLOR_STYLES.green
|
||||
}
|
||||
<FieldRootContainer
|
||||
color={getRecipientColorStyles(field.fieldMeta?.readOnly ? 'readOnly' : 0)}
|
||||
field={field}
|
||||
>
|
||||
{!field.inserted && !loading && !readOnlyField && (
|
||||
@@ -145,54 +142,53 @@ export const DocumentSigningFieldContainer = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
||||
<button
|
||||
className="absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
|
||||
onClick={() => void onClearCheckBoxValues(type)}
|
||||
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
||||
<button
|
||||
className="absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
|
||||
onClick={() => void onClearCheckBoxValues(type)}
|
||||
>
|
||||
<span className="rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
|
||||
<X className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<button className="absolute inset-0 z-10" onClick={onRemoveSignedFieldClick}></button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent
|
||||
className="border-0 bg-orange-300 fill-orange-300 text-orange-900"
|
||||
sideOffset={2}
|
||||
>
|
||||
<span className="rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
|
||||
<X className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
{tooltipText && <p>{tooltipText}</p>}
|
||||
|
||||
<Trans>Remove</Trans>
|
||||
<TooltipArrow />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
|
||||
field.fieldMeta?.label && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute -top-16 left-0 right-0 rounded-md p-2 text-center text-xs text-gray-700',
|
||||
{
|
||||
'border border-border bg-foreground/5': !field.inserted,
|
||||
},
|
||||
{
|
||||
'border border-primary bg-documenso-200': field.inserted,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{field.fieldMeta.label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<button className="absolute inset-0 z-10" onClick={onRemoveSignedFieldClick}></button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent
|
||||
className="border-0 bg-orange-300 fill-orange-300 text-orange-900"
|
||||
sideOffset={2}
|
||||
>
|
||||
{tooltipText && <p>{tooltipText}</p>}
|
||||
|
||||
<Trans>Remove</Trans>
|
||||
<TooltipArrow />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
|
||||
field.fieldMeta?.label && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute -top-16 left-0 right-0 rounded-md p-2 text-center text-xs text-gray-700',
|
||||
{
|
||||
'bg-foreground/5 border-border border': !field.inserted,
|
||||
},
|
||||
{
|
||||
'bg-documenso-200 border-primary border': field.inserted,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{field.fieldMeta.label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</FieldRootContainer>
|
||||
</div>
|
||||
{children}
|
||||
</FieldRootContainer>
|
||||
);
|
||||
};
|
||||
|
||||
+13
-10
@@ -22,6 +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 { 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';
|
||||
@@ -30,7 +31,6 @@ import { DocumentReadOnlyFields } from '@documenso/ui/components/document/docume
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
|
||||
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
|
||||
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
|
||||
@@ -46,6 +46,7 @@ import { DocumentSigningRadioField } from '~/components/general/document-signing
|
||||
import { DocumentSigningRejectDialog } from '~/components/general/document-signing/document-signing-reject-dialog';
|
||||
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
||||
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||
@@ -162,8 +163,6 @@ export const DocumentSigningPageViewV1 = ({
|
||||
: undefined;
|
||||
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
||||
|
||||
const highestPageNumber = Math.max(...fields.map((field) => field.page));
|
||||
|
||||
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
|
||||
const hasPendingFields = pendingFields.length > 0;
|
||||
|
||||
@@ -275,10 +274,16 @@ export const DocumentSigningPageViewV1 = ({
|
||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewerLazy
|
||||
key={document.envelopeItems[0].id}
|
||||
envelopeItem={document.envelopeItems[0]}
|
||||
token={recipient.token}
|
||||
version="signed"
|
||||
key={document.envelopeItems[0]?.id}
|
||||
data={getDocumentDataUrlForPdfViewer({
|
||||
envelopeId: document.envelopeId,
|
||||
envelopeItemId: document.envelopeItems[0]?.id,
|
||||
documentDataId: document.envelopeItems[0]?.documentData.id,
|
||||
version: 'current',
|
||||
token: recipient.token,
|
||||
presignToken: undefined,
|
||||
})}
|
||||
scrollParentRef="window"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -400,9 +405,7 @@ export const DocumentSigningPageViewV1 = ({
|
||||
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
|
||||
)}
|
||||
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||
>
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{fields
|
||||
.filter(
|
||||
(field) =>
|
||||
|
||||
+168
-117
@@ -1,15 +1,23 @@
|
||||
import { lazy, useMemo } from 'react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { Plural, Trans, useLingui } from '@lingui/react/macro';
|
||||
import { EnvelopeType, RecipientRole } from '@prisma/client';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeftIcon, BanIcon, DownloadCloudIcon, PaperclipIcon } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
BanIcon,
|
||||
DownloadCloudIcon,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PaperclipIcon,
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
@@ -23,6 +31,8 @@ import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-di
|
||||
import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog';
|
||||
import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog';
|
||||
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||
import { EnvelopeSignerPageRenderer } from '~/components/general/envelope-signing/envelope-signer-page-renderer';
|
||||
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
|
||||
|
||||
import { BrandingLogo } from '../branding-logo';
|
||||
import { DocumentSigningAttachmentsPopover } from '../document-signing/document-signing-attachments-popover';
|
||||
@@ -33,13 +43,11 @@ import { DocumentSigningMobileWidget } from './document-signing-mobile-widget';
|
||||
import { DocumentSigningRejectDialog } from './document-signing-reject-dialog';
|
||||
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
|
||||
|
||||
const EnvelopeSignerPageRenderer = lazy(
|
||||
async () => import('~/components/general/envelope-signing/envelope-signer-page-renderer'),
|
||||
);
|
||||
|
||||
export const DocumentSigningPageViewV2 = () => {
|
||||
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
isDirectTemplate,
|
||||
envelope,
|
||||
@@ -57,6 +65,9 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
onDocumentRejected,
|
||||
} = useEmbedSigningContext() || {};
|
||||
|
||||
const { t } = useLingui();
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
|
||||
/**
|
||||
* The total remaining fields remaining for the current recipient or selected assistant recipient.
|
||||
*
|
||||
@@ -86,120 +97,159 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
{/* Main Content Area */}
|
||||
<div className="flex h-[calc(100vh-4rem)] w-screen">
|
||||
{/* Left Section - Step Navigation */}
|
||||
<div className="embed--DocumentWidgetContainer hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4 lg:flex">
|
||||
<div className="px-4">
|
||||
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
|
||||
.otherwise(() => null)}
|
||||
|
||||
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<Plural
|
||||
value={recipientFieldsRemaining.length}
|
||||
one="1 Field Remaining"
|
||||
other="# Fields Remaining"
|
||||
/>
|
||||
</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 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="embed--DocumentWidgetContent mt-6 space-y-3">
|
||||
<EnvelopeSignerForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
{/* Quick Actions. */}
|
||||
{!isDirectTemplate && (
|
||||
<div className="embed--Actions space-y-3 px-4">
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
<Trans>Actions</Trans>
|
||||
</h4>
|
||||
|
||||
<DocumentSigningAttachmentsPopover
|
||||
envelopeId={envelope.id}
|
||||
token={recipient.token}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<PaperclipIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Attachments</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={envelope.id}
|
||||
envelopeStatus={envelope.status}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={recipient.token}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<DownloadCloudIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Download PDF</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection && (
|
||||
<DocumentSigningRejectDialog
|
||||
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||
token={recipient.token}
|
||||
onRejected={
|
||||
onDocumentRejected &&
|
||||
((reason) =>
|
||||
onDocumentRejected({
|
||||
token: recipient.token,
|
||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
envelopeId: envelope.id,
|
||||
recipientId: recipient.id,
|
||||
reason,
|
||||
}))
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start hover:text-destructive"
|
||||
>
|
||||
<BanIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Reject Document</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'embed--DocumentWidgetContainer hidden flex-shrink-0 flex-col border-r border-border bg-background transition-[width] duration-300 lg:flex',
|
||||
isSidebarCollapsed ? 'w-12' : 'w-80',
|
||||
)}
|
||||
>
|
||||
{isSidebarCollapsed && (
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label={t`Expand sidebar`}
|
||||
onClick={() => setIsSidebarCollapsed(false)}
|
||||
>
|
||||
<PanelLeftOpenIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="embed--DocumentWidgetFooter mt-auto">
|
||||
{/* Footer of left sidebar. */}
|
||||
{!isEmbed && (
|
||||
<div className="px-4">
|
||||
<Button asChild variant="ghost" className="w-full justify-start">
|
||||
<Link to="/">
|
||||
<ArrowLeftIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Return</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 flex-col overflow-hidden py-4',
|
||||
isSidebarCollapsed && 'invisible w-0',
|
||||
)}
|
||||
>
|
||||
<div className="px-4">
|
||||
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
|
||||
.otherwise(() => null)}
|
||||
|
||||
<div className="ml-2 flex items-center gap-1">
|
||||
<span className="rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<Plural
|
||||
value={recipientFieldsRemaining.length}
|
||||
one="1 Field Remaining"
|
||||
other="# Fields Remaining"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label={t`Collapse sidebar`}
|
||||
onClick={() => setIsSidebarCollapsed(true)}
|
||||
>
|
||||
<PanelLeftCloseIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</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 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="embed--DocumentWidgetContent mt-6 space-y-3">
|
||||
<EnvelopeSignerForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
{/* Quick Actions. */}
|
||||
{!isDirectTemplate && (
|
||||
<div className="embed--Actions space-y-3 px-4">
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
<Trans>Actions</Trans>
|
||||
</h4>
|
||||
|
||||
<DocumentSigningAttachmentsPopover
|
||||
envelopeId={envelope.id}
|
||||
token={recipient.token}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<PaperclipIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Attachments</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={envelope.id}
|
||||
envelopeStatus={envelope.status}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={recipient.token}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<DownloadCloudIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Download PDF</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection && (
|
||||
<DocumentSigningRejectDialog
|
||||
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||
token={recipient.token}
|
||||
onRejected={
|
||||
onDocumentRejected &&
|
||||
((reason) =>
|
||||
onDocumentRejected({
|
||||
token: recipient.token,
|
||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
envelopeId: envelope.id,
|
||||
recipientId: recipient.id,
|
||||
reason,
|
||||
}))
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start hover:text-destructive"
|
||||
>
|
||||
<BanIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Reject Document</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="embed--DocumentWidgetFooter mt-auto">
|
||||
{/* Footer of left sidebar. */}
|
||||
{!isEmbed && (
|
||||
<div className="px-4">
|
||||
<Button asChild variant="ghost" className="w-full justify-start">
|
||||
<Link to="/">
|
||||
<ArrowLeftIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Return</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="embed--DocumentContainer flex-1 overflow-y-auto">
|
||||
<div
|
||||
className="embed--DocumentContainer min-w-0 flex-1 overflow-y-auto"
|
||||
ref={scrollableContainerRef}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{/* Horizontal envelope item selector */}
|
||||
{envelopeItems.length > 1 && (
|
||||
@@ -228,15 +278,16 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
{/* Document View */}
|
||||
<div className="embed--DocumentViewer flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
|
||||
{currentEnvelopeItem ? (
|
||||
<PDFViewerKonvaLazy
|
||||
renderer="signing"
|
||||
<EnvelopePdfViewer
|
||||
key={currentEnvelopeItem.id}
|
||||
customPageRenderer={EnvelopeSignerPageRenderer}
|
||||
scrollParentRef={scrollableContainerRef}
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.signing}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<p className="text-sm text-foreground">
|
||||
<Trans>No documents found</Trans>
|
||||
<Trans>No document selected</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy, useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client';
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
EnvelopeRenderProvider,
|
||||
useCurrentEnvelopeRender,
|
||||
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -21,15 +22,13 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
|
||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
|
||||
import { EnvelopeRendererFileSelector } from '../envelope-editor/envelope-file-selector';
|
||||
|
||||
const EnvelopeGenericPageRenderer = lazy(
|
||||
async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'),
|
||||
);
|
||||
import { EnvelopeGenericPageRenderer } from '../envelope-editor/envelope-generic-page-renderer';
|
||||
|
||||
export type DocumentCertificateQRViewProps = {
|
||||
documentId: number;
|
||||
@@ -104,11 +103,13 @@ export const DocumentCertificateQRView = ({
|
||||
|
||||
{internalVersion === 2 ? (
|
||||
<EnvelopeRenderProvider
|
||||
version="current"
|
||||
envelope={{
|
||||
envelopeItems,
|
||||
id: envelopeItems[0].envelopeId,
|
||||
status: DocumentStatus.COMPLETED,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
}}
|
||||
envelopeItems={envelopeItems}
|
||||
token={token}
|
||||
>
|
||||
<DocumentCertificateQrV2
|
||||
@@ -150,10 +151,16 @@ export const DocumentCertificateQRView = ({
|
||||
|
||||
<div className="mt-12 w-full">
|
||||
<PDFViewerLazy
|
||||
key={envelopeItems[0].id}
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={token}
|
||||
version="signed"
|
||||
key={envelopeItems[0]?.id}
|
||||
data={getDocumentDataUrlForPdfViewer({
|
||||
envelopeId: envelopeItems[0]?.envelopeId,
|
||||
envelopeItemId: envelopeItems[0]?.id,
|
||||
documentDataId: envelopeItems[0]?.documentDataId,
|
||||
version: 'current',
|
||||
token,
|
||||
presignToken: undefined,
|
||||
})}
|
||||
scrollParentRef="window"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -175,7 +182,7 @@ const DocumentCertificateQrV2 = ({
|
||||
formattedDate,
|
||||
token,
|
||||
}: DocumentCertificateQrV2Props) => {
|
||||
const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender();
|
||||
const { envelopeItems } = useCurrentEnvelopeRender();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-start">
|
||||
@@ -210,7 +217,11 @@ const DocumentCertificateQrV2 = ({
|
||||
<div className="mt-12 w-full">
|
||||
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
|
||||
|
||||
<PDFViewerKonvaLazy renderer="preview" customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||
<EnvelopePdfViewer
|
||||
scrollParentRef="window"
|
||||
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,6 +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 { 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';
|
||||
@@ -27,10 +28,10 @@ import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/ad
|
||||
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type DocumentEditFormProps = {
|
||||
@@ -441,10 +442,16 @@ export const DocumentEditForm = ({
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewerLazy
|
||||
key={document.envelopeItems[0].id}
|
||||
envelopeItem={document.envelopeItems[0]}
|
||||
token={undefined}
|
||||
version="signed"
|
||||
key={document.envelopeItems[0]?.id}
|
||||
data={getDocumentDataUrlForPdfViewer({
|
||||
envelopeId: document.envelopeId,
|
||||
envelopeItemId: document.envelopeItems[0]?.id,
|
||||
documentDataId: initialDocument.documentDataId,
|
||||
version: 'current',
|
||||
token: undefined,
|
||||
presignToken: undefined,
|
||||
})}
|
||||
scrollParentRef="window"
|
||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+11
-26
@@ -23,7 +23,7 @@ import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||
import { RECIPIENT_COLOR_STYLES } from '@documenso/ui/lib/recipient-colors';
|
||||
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||
|
||||
@@ -175,15 +175,6 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
|
||||
const { top, left, height, width } = getBoundingClientRect($page);
|
||||
|
||||
console.log({
|
||||
top,
|
||||
left,
|
||||
height,
|
||||
width,
|
||||
rawPageX: event.pageX,
|
||||
rawPageY: event.pageY,
|
||||
});
|
||||
|
||||
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
|
||||
|
||||
// Calculate x and y as a percentage of the page width and height
|
||||
@@ -262,9 +253,10 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
};
|
||||
}, [onMouseClick, onMouseMove, selectedField]);
|
||||
|
||||
const selectedRecipientColor = useMemo(() => {
|
||||
return selectedRecipientId ? getRecipientColorKey(selectedRecipientId) : 'green';
|
||||
}, [selectedRecipientId, getRecipientColorKey]);
|
||||
const selectedRecipientStyles = useMemo(
|
||||
() => getRecipientColorStyles(getRecipientColorKey(selectedRecipientId ?? -1)),
|
||||
[selectedRecipientId, getRecipientColorKey],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -278,22 +270,15 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
onMouseDown={() => setSelectedField(field.type)}
|
||||
data-selected={selectedField === field.type ? true : undefined}
|
||||
className={cn(
|
||||
'border-border group flex h-12 cursor-pointer items-center justify-center rounded-lg border px-4 transition-colors',
|
||||
RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton,
|
||||
'group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-border px-4 transition-colors',
|
||||
selectedRecipientStyles.fieldButton,
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground font-noto group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||
'flex items-center justify-center gap-x-1.5 font-noto text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
|
||||
field.className,
|
||||
{
|
||||
'group-hover:text-recipient-green': selectedRecipientColor === 'green',
|
||||
'group-hover:text-recipient-blue': selectedRecipientColor === 'blue',
|
||||
'group-hover:text-recipient-purple': selectedRecipientColor === 'purple',
|
||||
'group-hover:text-recipient-orange': selectedRecipientColor === 'orange',
|
||||
'group-hover:text-recipient-yellow': selectedRecipientColor === 'yellow',
|
||||
'group-hover:text-recipient-pink': selectedRecipientColor === 'pink',
|
||||
},
|
||||
selectedRecipientStyles.fieldButtonText,
|
||||
)}
|
||||
>
|
||||
{field.type !== FieldType.SIGNATURE && <field.icon className="h-4 w-4" />}
|
||||
@@ -306,8 +291,8 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
{selectedField && (
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground dark:text-muted-background font-noto pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
|
||||
RECIPIENT_COLOR_STYLES[selectedRecipientColor].base,
|
||||
'dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size]',
|
||||
selectedRecipientStyles.base,
|
||||
selectedField === FieldType.SIGNATURE && 'font-signature',
|
||||
{
|
||||
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
|
||||
|
||||
+17
-38
@@ -10,7 +10,10 @@ import { CopyPlusIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide
|
||||
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
|
||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import {
|
||||
type PageRenderData,
|
||||
useCurrentEnvelopeRender,
|
||||
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||
import {
|
||||
MIN_FIELD_HEIGHT_PX,
|
||||
@@ -25,7 +28,7 @@ import { CommandDialog } from '@documenso/ui/primitives/command';
|
||||
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
|
||||
import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector';
|
||||
|
||||
export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
|
||||
const { t, i18n } = useLingui();
|
||||
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
||||
@@ -37,34 +40,22 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
const [isFieldChanging, setIsFieldChanging] = useState(false);
|
||||
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
|
||||
|
||||
const {
|
||||
stage,
|
||||
pageLayer,
|
||||
canvasElement,
|
||||
konvaContainer,
|
||||
pageContext,
|
||||
scaledViewport,
|
||||
unscaledViewport,
|
||||
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
|
||||
const { stage, pageLayer, konvaContainer, scaledViewport, unscaledViewport } = usePageRenderer(
|
||||
({ stage, pageLayer }) => createPageCanvas(stage, pageLayer),
|
||||
pageData,
|
||||
);
|
||||
|
||||
const { _className, scale } = pageContext;
|
||||
const { scale, pageNumber } = pageData;
|
||||
|
||||
const localPageFields = useMemo(
|
||||
() =>
|
||||
editorFields.localFields.filter(
|
||||
(field) =>
|
||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
),
|
||||
[editorFields.localFields, pageContext.pageNumber],
|
||||
[editorFields.localFields, pageNumber, currentEnvelopeItem?.id],
|
||||
);
|
||||
|
||||
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
|
||||
const { current: container } = canvasElement;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isDragEvent = event.type === 'dragend';
|
||||
|
||||
const fieldGroup = event.target as Konva.Group;
|
||||
@@ -344,7 +335,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
// Create a field if no items are selected or the size is too small.
|
||||
if (
|
||||
selectedFieldGroups.length === 0 &&
|
||||
canvasElement.current &&
|
||||
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
|
||||
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
|
||||
editorFields.selectedRecipient &&
|
||||
@@ -531,7 +521,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
|
||||
removePendingField();
|
||||
|
||||
if (!canvasElement.current || !currentEnvelopeItem || !editorFields.selectedRecipient) {
|
||||
if (!currentEnvelopeItem || !editorFields.selectedRecipient) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -546,7 +536,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
|
||||
editorFields.addField({
|
||||
envelopeItemId: currentEnvelopeItem.id,
|
||||
page: pageContext.pageNumber,
|
||||
page: pageNumber,
|
||||
type,
|
||||
positionX: fieldX,
|
||||
positionY: fieldY,
|
||||
@@ -575,10 +565,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full"
|
||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
||||
>
|
||||
<>
|
||||
{selectedKonvaFieldGroups.length > 0 &&
|
||||
interactiveTransformer.current &&
|
||||
!isFieldChanging && (
|
||||
@@ -640,17 +627,9 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
|
||||
{/* The element Konva will inject it's canvas into. */}
|
||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
||||
|
||||
{/* Canvas the PDF will be rendered on. */}
|
||||
<canvas
|
||||
className={`${_className}__canvas z-0`}
|
||||
ref={canvasElement}
|
||||
height={scaledViewport.height}
|
||||
width={scaledViewport.width}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type FieldActionButtonsProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
handleDuplicateSelectedFields: () => void;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -6,12 +6,13 @@ 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';
|
||||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
||||
import {
|
||||
FIELD_META_DEFAULT_VALUES,
|
||||
@@ -29,7 +30,6 @@ import {
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
@@ -46,16 +46,14 @@ import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-nu
|
||||
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
|
||||
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
|
||||
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
|
||||
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
|
||||
import { EnvelopeEditorFieldsPageRenderer } from './envelope-editor-fields-page-renderer';
|
||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
|
||||
|
||||
const EnvelopeEditorFieldsPageRenderer = lazy(
|
||||
async () => import('~/components/general/envelope-editor/envelope-editor-fields-page-renderer'),
|
||||
);
|
||||
|
||||
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
||||
[FieldType.SIGNATURE]: msg`Signature Settings`,
|
||||
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
|
||||
@@ -75,7 +73,9 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
|
||||
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -156,12 +152,15 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full">
|
||||
<div className="flex w-full flex-col overflow-y-auto">
|
||||
<div
|
||||
className="flex h-full w-full flex-col overflow-y-auto px-2"
|
||||
ref={scrollableContainerRef}
|
||||
>
|
||||
{/* Horizontal envelope item selector */}
|
||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||
<EnvelopeRendererFileSelector className="px-0" fields={editorFields.localFields} />
|
||||
|
||||
{/* Document View */}
|
||||
<div className="mt-4 flex flex-col items-center justify-center">
|
||||
<div className="mt-4 flex h-full flex-col items-center justify-center">
|
||||
{envelope.recipients.length === 0 && (
|
||||
<Alert
|
||||
variant="neutral"
|
||||
@@ -176,18 +175,17 @@ 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>
|
||||
)}
|
||||
|
||||
{currentEnvelopeItem !== null ? (
|
||||
<PDFViewerKonvaLazy
|
||||
renderer="editor"
|
||||
<EnvelopePdfViewer
|
||||
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
|
||||
scrollParentRef={scrollableContainerRef}
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
@@ -249,36 +247,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. */}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType, TemplateType } from '@prisma/client';
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
Building2Icon,
|
||||
Globe2Icon,
|
||||
LockIcon,
|
||||
RefreshCwIcon,
|
||||
@@ -22,6 +23,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 +32,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({
|
||||
@@ -58,12 +95,19 @@ export default function EnvelopeEditorHeader() {
|
||||
|
||||
{envelope.type === EnvelopeType.TEMPLATE && (
|
||||
<>
|
||||
{envelope.templateType === 'PRIVATE' ? (
|
||||
{envelope.templateType === TemplateType.PRIVATE && (
|
||||
<Badge variant="secondary">
|
||||
<LockIcon className="mr-2 h-4 w-4 text-blue-600 dark:text-blue-300" />
|
||||
<Trans>Private Template</Trans>
|
||||
</Badge>
|
||||
) : (
|
||||
)}
|
||||
{envelope.templateType === TemplateType.ORGANISATION && (
|
||||
<Badge variant="orange">
|
||||
<Building2Icon className="mr-2 size-4" />
|
||||
<Trans>Organisation Template</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
{envelope.templateType === TemplateType.PUBLIC && (
|
||||
<Badge variant="default">
|
||||
<Globe2Icon className="mr-2 h-4 w-4 text-green-500 dark:text-green-300" />
|
||||
<Trans>Public Template</Trans>
|
||||
@@ -127,54 +171,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>
|
||||
|
||||
+44
-24
@@ -1,6 +1,6 @@
|
||||
import { lazy, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { faker } from '@faker-js/faker/locale/en';
|
||||
import type { Faker } from '@faker-js/faker';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { FieldType, SigningStatus } from '@prisma/client';
|
||||
import { FileTextIcon } from 'lucide-react';
|
||||
@@ -11,33 +11,46 @@ import {
|
||||
EnvelopeRenderProvider,
|
||||
useCurrentEnvelopeRender,
|
||||
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
|
||||
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
|
||||
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
|
||||
|
||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||
|
||||
const EnvelopeGenericPageRenderer = lazy(
|
||||
async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'),
|
||||
);
|
||||
|
||||
// Todo: Envelopes - Dynamically import faker
|
||||
export const EnvelopeEditorPreviewPage = () => {
|
||||
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
||||
const { envelope, editorFields, editorConfig } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
|
||||
'recipient',
|
||||
);
|
||||
|
||||
const [fakerInstance, setFakerInstance] = useState<Faker | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void import('@faker-js/faker/locale/en').then((mod) => {
|
||||
setFakerInstance(mod.faker);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fieldsWithPlaceholders = useMemo(() => {
|
||||
if (!fakerInstance) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const faker = fakerInstance;
|
||||
return fields.map((field) => {
|
||||
const fieldMeta = ZFieldAndMetaSchema.parse(field);
|
||||
|
||||
@@ -188,7 +201,7 @@ export const EnvelopeEditorPreviewPage = () => {
|
||||
.exhaustive(),
|
||||
};
|
||||
});
|
||||
}, [fields, envelope, envelope.recipients, envelope.documentMeta]);
|
||||
}, [fields, envelope, envelope.recipients, envelope.documentMeta, fakerInstance]);
|
||||
|
||||
/**
|
||||
* Set the selected recipient to the first recipient in the envelope.
|
||||
@@ -200,37 +213,44 @@ export const EnvelopeEditorPreviewPage = () => {
|
||||
// Override the parent renderer provider so we can inject custom fields.
|
||||
return (
|
||||
<EnvelopeRenderProvider
|
||||
version="current"
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={undefined}
|
||||
fields={fieldsWithPlaceholders}
|
||||
recipients={envelope.recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
}))}
|
||||
presignToken={editorConfig?.embedded?.presignToken}
|
||||
overrideSettings={{
|
||||
mode: 'export',
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-full">
|
||||
<div className="flex w-full flex-col overflow-y-auto">
|
||||
<div
|
||||
className="flex h-full w-full flex-col overflow-y-auto px-2"
|
||||
ref={scrollableContainerRef}
|
||||
>
|
||||
{/* Horizontal envelope item selector */}
|
||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||
<EnvelopeRendererFileSelector className="px-0" fields={editorFields.localFields} />
|
||||
|
||||
<Alert variant="warning" className="mx-auto max-w-[800px]">
|
||||
<AlertTitle>
|
||||
<Trans>Preview Mode</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>Preview what the signed document will look like with placeholder data</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Document View */}
|
||||
<div className="mt-4 flex flex-col items-center justify-center">
|
||||
<Alert variant="warning" className="mb-4 max-w-[800px]">
|
||||
<AlertTitle>
|
||||
<Trans>Preview Mode</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>Preview what the signed document will look like with placeholder data</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="mt-4 flex h-full flex-col items-center justify-center">
|
||||
{currentEnvelopeItem !== null ? (
|
||||
<PDFViewerKonvaLazy
|
||||
renderer="editor"
|
||||
<EnvelopePdfViewer
|
||||
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||
scrollParentRef={scrollableContainerRef}
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
|
||||
+400
-374
@@ -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}
|
||||
|
||||
+28
@@ -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>
|
||||
);
|
||||
};
|
||||
+456
-378
@@ -1,14 +1,15 @@
|
||||
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,
|
||||
DocumentVisibility,
|
||||
EnvelopeType,
|
||||
SendStatus,
|
||||
TemplateType,
|
||||
} from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { InfoIcon, MailIcon, SettingsIcon, ShieldIcon } from 'lucide-react';
|
||||
@@ -30,6 +31,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,
|
||||
@@ -65,6 +67,10 @@ import {
|
||||
DocumentVisibilityTooltip,
|
||||
} from '@documenso/ui/components/document/document-visibility-select';
|
||||
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
|
||||
import {
|
||||
TemplateTypeSelect,
|
||||
TemplateTypeTooltip,
|
||||
} from '@documenso/ui/components/template/template-type-select';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
|
||||
@@ -101,6 +107,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export const ZAddSettingsFormSchema = z.object({
|
||||
templateType: z.nativeEnum(TemplateType).optional(),
|
||||
externalId: z.string().optional(),
|
||||
visibility: z.nativeEnum(DocumentVisibility).optional(),
|
||||
globalAccessAuth: z
|
||||
@@ -175,10 +182,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();
|
||||
@@ -192,6 +202,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
|
||||
const createDefaultValues = () => {
|
||||
return {
|
||||
templateType: envelope.templateType || TemplateType.PRIVATE,
|
||||
externalId: envelope.externalId || '',
|
||||
visibility: envelope.visibility || '',
|
||||
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
|
||||
@@ -228,12 +239,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);
|
||||
|
||||
@@ -260,6 +277,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
try {
|
||||
await updateEnvelopeAsync({
|
||||
data: {
|
||||
templateType: envelope.type === EnvelopeType.TEMPLATE ? data.templateType : undefined,
|
||||
externalId: data.externalId || null,
|
||||
visibility: data.visibility,
|
||||
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
|
||||
@@ -285,11 +303,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 +346,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
|
||||
const selectedTab = tabs.find((tab) => tab.id === activeTab);
|
||||
|
||||
if (!selectedTab) {
|
||||
if (!selectedTab || !settings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -347,34 +367,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 +410,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 +613,269 @@ 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 && (
|
||||
{envelope.type === EnvelopeType.TEMPLATE && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.emailId"
|
||||
name="templateType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Email Sender</Trans>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Template type</Trans>
|
||||
<TemplateTypeTooltip
|
||||
organisationTeamCount={organisation.teams.length}
|
||||
/>
|
||||
</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 />
|
||||
<TemplateTypeSelect
|
||||
value={field.value}
|
||||
disabled={field.disabled}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{settings.allowConfigureDistribution && (
|
||||
<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>
|
||||
{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 +921,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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
+192
-68
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -62,7 +62,12 @@ export const EnvelopeRendererFileSelector = ({
|
||||
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-fit flex-shrink-0 space-x-2 overflow-x-auto p-4', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'scrollbar-hidden flex h-fit flex-shrink-0 space-x-2 overflow-x-auto p-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{envelopeItems.map((doc, i) => (
|
||||
<EnvelopeItemSelector
|
||||
key={doc.id}
|
||||
|
||||
+18
-31
@@ -5,7 +5,10 @@ import { DocumentStatus, type Recipient, SigningStatus } from '@prisma/client';
|
||||
import type Konva from 'konva';
|
||||
|
||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import {
|
||||
type PageRenderData,
|
||||
useCurrentEnvelopeRender,
|
||||
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
|
||||
@@ -15,7 +18,7 @@ type GenericLocalField = TEnvelope['fields'][number] & {
|
||||
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
|
||||
};
|
||||
|
||||
export default function EnvelopeGenericPageRenderer() {
|
||||
export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const {
|
||||
@@ -28,19 +31,14 @@ export default function EnvelopeGenericPageRenderer() {
|
||||
overrideSettings,
|
||||
} = useCurrentEnvelopeRender();
|
||||
|
||||
const {
|
||||
stage,
|
||||
pageLayer,
|
||||
canvasElement,
|
||||
konvaContainer,
|
||||
pageContext,
|
||||
scaledViewport,
|
||||
unscaledViewport,
|
||||
} = usePageRenderer(({ stage, pageLayer }) => {
|
||||
createPageCanvas(stage, pageLayer);
|
||||
});
|
||||
const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer(
|
||||
({ stage, pageLayer }) => {
|
||||
createPageCanvas(stage, pageLayer);
|
||||
},
|
||||
pageData,
|
||||
);
|
||||
|
||||
const { _className, scale } = pageContext;
|
||||
const { scale, pageNumber } = pageData;
|
||||
|
||||
const localPageFields = useMemo((): GenericLocalField[] => {
|
||||
if (envelopeStatus === DocumentStatus.COMPLETED) {
|
||||
@@ -49,8 +47,7 @@ export default function EnvelopeGenericPageRenderer() {
|
||||
|
||||
return fields
|
||||
.filter(
|
||||
(field) =>
|
||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
)
|
||||
.map((field) => {
|
||||
const recipient = recipients.find((recipient) => recipient.id === field.recipientId);
|
||||
@@ -73,7 +70,7 @@ export default function EnvelopeGenericPageRenderer() {
|
||||
(recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) ||
|
||||
fieldMeta?.readOnly,
|
||||
);
|
||||
}, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]);
|
||||
}, [fields, pageNumber, currentEnvelopeItem?.id, recipients, envelopeStatus]);
|
||||
|
||||
const unsafeRenderFieldOnLayer = (field: GenericLocalField) => {
|
||||
if (!pageLayer.current) {
|
||||
@@ -160,11 +157,9 @@ export default function EnvelopeGenericPageRenderer() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full"
|
||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
||||
>
|
||||
<>
|
||||
{overrideSettings?.showRecipientTooltip &&
|
||||
pageData.imageLoadingState === 'loaded' &&
|
||||
localPageFields.map((field) => (
|
||||
<EnvelopeRecipientFieldTooltip
|
||||
key={field.id}
|
||||
@@ -176,14 +171,6 @@ export default function EnvelopeGenericPageRenderer() {
|
||||
|
||||
{/* The element Konva will inject it's canvas into. */}
|
||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
||||
|
||||
{/* Canvas the PDF will be rendered on. */}
|
||||
<canvas
|
||||
className={`${_className}__canvas z-0`}
|
||||
ref={canvasElement}
|
||||
height={scaledViewport.height}
|
||||
width={scaledViewport.width}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -59,12 +59,8 @@ export const EnvelopeRecipientSelector = ({
|
||||
role="combobox"
|
||||
className={cn(
|
||||
'justify-between bg-background font-normal text-muted-foreground hover:text-foreground',
|
||||
getRecipientColorStyles(
|
||||
Math.max(
|
||||
recipients.findIndex((r) => r.id === selectedRecipient?.id),
|
||||
0,
|
||||
),
|
||||
).comboxBoxTrigger,
|
||||
getRecipientColorStyles(recipients.findIndex((r) => r.id === selectedRecipient?.id))
|
||||
.comboBoxTrigger,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -197,12 +193,8 @@ export const EnvelopeRecipientSelectorCommand = ({
|
||||
key={recipient.id}
|
||||
className={cn(
|
||||
'px-2 last:mb-1 [&:not(:first-child)]:mt-1',
|
||||
getRecipientColorStyles(
|
||||
Math.max(
|
||||
recipients.findIndex((r) => r.id === recipient.id),
|
||||
0,
|
||||
),
|
||||
).comboxBoxItem,
|
||||
getRecipientColorStyles(recipients.findIndex((r) => r.id === recipient.id))
|
||||
.comboBoxItem,
|
||||
{
|
||||
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
|
||||
'cursor-not-allowed': isRecipientDisabled(recipient.id),
|
||||
|
||||
+23
-40
@@ -14,7 +14,10 @@ import type { KonvaEventObject } from 'konva/lib/Node';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import {
|
||||
type PageRenderData,
|
||||
useCurrentEnvelopeRender,
|
||||
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
|
||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||
@@ -29,7 +32,6 @@ import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
|
||||
import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
|
||||
import { EnvelopeFieldToolTip } from '@documenso/ui/components/field/envelope-field-tooltip';
|
||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||
@@ -49,7 +51,7 @@ type GenericLocalField = TEnvelope['fields'][number] & {
|
||||
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
|
||||
};
|
||||
|
||||
export default function EnvelopeSignerPageRenderer() {
|
||||
export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
|
||||
const { t, i18n } = useLingui();
|
||||
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
||||
const { sessionData } = useOptionalSession();
|
||||
@@ -77,17 +79,12 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
|
||||
const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {};
|
||||
|
||||
const {
|
||||
stage,
|
||||
pageLayer,
|
||||
canvasElement,
|
||||
konvaContainer,
|
||||
pageContext,
|
||||
scaledViewport,
|
||||
unscaledViewport,
|
||||
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
|
||||
const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer(
|
||||
({ stage, pageLayer }) => createPageCanvas(stage, pageLayer),
|
||||
pageData,
|
||||
);
|
||||
|
||||
const { _className, scale } = pageContext;
|
||||
const { scale, pageNumber } = pageData;
|
||||
|
||||
const { envelope } = envelopeData;
|
||||
|
||||
@@ -99,10 +96,9 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
}
|
||||
|
||||
return fieldsToRender.filter(
|
||||
(field) =>
|
||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
);
|
||||
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
|
||||
}, [recipientFields, selectedAssistantRecipientFields, pageNumber, currentEnvelopeItem?.id]);
|
||||
|
||||
/**
|
||||
* Returns fields that have been fully signed by other recipients for this specific
|
||||
@@ -117,7 +113,7 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
return recipient.fields
|
||||
.filter(
|
||||
(field) =>
|
||||
field.page === pageContext.pageNumber &&
|
||||
field.page === pageNumber &&
|
||||
field.envelopeItemId === currentEnvelopeItem?.id &&
|
||||
(field.inserted || field.fieldMeta?.readOnly),
|
||||
)
|
||||
@@ -132,7 +128,7 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
},
|
||||
}));
|
||||
});
|
||||
}, [envelope.recipients, pageContext.pageNumber]);
|
||||
}, [envelope.recipients, pageNumber, currentEnvelopeItem?.id]);
|
||||
|
||||
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
|
||||
if (!pageLayer.current) {
|
||||
@@ -142,13 +138,11 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
|
||||
const fieldToRender = ZFullFieldSchema.parse(unparsedField);
|
||||
|
||||
let color: TRecipientColor = 'green';
|
||||
|
||||
if (fieldToRender.fieldMeta?.readOnly) {
|
||||
color = 'readOnly';
|
||||
} else if (showPendingFieldTooltip && isFieldUnsignedAndRequired(fieldToRender)) {
|
||||
color = 'orange';
|
||||
}
|
||||
const color = fieldToRender.fieldMeta?.readOnly
|
||||
? 'readOnly'
|
||||
: showPendingFieldTooltip && isFieldUnsignedAndRequired(fieldToRender)
|
||||
? 'orange'
|
||||
: 'green';
|
||||
|
||||
const { fieldGroup } = renderField({
|
||||
scale,
|
||||
@@ -534,14 +528,11 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full"
|
||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
||||
>
|
||||
<>
|
||||
{showPendingFieldTooltip &&
|
||||
recipientFieldsRemaining.length > 0 &&
|
||||
recipientFieldsRemaining[0]?.envelopeItemId === currentEnvelopeItem?.id &&
|
||||
recipientFieldsRemaining[0]?.page === pageContext.pageNumber && (
|
||||
recipientFieldsRemaining[0]?.page === pageNumber && (
|
||||
<EnvelopeFieldToolTip
|
||||
key={recipientFieldsRemaining[0].id}
|
||||
field={recipientFieldsRemaining[0]}
|
||||
@@ -562,14 +553,6 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
|
||||
{/* The element Konva will inject it's canvas into. */}
|
||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
||||
|
||||
{/* Canvas the PDF will be rendered on. */}
|
||||
<canvas
|
||||
className={`${_className}__canvas z-0`}
|
||||
ref={canvasElement}
|
||||
height={scaledViewport.height}
|
||||
width={scaledViewport.width}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
+11
@@ -6,6 +6,7 @@ import { useNavigate, useRevalidator, useSearchParams } from 'react-router';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_CONTENT_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
@@ -71,6 +72,14 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
|
||||
if (fieldTooltip) {
|
||||
fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
} else {
|
||||
// Tooltip not in DOM (page virtualized away) — signal the PDF viewer
|
||||
// to scroll to the correct page via the data attribute.
|
||||
const pdfContent = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR);
|
||||
|
||||
if (pdfContent) {
|
||||
pdfContent.setAttribute('data-scroll-to-page', String(nextField.page));
|
||||
}
|
||||
}
|
||||
},
|
||||
isEnvelopeItemSwitch ? 150 : 50,
|
||||
@@ -212,10 +221,12 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
return {
|
||||
name:
|
||||
recipient.name ||
|
||||
fullName ||
|
||||
recipient.fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
||||
'',
|
||||
email:
|
||||
recipient.email ||
|
||||
email ||
|
||||
recipient.fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
|
||||
'',
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user