Compare commits

...

45 Commits

Author SHA1 Message Date
Lucas Smith fae1798eeb fix: add typecheck to build.sh 2026-03-13 14:06:48 +11:00
Lucas Smith 69e08ac7cd fix: make typecheck work 2026-03-13 12:32:10 +11:00
Lucas Smith f69f3c9473 chore: hoist dev deps 2026-03-13 12:16:24 +11:00
Lucas Smith 9747fd1800 fix: tidy 2026-03-13 12:03:05 +11:00
Lucas Smith 53afafe5db fix: remove hacks 2026-03-13 11:49:56 +11:00
Lucas Smith 38ab1cd4a7 fix: add babel-plugin-macros as explicit devDependency
Required at runtime by @lingui/core/macro when code runs outside
Vite/Rolldown (e.g. prisma seed scripts via tsx). Previously a
transitive dependency of the now-removed vite-plugin-babel-macros.
2026-03-13 10:34:19 +11:00
Lucas Smith b3ef92feb9 perf: upgrade to vite 8, migrate rollup to rolldown, and optimise build pipeline
- Upgrade vite from v7 to v8 (Rolldown-based bundler)
- Migrate server build from rollup to rolldown, eliminating 5 plugins
  (rolldown handles TS, JSX, JSON, CJS, and node resolution natively)
- Replace vite-plugin-babel-macros with targeted lingui macro plugin
  that skips files without macro imports (~90% of files)
- Replace vite-tsconfig-paths with native resolve.tsconfigPaths
- Remove redundant lingui error-reporter plugin (51% of plugin time)
- Narrow optimizeDeps.entries from ~1060 to ~460 files
- Move typecheck out of build into separate CI job (43s -> 24s build)
- Fix card.tsx var() typo exposed by LightningCSS
- Remove 30 unused packages (rollup, babel presets, rollup plugins, etc.)
- Add vite override for peer dep compatibility
- Patch @lingui/vite-plugin with moduleType for Rolldown compat
2026-03-13 10:06:15 +11:00
Lucas Smith 03ca3971a0 perf: upgrade @libpdf/core to 0.3.3 and deduplicate font registration (#2598)
Upgrade @libpdf/core from 0.2.12 to 0.3.3, which includes:
- WebCrypto SHA-256 replacing pure-JS @noble/hashes (10x signing
speedup)
- Iterative collectReachableRefs (fixes stack overflow on large PDFs)
- Iterative Math.max helpers in xref writer (fixes remaining stack
overflow)

Extract duplicated FontLibrary.use() calls from render-certificate,
render-audit-logs, and insert-field-in-pdf-v2 into a shared
ensureFontLibrary() helper with has() guards so fonts are only
registered once per process.
2026-03-11 20:23:18 +11:00
Lucas Smith 5ea4060fd7 v2.8.0 2026-03-10 21:43:01 +11:00
Lucas Smith af346b179c feat: add recipient role editing and audit log PDF download in admin (#2594)
- Allow admins to update recipient role from document detail page
- Add download button to export audit logs as PDF
- Display recipient status details in accordion
- Add LocalTime component with hover popover for timestamps
2026-03-10 21:41:46 +11:00
Catalin Pit ab69ee627b fix: include extra recipient info in missing fields error msg (#2590) 2026-03-10 12:17:24 +11:00
Lucas Smith 4daec44550 fix: move window.__ENV__ script before client bundle to prevent stale fallback (#2592) 2026-03-10 12:15:15 +11:00
Ted Liang 11eb4dd2cd fix: security CVE-2026-29045 (#2589) 2026-03-09 16:46:11 +11:00
Lucas Smith cc71c7d9ba fix: add cmaps (#2588) 2026-03-09 14:07:13 +11:00
Lucas Smith f82bf97480 fix: only use embed hash name/email as fallback when recipient values are blank (#2586)
For document signing embeds, the hash-provided name and email should
only
be used when the recipient doesn't already have values set. For template
signing, the hash values are always allowed.

Also makes the email input editable in V1 embeds when the recipient has
no email, matching V2 behavior.

Ref: documenso/embeds#53
2026-03-09 13:30:27 +11:00
Lucas Smith 0e20d364ef fix: opt findDocumentsInternal query out of batch fetching (#2585) 2026-03-09 12:47:59 +11:00
David Nguyen ef57c8448a fix: dropdown fields (#2584) 2026-03-09 12:19:20 +11:00
Lucas Smith eaaf8f9e63 chore: add translations (#2582) 2026-03-09 11:56:17 +11:00
David Nguyen 58f0c98038 chore: add embed envelope docs (#2576) 2026-03-09 11:50:13 +11:00
Catalin Pit da7b5d12f8 fix: make signing page left-hand sidebar collapsible (#2541) 2026-03-09 11:45:28 +11:00
github-actions[bot] 7cfe876762 chore: extract translations (#2577) 2026-03-09 11:39:37 +11:00
Ephraim Duncan 15399cbe8e feat: auto-disable telemetry when license key is configured (#2562) 2026-03-09 11:24:24 +11:00
Catalin Pit c4754553c9 feat: implement template search functionality (#2376)
- Added  function to handle template searches based on user input
- Introduced in the TRPC router to facilitate authenticated template
searches
- Updated to include template search results alongside document search
results
- Enhanced query handling by enabling searches only when the input is
valid
- Created corresponding Zod schemas for request and response validation
in
2026-03-09 10:44:51 +11:00
David Nguyen 6c8726b58c fix: performance improvements (#2581) 2026-03-09 10:22:57 +11:00
Lucas Smith abd031b58b chore: add translations (#2575) 2026-03-06 16:10:54 +11:00
github-actions[bot] 1ff8680c32 chore: extract translations (#2566) 2026-03-06 14:15:37 +11:00
David Nguyen 7ea664214a feat: add embedded envelopes (#2564)
## Description

Add envelopes V2 embedded support

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-06 14:11:27 +11:00
Ephraim Duncan 7e2cbe46c0 fix: show current month data and add caching (#2573)
### Summary

- Add Cache-Control headers to all route responses (1h s-maxage, 2h
stale-while-revalidate)
- Append current month to chart data so graphs stay up-to-date
(cumulative carries forward, else zero)
- Remove `.limit(12)` from growth queries for full history
- Pass isCumulative flag through addZeroMonth
- Deduplicate TransformedData type, remove transformRepoStats
2026-03-06 13:30:31 +11:00
Konrad c63b4ca3cc fix(i18n): mark dropdown and radio placeholder for translation (#2537) 2026-03-06 13:05:03 +11:00
David Nguyen 6faa01d384 feat: add pdf image renderer (#2554)
## Description

Replace the PDF renderer with an custom image renderer.

This allows us to remove the "react-pdf" dependency and allows us to use
a virtual list to improve performance.
2026-03-06 12:39:03 +11:00
Lucas Smith 0ce909a298 refactor: find envelopes (#2557) 2026-03-06 12:38:40 +11:00
Lucas Smith 7f271379b9 fix: upgrade @libpdf/core (#2572) 2026-03-06 10:08:58 +11:00
Lucas Smith 406e77e4be chore: add translations (#2570) 2026-03-05 17:33:36 +11:00
Lucas Smith bff360b084 fix: upgrade @libpdf/core (#2569) 2026-03-05 15:34:40 +11:00
Lucas Smith db1087d76d v2.7.1 2026-03-05 15:16:45 +11:00
Lucas Smith ef0a5b54ba fix: verify before re-registering in email sync (#2568) 2026-03-05 15:12:20 +11:00
David Nguyen 1f985e2cd3 fix: invalid po translations (#2567) 2026-03-05 14:54:36 +11:00
Konrad 525dd92a56 fix(i18n): mark SUBSCRIPTION_STATUS_MAP for translation (#2515) 2026-03-05 14:42:40 +11:00
Konrad d21b99825d fix(i18n): add pluralization to expiration period picker (#2535) 2026-03-05 14:32:12 +11:00
Konrad dfbf68e4cd fix(i18n): mark editor field number form placeholder for translation (#2536) 2026-03-05 14:31:24 +11:00
github-actions[bot] 8b0231825f chore: extract translations (#2539) 2026-03-05 14:11:53 +11:00
Ephraim Duncan 03e2e4f171 docs: clarify placeholder support is envelope.* only (#2560) 2026-03-05 13:58:29 +11:00
Lucas Smith 7f5f2b22ed feat: add seal-document sweep job and admin unsealed documents page (#2563) 2026-03-05 13:56:40 +11:00
Lucas Smith 7d3a56a006 feat: add admin ability to move subscription between orgs (#2558)
## Summary

- Adds a new admin action to move a subscription (and Stripe customerId)
from one organisation to another owned by the same user
- The target organisation must be on the free plan (no active
subscription) — enforces paid → free only
- The source organisation's claim is reset to the free plan after the
move

## How it works

A "Move Subscription" option appears in the actions dropdown of the
organisations table (on the admin user detail page) for any org with an
active or past-due subscription. Clicking it opens a dialog where the
admin selects a target org from a filtered list of eligible (free-plan)
orgs owned by the same user.

The backend performs the swap atomically in a single Prisma transaction:
1. Deletes any stale inactive subscription on the target org
2. Moves the `customerId` from source to target org
3. Reassigns the `Subscription` record to the target org
4. Copies claim entitlements to the target org
5. Resets the source org's claim to FREE

No Stripe API calls are made — the Stripe subscription and customer
remain unchanged; only the DB-level org association is updated.

## Files changed

- **New:**
`packages/trpc/server/admin-router/swap-organisation-subscription.types.ts`
— Zod schemas
- **New:**
`packages/trpc/server/admin-router/swap-organisation-subscription.ts` —
Admin mutation
- **New:**
`apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx` —
Dialog component
- **Modified:** `packages/trpc/server/admin-router/router.ts` — Register
route
- **Modified:**
`apps/remix/app/components/tables/admin-organisations-table.tsx` — Add
action menu item
2026-03-04 22:34:53 +11:00
Catalin Pit f1323679aa fix: use default field meta for embedding template fields (#2556) 2026-03-03 22:24:57 +11:00
295 changed files with 27984 additions and 7960 deletions
@@ -0,0 +1,151 @@
---
date: 2026-03-04
title: Swap Subscription Between Orgs
---
## Overview
Add the ability for admins to move a subscription (and its associated Stripe customerId) from one organisation to another, when viewing a user in the admin panel. The target org must be owned by the same user and must be on the free plan (no existing active subscription).
## Context & Data Model
- `Organisation` has a 1:1 optional `Subscription` and a `customerId` (Stripe customer ID, `@unique`)
- `Organisation` has a 1:1 `OrganisationClaim` that tracks entitlements (team count, member count, feature flags)
- `Subscription` also stores a redundant `customerId` and has `organisationId` (`@unique`)
- When a subscription is removed from an org, its `OrganisationClaim` should be reset to the FREE claim
- Relationship chain: `User --owns--> Organisation --has--> Subscription + OrganisationClaim`
## Constraints
- **paid → free only**: The target org must NOT have an active subscription (status ACTIVE or PAST_DUE). It must be on the free plan.
- **same owner**: Both source and target orgs must be owned by the same user (the user being viewed).
- The `customerId` must move with the subscription to the target org (cleared from source, set on target).
- The Stripe subscription object itself is NOT modified — only the DB-level mapping changes. The Stripe customer stays the same; we just reassociate it to a different org.
## Implementation Plan
### 1. Backend: TRPC Admin Route
**Files to create:**
- `packages/trpc/server/admin-router/swap-organisation-subscription.types.ts`
- `packages/trpc/server/admin-router/swap-organisation-subscription.ts`
**Request schema (`ZSwapOrganisationSubscriptionRequestSchema`):**
```ts
z.object({
sourceOrganisationId: z.string(),
targetOrganisationId: z.string(),
});
```
**Response schema:** `z.void()`
**Route logic (in a single `prisma.$transaction`):**
1. Fetch source org with `subscription` + `organisationClaim`
2. Fetch target org with `subscription` + `organisationClaim`
3. Validate:
- Source org has an active subscription (status `ACTIVE` or `PAST_DUE`)
- Target org does NOT have an active subscription (no subscription record, or status `INACTIVE`)
- Both orgs have the same `ownerUserId`
4. In a transaction:
a. Clear `customerId` on source org (set to `null`)
b. Set `customerId` on target org to the source's `customerId`
c. Move the `Subscription` record: update `organisationId` to target org ID
d. Copy the source org's `OrganisationClaim` entitlements to the target org's `OrganisationClaim` (`originalSubscriptionClaimId`, `teamCount`, `memberCount`, `envelopeItemCount`, `flags`)
e. Reset the source org's `OrganisationClaim` to the FREE claim (using `createOrganisationClaimUpsertData(internalClaims[INTERNAL_CLAIM_ID.FREE])` pattern from `on-subscription-deleted.ts`)
**Note on ordering:** Because `Organisation.customerId` is `@unique`, we must clear the source first, then set the target — or do both in a transaction that handles the constraint. Prisma transactions handle this correctly as they apply all writes atomically.
**Register the route:**
- Import in `packages/trpc/server/admin-router/router.ts`
- Add under `organisation` as `swapSubscription`
- Call path: `trpc.admin.organisation.swapSubscription`
### 2. Frontend: Dialog Component
**File to create:**
- `apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx`
**Props:**
```ts
type AdminSwapSubscriptionDialogProps = {
trigger?: React.ReactNode;
sourceOrganisationId: string;
sourceOrganisationName: string;
userId: number;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
```
**Dialog behavior:**
1. Opens when the trigger is clicked (from the organisations table actions dropdown)
2. Fetches the user's owned orgs via `trpc.admin.organisation.find.useQuery({ ownerUserId: userId })`
3. Filters to only show orgs that are on the free plan (no active subscription) and excludes the source org
4. Displays a select dropdown to pick the target org
5. Shows a warning alert: "This will move the subscription from {source} to {target}. The source organisation will be reset to the free plan."
6. On submit, calls `trpc.admin.organisation.swapSubscription.useMutation()`
7. On success, shows a toast, invalidates relevant queries, and closes the dialog
**UI layout (following existing dialog patterns like `admin-organisation-create-dialog.tsx`):**
- `DialogHeader` with title "Move Subscription" and description
- A select dropdown listing eligible target orgs (name + url)
- An `Alert` explaining what will happen
- `DialogFooter` with Cancel + "Move Subscription" buttons (submit button uses `loading` prop)
### 3. Frontend: Wire into the Organisations Table
**File to modify:**
- `apps/remix/app/components/tables/admin-organisations-table.tsx`
**Changes:**
- Import the `AdminSwapSubscriptionDialog`
- Add a new prop `ownerUserId?: number` to `AdminOrganisationsTableOptions` (needed so the dialog can query other owned orgs)
- Add a new dropdown menu item in the actions column: "Move Subscription" with `ArrowRightLeftIcon` from lucide
- Only render this item when the org row has an active subscription (`subscription?.status === 'ACTIVE' || subscription?.status === 'PAST_DUE'`)
- The menu item renders inside `AdminSwapSubscriptionDialog` with `trigger` prop as the menu item
### 4. Frontend: Pass userId from User Detail Page
**File to modify:**
- `apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx`
**Changes:**
- Pass `ownerUserId={user.id}` to `<AdminOrganisationsTable>` so it can forward this to the swap dialog
## File Change Summary
| File | Action | Description |
| --------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------- |
| `packages/trpc/server/admin-router/swap-organisation-subscription.types.ts` | **Create** | Request/response Zod schemas + TS types |
| `packages/trpc/server/admin-router/swap-organisation-subscription.ts` | **Create** | Admin mutation with prisma transaction |
| `packages/trpc/server/admin-router/router.ts` | **Modify** | Register route at `organisation.swapSubscription` |
| `apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx` | **Create** | Dialog for selecting target org |
| `apps/remix/app/components/tables/admin-organisations-table.tsx` | **Modify** | Add "Move Subscription" action + accept `ownerUserId` prop |
| `apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx` | **Modify** | Pass `ownerUserId={user.id}` to table |
## Edge Cases & Considerations
1. **Stripe customer stays the same**: The Stripe subscription is tied to a Stripe customer. We move the `customerId` to the target org, so webhook lookups (`findFirst where customerId`) will correctly resolve to the target org going forward.
2. **`@unique` constraint on `Organisation.customerId`**: Must clear source before setting target within the transaction. Prisma interactive transactions handle this correctly.
3. **`@unique` constraint on `Subscription.organisationId`**: Since the target org should not have a subscription record, updating the existing subscription's `organisationId` to the target should work. If the target has an INACTIVE subscription record, we need to delete it first.
4. **Target org has INACTIVE subscription**: The target org might have a stale INACTIVE subscription from a previous cancellation. In this case, delete the target's old subscription record before moving the source's subscription over.
5. **Seat-based plans**: If the subscription is seat-based, the Stripe quantity may not match the target org's member count. Consider calling `syncMemberCountWithStripeSeatPlan` after the swap as a post-transaction step.
6. **OrganisationClaim transfer**: Copy `originalSubscriptionClaimId`, `teamCount`, `memberCount`, `envelopeItemCount`, and `flags` from source claim to target claim. Reset source claim to FREE.
7. **No Stripe API calls needed**: This is purely a DB-level reassociation. The Stripe subscription, customer, and payment method all remain unchanged.
@@ -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.
+14
View File
@@ -12,6 +12,20 @@ concurrency:
cancel-in-progress: true
jobs:
typecheck:
name: Typecheck
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: ./.github/actions/node-install
- name: Typecheck
run: npm run typecheck -w @documenso/remix
build_app:
name: Build App
runs-on: ubuntu-latest
+1
View File
@@ -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
+8 -60
View File
@@ -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
+8 -61
View File
@@ -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
+8 -60
View File
@@ -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
-201
View File
@@ -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
View File
@@ -1 +0,0 @@
../../.agents/skills/agent-browser
@@ -269,6 +269,10 @@ Returns the full document object including recipients, fields, and envelope item
Create a new document with optional recipients and fields in a single request.
<Callout type="info">
This endpoint automatically scans uploaded PDFs for [placeholder patterns](/docs/users/documents/advanced/pdf-placeholders) like `{"{{signature, r1}}"}` and creates fields at those locations.
</Callout>
```
POST /envelope/create
Content-Type: multipart/form-data
@@ -473,6 +473,10 @@ Instead of specifying exact coordinates, you can position fields using placehold
This approach is useful when generating PDFs programmatically or using templates with consistent layouts.
<Callout type="info">
Placeholder support is only available in `envelope.*` endpoints. `POST /template/use` does not support placeholder parsing.
</Callout>
See the [PDF Placeholders](/docs/users/documents/advanced/pdf-placeholders) guide for the full placeholder format reference, including supported field types, recipient identifiers, and field options.
---
@@ -240,6 +240,10 @@ Returns the full template object including recipients, fields, and metadata.
Create a new document using a template. This is the primary way to use templates programmatically.
<Callout type="info">
This endpoint does not support [PDF placeholder parsing](/docs/users/documents/advanced/pdf-placeholders). Use `POST /envelope/create` for placeholder-based field positioning.
</Callout>
```
POST /template/use
@@ -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"]
}
@@ -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,340 @@
---
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 |
### 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
@@ -271,6 +271,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.
---
@@ -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',
},
}),
);
+27 -28
View File
@@ -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>>;
+3 -14
View File
@@ -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;
+1 -5
View File
@@ -14,9 +14,5 @@
"luxon": "^3.7.2",
"next": "15.5.12"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "18.3.27",
"typescript": "5.6.2"
}
"devDependencies": {}
}
+6 -2
View File
@@ -19,6 +19,9 @@ start_time=$(date +%s)
echo "[Build]: Extracting and compiling translations"
npm run translate --prefix ../../
echo "[Build]: Typechecking app"
npm run typecheck
echo "[Build]: Building app"
npm run build:app
@@ -28,10 +31,11 @@ npm run build:server
# Copy over the entry point for the server.
cp server/main.js build/server/main.js
# Copy over all web.js translations
# Copy over all web.js translations.
# Rolldown preserveModules mirrors the source tree under build/server/hono/.
cp -r ../../packages/lib/translations build/server/hono/packages/lib/translations
# Time taken
end_time=$(date +%s)
echo "[Build]: Done in $((end_time - start_time)) seconds"
echo "[Build]: Done in $((end_time - start_time)) seconds"
@@ -0,0 +1,197 @@
import { useEffect, useMemo, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminSwapSubscriptionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
sourceOrganisationId: string;
sourceOrganisationName: string;
userId: number;
};
export const AdminSwapSubscriptionDialog = ({
open,
onOpenChange,
sourceOrganisationId,
sourceOrganisationName,
userId,
}: AdminSwapSubscriptionDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [selectedOrgId, setSelectedOrgId] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: orgsData } = trpc.admin.organisation.find.useQuery(
{
ownerUserId: userId,
perPage: 100,
},
{
enabled: open,
},
);
const trpcUtils = trpc.useUtils();
const eligibleOrgs = useMemo(() => {
if (!orgsData?.data) {
return [];
}
return orgsData.data.filter((org) => {
if (org.id === sourceOrganisationId) {
return false;
}
const hasActiveSubscription =
org.subscription &&
(org.subscription.status === 'ACTIVE' || org.subscription.status === 'PAST_DUE');
return !hasActiveSubscription;
});
}, [orgsData, sourceOrganisationId]);
const selectedOrg = eligibleOrgs.find((org) => org.id === selectedOrgId);
const { mutateAsync: swapSubscription } = trpc.admin.organisation.swapSubscription.useMutation();
const onSubmit = async () => {
if (!selectedOrgId) {
return;
}
setIsSubmitting(true);
try {
await swapSubscription({
sourceOrganisationId,
targetOrganisationId: selectedOrgId,
});
await trpcUtils.admin.organisation.find.invalidate();
await trpcUtils.admin.organisation.get.invalidate();
onOpenChange(false);
toast({
title: t`Success`,
description: t`Subscription moved successfully`,
duration: 5000,
});
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: t`Error`,
description: t`Failed to move subscription. Please try again.`,
variant: 'destructive',
});
} finally {
setIsSubmitting(false);
}
};
useEffect(() => {
if (!open) {
setSelectedOrgId('');
}
}, [open]);
return (
<Dialog open={open} onOpenChange={(value) => !isSubmitting && onOpenChange(value)}>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Move Subscription</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Move the subscription from "{sourceOrganisationName}" to another organisation owned by
this user.
</Trans>
</DialogDescription>
</DialogHeader>
<fieldset className="flex flex-col space-y-4" disabled={isSubmitting}>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">
<Trans>Target Organisation</Trans>
</label>
<Select value={selectedOrgId} onValueChange={setSelectedOrgId}>
<SelectTrigger>
<SelectValue placeholder={t`Select an organisation`} />
</SelectTrigger>
<SelectContent>
{eligibleOrgs.map((org) => (
<SelectItem key={org.id} value={org.id}>
{org.name} ({org.url})
</SelectItem>
))}
</SelectContent>
</Select>
{eligibleOrgs.length === 0 && orgsData && (
<p className="text-sm text-muted-foreground">
<Trans>No eligible organisations found. The target must be on the free plan.</Trans>
</p>
)}
</div>
{selectedOrg && (
<Alert variant="warning">
<AlertDescription className="mt-0">
<Trans>
This will move the subscription from "{sourceOrganisationName}" to "
{selectedOrg.name}". The source organisation will be reset to the free plan.
</Trans>
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
onClick={onSubmit}
disabled={!selectedOrgId}
loading={isSubmitting}
>
<Trans>Move Subscription</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};
@@ -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,17 +5,17 @@ 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 { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -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) => ({
@@ -179,8 +179,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 +203,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 +224,7 @@ export const ConfigureFieldsView = ({
};
append(newField);
});
}
return;
}
@@ -548,17 +548,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,8 +270,9 @@ export const EditorFieldNumberForm = ({
</FormLabel>
<FormControl>
<Input
data-testid="field-form-minValue"
className="bg-background"
placeholder="E.g. 0"
placeholder={t`E.g. 0`}
{...field}
value={field.value ?? ''}
onChange={(e) =>
@@ -281,8 +295,9 @@ export const EditorFieldNumberForm = ({
</FormLabel>
<FormControl>
<Input
data-testid="field-form-maxValue"
className="bg-background"
placeholder="E.g. 100"
placeholder={t`E.g. 100`}
{...field}
value={field.value ?? ''}
onChange={(e) =>
@@ -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}
@@ -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>
)}
</>
@@ -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>
@@ -130,69 +130,67 @@ export const DocumentSigningFieldContainer = ({
};
return (
<div className={cn('[container-type:size]')}>
<FieldRootContainer
color={
field.fieldMeta?.readOnly ? RECIPIENT_COLOR_STYLES.readOnly : RECIPIENT_COLOR_STYLES.green
}
field={field}
>
{!field.inserted && !loading && !readOnlyField && (
<button
type="submit"
className="absolute inset-0 z-10 h-full w-full rounded-[2px]"
onClick={async () => handleInsertField()}
/>
)}
<FieldRootContainer
color={
field.fieldMeta?.readOnly ? RECIPIENT_COLOR_STYLES.readOnly : RECIPIENT_COLOR_STYLES.green
}
field={field}
>
{!field.inserted && !loading && !readOnlyField && (
<button
type="submit"
className="absolute inset-0 z-10 h-full w-full rounded-[2px]"
onClick={async () => handleInsertField()}
/>
)}
{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>
);
};
@@ -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) =>
@@ -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>
);
};
@@ -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
@@ -278,13 +269,13 @@ 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',
'group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-border px-4 transition-colors',
RECIPIENT_COLOR_STYLES[selectedRecipientColor].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',
@@ -306,7 +297,7 @@ 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]',
'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]',
RECIPIENT_COLOR_STYLES[selectedRecipientColor].base,
selectedField === FieldType.SIGNATURE && 'font-signature',
{
@@ -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. */}
@@ -22,6 +22,7 @@ import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redist
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
import { BrandingLogo } from '~/components/general/branding-logo';
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
import { EmbeddedEditorAttachmentPopover } from '~/components/general/document/embedded-editor-attachment-popover';
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
import { TemplateDirectLinkBadge } from '../template/template-direct-link-badge';
@@ -30,21 +31,56 @@ import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
export default function EnvelopeEditorHeader() {
const { t } = useLingui();
const { envelope, isDocument, isTemplate, updateEnvelope, autosaveError, relativePath } =
useCurrentEnvelopeEditor();
const {
envelope,
isDocument,
isTemplate,
isEmbedded,
updateEnvelope,
autosaveError,
relativePath,
editorConfig,
flushAutosave,
} = useCurrentEnvelopeEditor();
const {
embedded,
general: { allowConfigureEnvelopeTitle },
actions: { allowAttachments, allowDistributing },
} = editorConfig;
const handleCreateEmbeddedEnvelope = async () => {
const latestEnvelope = await flushAutosave();
embedded?.onCreate?.(latestEnvelope);
};
const handleUpdateEmbeddedEnvelope = async () => {
const latestEnvelope = await flushAutosave();
embedded?.onUpdate?.(latestEnvelope);
};
return (
<nav className="w-full border-b border-border bg-background px-4 py-3 md:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Link to="/">
<BrandingLogo className="h-6 w-auto" />
</Link>
{editorConfig.embedded?.customBrandingLogo ? (
<img
src={`/api/branding/logo/team/${envelope.teamId}`}
alt="Logo"
className="h-6 w-auto"
/>
) : (
<Link to="/">
<BrandingLogo className="h-6 w-auto" />
</Link>
)}
<Separator orientation="vertical" className="h-6" />
<div className="flex items-center space-x-2">
<EnvelopeItemTitleInput
disabled={envelope.status !== DocumentStatus.DRAFT}
disabled={envelope.status !== DocumentStatus.DRAFT || !allowConfigureEnvelopeTitle}
value={envelope.title}
onChange={(title) => {
updateEnvelope({
@@ -127,54 +163,75 @@ export default function EnvelopeEditorHeader() {
</div>
<div className="flex items-center space-x-2">
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
{allowAttachments &&
(isEmbedded ? (
<EmbeddedEditorAttachmentPopover buttonSize="sm" />
) : (
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
))}
<EnvelopeEditorSettingsDialog
trigger={
<Button variant="outline" size="sm">
<SettingsIcon className="h-4 w-4" />
</Button>
}
/>
{isDocument && (
<>
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send Document</Trans>
</Button>
}
/>
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Resend Document</Trans>
</Button>
}
/>
</>
)}
{isTemplate && (
<TemplateUseDialog
envelopeId={envelope.id}
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
templateSigningOrder={envelope.documentMeta?.signingOrder}
recipients={envelope.recipients}
documentRootPath={relativePath.documentRootPath}
{editorConfig.settings && (
<EnvelopeEditorSettingsDialog
trigger={
<Button size="sm">
<Trans>Use Template</Trans>
<Button variant="outline" size="sm">
<SettingsIcon className="h-4 w-4" />
</Button>
}
/>
)}
{match({ isEmbedded, isDocument, isTemplate, allowDistributing })
.with({ isEmbedded: false, isDocument: true, allowDistributing: true }, () => (
<>
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send Document</Trans>
</Button>
}
/>
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Resend Document</Trans>
</Button>
}
/>
</>
))
.with({ isEmbedded: false, isTemplate: true, allowDistributing: true }, () => (
<TemplateUseDialog
envelopeId={envelope.id}
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
templateSigningOrder={envelope.documentMeta?.signingOrder}
recipients={envelope.recipients}
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<Trans>Use Template</Trans>
</Button>
}
/>
))
.otherwise(() => null)}
{embedded?.mode === 'create' && (
<Button size="sm" onClick={handleCreateEmbeddedEnvelope}>
{isDocument ? <Trans>Create Document</Trans> : <Trans>Create Template</Trans>}
</Button>
)}
{embedded?.mode === 'edit' && (
<Button size="sm" onClick={handleUpdateEmbeddedEnvelope}>
{isDocument ? <Trans>Update Document</Trans> : <Trans>Update Template</Trans>}
</Button>
)}
</div>
</div>
</nav>
@@ -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">
@@ -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}
@@ -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>
);
};
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg, t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import {
DocumentDistributionMethod,
@@ -30,6 +30,7 @@ import {
isValidLanguageCode,
} from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import {
ZDocumentAccessAuthTypesSchema,
@@ -175,10 +176,13 @@ export const EnvelopeEditorSettingsDialog = ({
trigger,
...props
}: EnvelopeEditorSettingsDialogProps) => {
const { _ } = useLingui();
const { t } = useLingui();
const { toast } = useToast();
const { envelope, updateEnvelopeAsync } = useCurrentEnvelopeEditor();
const { envelope, updateEnvelopeAsync, editorConfig, isEmbedded, organisationEmails } =
useCurrentEnvelopeEditor();
const { settings } = editorConfig;
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
@@ -228,12 +232,18 @@ export const EnvelopeEditorSettingsDialog = ({
const emailSettings = form.watch('meta.emailSettings');
const { data: emailData, isLoading: isLoadingEmails } =
trpc.enterprise.organisation.email.find.useQuery({
organisationId: organisation.id,
perPage: 100,
});
trpc.enterprise.organisation.email.find.useQuery(
{
organisationId: organisation.id,
perPage: 100,
},
{
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
enabled: Boolean(organisationEmails !== undefined && organisation.id),
},
);
const emails = emailData?.data || [];
const emails = emailData?.data || organisationEmails || [];
const canUpdateVisibility = canAccessTeamDocument(team.currentTeamRole, envelope.visibility);
@@ -285,11 +295,13 @@ export const EnvelopeEditorSettingsDialog = ({
setOpen(false);
toast({
title: t`Success`,
description: t`Envelope updated`,
duration: 5000,
});
if (!isEmbedded) {
toast({
title: t`Success`,
description: t`Envelope updated`,
duration: 5000,
});
}
} catch (err) {
const error = AppError.parseError(err);
@@ -326,7 +338,7 @@ export const EnvelopeEditorSettingsDialog = ({
const selectedTab = tabs.find((tab) => tab.id === activeTab);
if (!selectedTab) {
if (!selectedTab || !settings) {
return null;
}
@@ -347,34 +359,40 @@ export const EnvelopeEditorSettingsDialog = ({
<DialogContent className="flex w-full !max-w-5xl flex-row gap-0 p-0">
{/* Sidebar. */}
<div className="flex w-80 flex-col border-r bg-accent/20">
<DialogHeader className="p-6 pb-4">
<DialogHeader className="p-6 pb-4" data-testid="envelope-editor-settings-dialog-header">
<DialogTitle>
<Trans>Document Settings</Trans>
</DialogTitle>
</DialogHeader>
<nav className="col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 px-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2">
{tabs.map((tab) => (
<Button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
variant="ghost"
className={cn('w-full justify-start', {
'bg-secondary': activeTab === tab.id,
})}
>
<tab.icon className="mr-2 h-5 w-5" />
{_(tab.title)}
</Button>
))}
{tabs.map((tab) => {
if (tab.id === 'email' && !settings.allowConfigureDistribution) {
return null;
}
return (
<Button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
variant="ghost"
className={cn('w-full justify-start', {
'bg-secondary': activeTab === tab.id,
})}
>
<tab.icon className="mr-2 h-5 w-5" />
{t(tab.title)}
</Button>
);
})}
</nav>
</div>
{/* Content. */}
<div className="flex w-full flex-col">
<CardHeader className="border-b pb-4">
<CardTitle>{selectedTab ? _(selectedTab.title) : ''}</CardTitle>
<CardDescription>{selectedTab ? _(selectedTab.description) : ''}</CardDescription>
<CardTitle>{selectedTab ? t(selectedTab.title) : ''}</CardTitle>
<CardDescription>{selectedTab ? t(selectedTab.description) : ''}</CardDescription>
</CardHeader>
<Form {...form}>
@@ -384,137 +402,151 @@ export const EnvelopeEditorSettingsDialog = ({
disabled={form.formState.isSubmitting}
key={activeTab}
>
{match(activeTab)
.with('general', () => (
{match({ activeTab, settings })
.with({ activeTab: 'general' }, () => (
<>
<FormField
control={form.control}
name="meta.language"
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center">
<Trans>Language</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
{settings.allowConfigureLanguage && (
<FormField
control={form.control}
name="meta.language"
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center">
<Trans>Language</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<Trans>
Controls the language for the document, including the language
to be used for email notifications, and the final certificate
that is generated and attached to the document.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<Trans>
Controls the language for the document, including the language
to be used for email notifications, and the final certificate
that is generated and attached to the document.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<FormControl>
<Select
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}>
{_(language.full)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.signatureTypes"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Allowed Signature Types</Trans>
<DocumentSignatureSettingsTooltip />
</FormLabel>
<SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}>
{t(language.full)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
label: _(option.label),
value: option.value,
}))}
selectedValues={field.value}
onChange={field.onChange}
className="w-full bg-background"
emptySelectionPlaceholder="Select signature types"
/>
</FormControl>
{settings.allowConfigureSignatureTypes && (
<FormField
control={form.control}
name="meta.signatureTypes"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Allowed Signature Types</Trans>
<DocumentSignatureSettingsTooltip />
</FormLabel>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.dateFormat"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Date Format</Trans>
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map(
(option) => ({
label: t(option.label),
value: option.value,
}),
)}
selectedValues={field.value}
onChange={field.onChange}
className="w-full bg-background"
emptySelectionPlaceholder="Select signature types"
/>
</FormControl>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={envelopeHasBeenSent}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<FormMessage />
</FormItem>
)}
/>
)}
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
{settings.allowConfigureDateFormat && (
<FormField
control={form.control}
name="meta.dateFormat"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Date Format</Trans>
</FormLabel>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.timezone"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Time Zone</Trans>
</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={envelopeHasBeenSent}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<FormControl>
<Combobox
className="bg-background"
options={TIME_ZONES}
value={field.value}
onChange={(value) => value && field.onChange(value)}
disabled={envelopeHasBeenSent}
/>
</FormControl>
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{settings.allowConfigureTimezone && (
<FormField
control={form.control}
name="meta.timezone"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Time Zone</Trans>
</FormLabel>
<FormControl>
<Combobox
className="bg-background"
options={TIME_ZONES}
value={field.value}
onChange={(value) => value && field.onChange(value)}
disabled={envelopeHasBeenSent}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="externalId"
@@ -573,233 +605,244 @@ export const EnvelopeEditorSettingsDialog = ({
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.distributionMethod"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Document Distribution Method</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Document Distribution Method</Trans>
</strong>
</h2>
<p>
<Trans>
This is how the document will reach the recipients once the
document is ready for signing.
</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<Trans>
<strong>Email</strong> - The recipient will be emailed the
document to sign, approve, etc.
</Trans>
</li>
<li>
<Trans>
<strong>None</strong> - We will generate links which you can
send to the recipients manually.
</Trans>
</li>
</ul>
<Trans>
<strong>Note</strong> - If you use Links in combination with
direct templates, you will need to manually send the links to
the remaining recipients.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue data-testid="documentDistributionMethodSelectValue" />
</SelectTrigger>
<SelectContent position="popper">
{Object.values(DOCUMENT_DISTRIBUTION_METHODS).map(
({ value, description }) => (
<SelectItem key={value} value={value}>
{_(description)}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.envelopeExpirationPeriod"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Expiration</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
How long recipients have to complete this document after it is
sent. Uses the team default when set to inherit.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<ExpirationPeriodPicker
value={field.value}
onChange={field.onChange}
disabled={envelopeHasBeenSent}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
))
.with('email', () => (
<>
{organisation.organisationClaim.flags.emailDomains && (
{settings.allowConfigureDistribution && (
<FormField
control={form.control}
name="meta.emailId"
name="meta.distributionMethod"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email Sender</Trans>
<FormLabel className="flex flex-row items-center">
<Trans>Document Distribution Method</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Document Distribution Method</Trans>
</strong>
</h2>
<p>
<Trans>
This is how the document will reach the recipients once the
document is ready for signing.
</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<Trans>
<strong>Email</strong> - The recipient will be emailed the
document to sign, approve, etc.
</Trans>
</li>
<li>
<Trans>
<strong>None</strong> - We will generate links which you
can send to the recipients manually.
</Trans>
</li>
</ul>
<Trans>
<strong>Note</strong> - If you use Links in combination with
direct templates, you will need to manually send the links to
the remaining recipients.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value}
onValueChange={(value) =>
field.onChange(value === '-1' ? null : value)
}
>
<SelectTrigger
loading={isLoadingEmails}
className="bg-background"
>
<SelectValue />
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue data-testid="documentDistributionMethodSelectValue" />
</SelectTrigger>
<SelectContent>
{emails.map((email) => (
<SelectItem key={email.id} value={email.id}>
{email.email}
</SelectItem>
))}
<SelectItem value={'-1'}>Documenso</SelectItem>
<SelectContent position="popper">
{Object.values(DOCUMENT_DISTRIBUTION_METHODS).map(
({ value, description }) => (
<SelectItem key={value} value={value}>
{t(description)}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
)}
{settings.allowConfigureExpirationPeriod && (
<FormField
control={form.control}
name="meta.envelopeExpirationPeriod"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Expiration</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
How long recipients have to complete this document after it is
sent. Uses the team default when set to inherit.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<ExpirationPeriodPicker
value={field.value}
onChange={field.onChange}
disabled={envelopeHasBeenSent}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="meta.emailReplyTo"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Reply To Email{' '}
<span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.subject"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Subject <span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.message"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>
Message <span className="text-muted-foreground">(Optional)</span>
</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="p-4 text-muted-foreground">
<DocumentSendEmailMessageHelper />
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Textarea className="h-16 resize-none bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DocumentEmailCheckboxes
value={emailSettings}
onChange={(value) => form.setValue('meta.emailSettings', value)}
/>
</>
))
.with('security', () => (
.with(
{ activeTab: 'email', settings: { allowConfigureDistribution: true } },
() => (
<>
{settings.allowConfigureEmailSender &&
organisation.organisationClaim.flags.emailDomains && (
<FormField
control={form.control}
name="meta.emailId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email Sender</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value}
onValueChange={(value) =>
field.onChange(value === '-1' ? null : value)
}
>
<SelectTrigger
loading={isLoadingEmails}
className="bg-background"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{emails.map((email) => (
<SelectItem key={email.id} value={email.id}>
{email.email}
</SelectItem>
))}
<SelectItem value={'-1'}>Documenso</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{settings.allowConfigureEmailReplyTo && (
<FormField
control={form.control}
name="meta.emailReplyTo"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Reply To Email{' '}
<span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="meta.subject"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Subject <span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.message"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>
Message <span className="text-muted-foreground">(Optional)</span>
</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="p-4 text-muted-foreground">
<DocumentSendEmailMessageHelper />
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Textarea className="h-16 resize-none bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DocumentEmailCheckboxes
value={emailSettings}
onChange={(value) => form.setValue('meta.emailSettings', value)}
/>
</>
),
)
.with({ activeTab: 'security' }, () => (
<>
{organisation.organisationClaim.flags.cfr21 && (
<FormField
@@ -845,30 +888,32 @@ export const EnvelopeEditorSettingsDialog = ({
)}
/>
<FormField
control={form.control}
name="visibility"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Document visibility</Trans>
<DocumentVisibilityTooltip />
</FormLabel>
{!isEmbedded && (
<FormField
control={form.control}
name="visibility"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Document visibility</Trans>
<DocumentVisibilityTooltip />
</FormLabel>
<FormControl>
<DocumentVisibilitySelect
canUpdateVisibility={canUpdateVisibility}
currentTeamMemberRole={team.currentTeamRole}
{...field}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormControl>
<DocumentVisibilitySelect
canUpdateVisibility={canUpdateVisibility}
currentTeamMemberRole={team.currentTeamRole}
{...field}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
</>
))
.exhaustive()}
.otherwise(() => null)}
</fieldset>
<div className="flex flex-row justify-end gap-4 p-6">
@@ -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,
@@ -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}
@@ -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>
</>
);
}
};
@@ -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';
@@ -49,7 +52,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 +80,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 +97,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 +114,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 +129,7 @@ export default function EnvelopeSignerPageRenderer() {
},
}));
});
}, [envelope.recipients, pageContext.pageNumber]);
}, [envelope.recipients, pageNumber, currentEnvelopeItem?.id]);
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
if (!pageLayer.current) {
@@ -534,14 +531,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 +556,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>
</>
);
}
};
@@ -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,
@@ -0,0 +1,65 @@
import React, { useRef } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { Trans, useLingui } from '@lingui/react/macro';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import type { PDFViewerProps } from './pdf-viewer';
import PDFViewerLazy from './pdf-viewer-lazy';
export type EnvelopePdfViewerProps = {
/**
* The error message to render when there is an error.
*/
errorMessage: { title: MessageDescriptor; description: MessageDescriptor } | null;
} & Omit<PDFViewerProps, 'data'>;
export const EnvelopePdfViewer = ({
errorMessage,
className,
...props
}: EnvelopePdfViewerProps) => {
const { t } = useLingui();
const $el = useRef<HTMLDivElement>(null);
const { currentEnvelopeItem, renderError } = useCurrentEnvelopeRender();
if (renderError || !currentEnvelopeItem) {
return (
<div ref={$el} className={cn('h-full w-full max-w-[800px]', className)} {...props}>
{renderError ? (
<Alert variant="destructive" className="mb-4 max-w-[800px]">
<AlertTitle>
{t(errorMessage?.title || PDF_VIEWER_ERROR_MESSAGES.default.title)}
</AlertTitle>
<AlertDescription>
{t(errorMessage?.description || PDF_VIEWER_ERROR_MESSAGES.default.description)}
</AlertDescription>
</Alert>
) : (
<div className="flex h-[80vh] max-h-[60rem] w-full flex-col items-center justify-center overflow-hidden rounded">
<p className="text-sm text-muted-foreground">
<Trans>No document found</Trans>
</p>
</div>
)}
</div>
);
}
return (
<PDFViewerLazy
key={`${currentEnvelopeItem.envelopeId}-${currentEnvelopeItem.id}`}
{...props}
className={cn('h-full w-full max-w-[800px]', className)}
data={currentEnvelopeItem.data}
/>
);
};
export default EnvelopePdfViewer;
@@ -0,0 +1,34 @@
/**
* We need to double wrap the PDFViewer to avoid the following errors:
*
* 1. Lazy prevents the pdfjs worker from being bundled
* 2. onMount guard prevents "Worker not defined"
*/
import { Suspense, lazy, useEffect, useState } from 'react';
import { cn } from '@documenso/ui/lib/utils';
import type { PDFViewerProps } from './pdf-viewer';
import { PdfViewerLoadingState } from './pdf-viewer-states';
const PDFViewer = lazy(async () => import('./pdf-viewer'));
export default function PDFViewerLazy(props: PDFViewerProps) {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
const fallback = (
<div className={cn('h-full w-full', props.className)}>
<PdfViewerLoadingState />
</div>
);
if (!isClient) {
return fallback;
}
return <Suspense fallback={fallback}>{PDFViewer && <PDFViewer {...props} />}</Suspense>;
}
@@ -0,0 +1,41 @@
import { Trans } from '@lingui/react/macro';
import type { ImageLoadingState } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { cn } from '@documenso/ui/lib/utils';
import { Spinner } from '@documenso/ui/primitives/spinner';
type PdfViewerPageImageProps = {
imageLoadingState: ImageLoadingState;
imageProps: React.ImgHTMLAttributes<HTMLImageElement> & Record<string, unknown> & { alt: '' };
};
export const PdfViewerPageImage = ({ imageLoadingState, imageProps }: PdfViewerPageImageProps) => {
return (
<>
{/* Loading State */}
{imageLoadingState === 'loading' && (
<div className="absolute inset-0 z-10 flex items-center justify-center text-muted-foreground opacity-20">
<Spinner />
</div>
)}
{imageLoadingState === 'error' && (
<div className="absolute inset-0 z-10 flex items-center justify-center">
<p>
<Trans>Error loading page</Trans>
</p>
</div>
)}
{/* The PDF image. */}
{imageProps.src && (
<img
{...imageProps}
className={cn(imageProps.className, 'select-none')}
draggable={false}
alt=""
/>
)}
</>
);
};
@@ -0,0 +1,26 @@
import { Trans } from '@lingui/react/macro';
import { Spinner } from '@documenso/ui/primitives/spinner';
export const PdfViewerLoadingState = () => {
return (
<div className="flex h-[80vh] max-h-[60rem] w-full flex-col items-center justify-center overflow-hidden opacity-50">
<Spinner />
</div>
);
};
export const PdfViewerErrorState = () => {
return (
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50 dark:bg-background">
<div className="text-center text-muted-foreground">
<p>
<Trans>Something went wrong while loading the document.</Trans>
</p>
<p className="mt-1 text-sm">
<Trans>Please try again or contact our support.</Trans>
</p>
</div>
</div>
);
};
@@ -0,0 +1,523 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import pMap from 'p-map';
import * as pdfjsLib from 'pdfjs-dist';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?url';
import type {
ImageLoadingState,
PageRenderData,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { ScrollTarget } from '../virtual-list/use-virtual-list';
import { useVirtualList } from '../virtual-list/use-virtual-list';
import { PdfViewerPageImage } from './pdf-viewer-page-image';
import { PdfViewerErrorState, PdfViewerLoadingState } from './pdf-viewer-states';
import { useScrollToPage } from './use-scroll-to-page';
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
type PageMeta = {
width: number;
height: number;
};
type LoadingState = 'loading' | 'loaded' | 'error';
const LOW_RENDER_RESOLUTION = 1;
const HIGH_RENDER_RESOLUTION = 2;
const IDLE_RENDER_DELAY = 200;
export type PDFViewerProps = {
className?: string;
/**
* The PDF data to render.
*
* If it's a URL, it will be fetched and rendered.
*
* If null will render an empty state.
*/
data: Uint8Array | string | null;
/**
* Ref to the scrollable parent container that handles scrolling.
*
* This must point to an element with `overflow-y: auto` or `overflow-y: scroll`
* that is an ancestor of this component, or `'window'` to use the browser
* window as the scroll container.
*/
scrollParentRef: ScrollTarget;
onDocumentLoad?: () => void;
/**
* Additional component to render next to the image, such as a Konva canvas
* for rendering fields.
*/
customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>;
} & React.HTMLAttributes<HTMLDivElement>;
export default function PDFViewer({
className,
data,
scrollParentRef,
onDocumentLoad,
customPageRenderer,
...props
}: PDFViewerProps) {
const { t } = useLingui();
const { toast } = useToast();
const $el = useRef<HTMLDivElement>(null);
const [loadingState, setLoadingState] = useState<LoadingState>('loading');
const pdfRef = useRef<pdfjsLib.PDFDocumentProxy | null>(null);
const [pages, setPages] = useState<PageMeta[]>([]);
useEffect(() => {
if (!data) {
return;
}
let isCancelled = false;
const fetchMetadata = async () => {
try {
setLoadingState('loading');
setPages([]);
if (isCancelled) {
return;
}
let result: Uint8Array | null = typeof data === 'string' ? null : new Uint8Array(data);
if (typeof data === 'string') {
const response = await fetch(data);
if (!response.ok) {
throw new Error(`Failed to fetch PDF data: ${response.status}`);
}
result = new Uint8Array(await response.arrayBuffer());
}
if (isCancelled) {
return;
}
const loadedPdf = await pdfjsLib.getDocument({ data: result!, cMapUrl: '/static/cmaps/' })
.promise;
if (isCancelled) {
await loadedPdf.destroy();
return;
}
// Destroy previous PDF if it exists
if (pdfRef.current) {
await pdfRef.current.destroy();
}
// eslint-disable-next-line require-atomic-updates
pdfRef.current = loadedPdf;
// Fetch the pages
const pages = await pMap(
Array.from({ length: loadedPdf.numPages }),
async (_, pageIndex) => {
const page = await loadedPdf.getPage(pageIndex + 1);
const viewport = page.getViewport({ scale: 1 });
return {
width: viewport.width,
height: viewport.height,
};
},
);
if (isCancelled) {
return;
}
setPages(pages);
setLoadingState('loaded');
} catch (err) {
if (isCancelled) {
return;
}
console.error(err);
setLoadingState('error');
toast({
title: t`Error`,
description: t`An error occurred while loading the document.`,
variant: 'destructive',
});
}
};
void fetchMetadata();
return () => {
isCancelled = true;
if (pdfRef.current) {
void pdfRef.current.destroy();
pdfRef.current = null;
}
};
}, [data]);
// Notify when document is loaded
useEffect(() => {
if (loadingState === 'loaded' && onDocumentLoad) {
onDocumentLoad();
}
}, [loadingState, onDocumentLoad]);
const isLoading = loadingState === 'loading';
const hasError = loadingState === 'error';
if (!data) {
return (
<div ref={$el} className={cn('h-full w-full', className)} {...props}>
<p className="py-32 text-center text-sm text-muted-foreground">
<Trans>No document found</Trans>
</p>
</div>
);
}
return (
<div ref={$el} className={cn('h-full w-full', className)} {...props}>
{/* Loading State */}
{isLoading && <PdfViewerLoadingState />}
{/* Error State */}
{hasError && <PdfViewerErrorState />}
{/* Loaded State */}
{loadingState === 'loaded' && pages.length > 0 && pdfRef.current && (
<VirtualizedPageList
scrollParentRef={scrollParentRef}
constraintRef={$el}
numPages={pages.length}
pages={pages}
pdf={pdfRef.current}
customPageRenderer={customPageRenderer}
/>
)}
</div>
);
}
type VirtualizedPageListProps = {
scrollParentRef: ScrollTarget;
constraintRef: React.RefObject<HTMLDivElement>;
pages: PageMeta[];
numPages: number;
pdf: pdfjsLib.PDFDocumentProxy;
customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>;
};
const VirtualizedPageList = ({
scrollParentRef,
constraintRef,
pages,
numPages,
pdf,
customPageRenderer,
}: VirtualizedPageListProps) => {
const contentRef = useRef<HTMLDivElement>(null);
const { virtualItems, totalSize, constraintWidth, scrollToItem } = useVirtualList({
scrollRef: scrollParentRef,
constraintRef,
contentRef,
itemCount: numPages,
itemSize: (index, width) => {
const pageMeta = pages[index];
// Calculate height based on aspect ratio and available width
const aspectRatio = pageMeta.height / pageMeta.width;
const scaledHeight = width * aspectRatio;
// Add 32px for the page number text and margins (my-2 = 8px * 2 + text height ~16px)
// Add additional 2px for the top and bottom borders.
return scaledHeight + 32 + 2;
},
overscan: 5,
});
useScrollToPage(contentRef, scrollToItem);
return (
<div
ref={contentRef}
// Note: This is actually used.
data-pdf-content=""
data-page-count={numPages}
style={{
height: `${totalSize}px`,
width: '100%',
position: 'relative',
}}
>
{virtualItems.map((virtualItem) => {
const index = virtualItem.index;
const pageMeta = pages[index];
const pageNumber = index + 1;
// Calculate scale based on constraint width
const scale = constraintWidth / pageMeta.width;
const scaledWidth = Math.floor(pageMeta.width * scale);
const scaledHeight = Math.floor(pageMeta.height * scale);
return (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: constraintWidth,
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<PdfViewerPage
unscaledWidth={pageMeta.width}
unscaledHeight={pageMeta.height}
scaledWidth={scaledWidth}
scaledHeight={scaledHeight}
pageNumber={pageNumber}
pdf={pdf}
scale={scale}
customPageRenderer={customPageRenderer}
/>
<p className="my-2 text-center text-[11px] text-muted-foreground/80">
<Trans>
Page {pageNumber} of {numPages}
</Trans>
</p>
</div>
);
})}
</div>
);
};
type PdfViewerPageProps = {
pageNumber: number;
pdf: pdfjsLib.PDFDocumentProxy;
unscaledWidth: number;
unscaledHeight: number;
scaledWidth: number;
scaledHeight: number;
scale: number;
customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>;
};
const PdfViewerPage = ({
pageNumber,
pdf,
unscaledWidth,
unscaledHeight,
scaledWidth,
scaledHeight,
scale,
customPageRenderer: CustomPageRenderer,
}: PdfViewerPageProps) => {
const { imageProps, imageLoadingState } = usePdfPageImage({
pageNumber,
pdf,
unscaledWidth,
unscaledHeight,
scaledWidth,
scaledHeight,
scale,
});
return (
<div
className="relative w-full rounded border border-border"
style={{ width: scaledWidth, height: scaledHeight }}
>
{CustomPageRenderer && imageLoadingState === 'loaded' && (
<CustomPageRenderer
pageData={{
scale,
pageIndex: pageNumber - 1,
pageNumber,
pageWidth: unscaledWidth,
pageHeight: unscaledHeight,
imageLoadingState,
}}
/>
)}
<PdfViewerPageImage imageLoadingState={imageLoadingState} imageProps={imageProps} />
</div>
);
};
/**
* Manages rendering a page from a pdf.
*/
const usePdfPageImage = ({
pageNumber,
pdf,
scale,
scaledWidth,
scaledHeight,
}: PdfViewerPageProps) => {
const [imageLoadingState, setImageLoadingState] = useState<ImageLoadingState>('loading');
const [imageUrl, setImageUrl] = useState('');
const renderTaskRef = useRef<pdfjsLib.RenderTask | null>(null);
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const renderedResolutionRef = useRef<number | null>(null);
const renderedPageNumberRef = useRef<number | null>(null);
const renderedPdfRef = useRef<pdfjsLib.PDFDocumentProxy | null>(null);
useEffect(() => {
let isCancelled = false;
const cancelRenderTask = () => {
if (!renderTaskRef.current) {
return;
}
renderTaskRef.current.cancel();
renderTaskRef.current = null;
};
const hasMatchingRenderedImage = (resolution: number) => {
return (
renderedPdfRef.current === pdf &&
renderedPageNumberRef.current === pageNumber &&
renderedResolutionRef.current === resolution
);
};
const setRenderedImageMeta = (resolution: number) => {
renderedPdfRef.current = pdf;
renderedPageNumberRef.current = pageNumber;
renderedResolutionRef.current = resolution;
};
const renderAtResolution = async (resolution: number) => {
let currentTask: pdfjsLib.RenderTask | null = null;
try {
if (isCancelled) {
return;
}
if (hasMatchingRenderedImage(resolution)) {
return;
}
cancelRenderTask();
const page = await pdf.getPage(pageNumber);
if (isCancelled) {
return;
}
const renderScale = scale * resolution;
const viewport = page.getViewport({ scale: renderScale });
const canvas = document.createElement('canvas');
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Failed to get canvas context');
}
currentTask = page.render({
canvasContext: context,
viewport,
canvas,
});
renderTaskRef.current = currentTask;
await currentTask.promise;
if (isCancelled || renderTaskRef.current !== currentTask) {
return;
}
setRenderedImageMeta(resolution);
setImageUrl(canvas.toDataURL('image/jpeg'));
} catch (err) {
if (err instanceof Error && err.name === 'RenderingCancelledException') {
return;
}
if (!isCancelled) {
console.error(err);
setImageLoadingState('error');
}
} finally {
if (renderTaskRef.current === currentTask) {
renderTaskRef.current = null;
}
}
};
void renderAtResolution(LOW_RENDER_RESOLUTION);
idleTimerRef.current = setTimeout(() => {
void renderAtResolution(HIGH_RENDER_RESOLUTION);
}, IDLE_RENDER_DELAY);
return () => {
isCancelled = true;
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current);
idleTimerRef.current = null;
}
cancelRenderTask();
};
}, [pdf, pageNumber, scale]);
const imageProps = useMemo(
(): React.ImgHTMLAttributes<HTMLImageElement> & Record<string, unknown> & { alt: '' } => ({
className: PDF_VIEWER_PAGE_CLASSNAME,
width: Math.floor(scaledWidth),
height: Math.floor(scaledHeight),
alt: '',
onLoad: () => setImageLoadingState('loaded'),
onError: () => setImageLoadingState('error'),
src: imageUrl,
'data-page-number': pageNumber,
draggable: false,
}),
[scaledWidth, scaledHeight, imageUrl, pageNumber],
);
return {
imageProps,
imageLoadingState,
};
};
@@ -0,0 +1,46 @@
import { type RefObject, useEffect } from 'react';
/**
* Watch for `data-scroll-to-page` attribute changes on a container element.
*
* When set (by `validateFieldsInserted`, `handleOnNextFieldClick`, or similar),
* scroll the virtual list to the requested page and clear the attribute.
*
* This is the communication bridge between field validation logic (which knows
* which page to scroll to) and the virtual list (which knows how to scroll).
*/
export const useScrollToPage = (
contentRef: RefObject<HTMLElement | null>,
scrollToItem: (index: number) => void,
) => {
useEffect(() => {
const el = contentRef.current;
if (!el) {
return;
}
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.attributeName === 'data-scroll-to-page') {
const raw = el.getAttribute('data-scroll-to-page');
if (raw) {
const pageNumber = parseInt(raw, 10);
if (!isNaN(pageNumber) && pageNumber >= 1) {
// Pages are 1-indexed, virtual list items are 0-indexed.
scrollToItem(pageNumber - 1);
}
el.removeAttribute('data-scroll-to-page');
}
}
}
});
observer.observe(el, { attributes: true, attributeFilter: ['data-scroll-to-page'] });
return () => observer.disconnect();
}, [contentRef, scrollToItem]);
};
@@ -13,12 +13,12 @@ import {
} from '@documenso/lib/constants/trpc';
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
import type { TTemplate } from '@documenso/lib/types/template';
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';
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 { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
@@ -28,6 +28,7 @@ import { AddTemplateSettingsFormPartial } from '@documenso/ui/primitives/templat
import type { TAddTemplateSettingsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-settings.types';
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 TemplateEditFormProps = {
@@ -313,10 +314,16 @@ export const TemplateEditForm = ({
>
<CardContent className="p-2">
<PDFViewerLazy
key={template.envelopeItems[0].id}
envelopeItem={template.envelopeItems[0]}
token={undefined}
version="signed"
key={template.envelopeItems[0]?.id}
data={getDocumentDataUrlForPdfViewer({
envelopeId: template.envelopeId,
envelopeItemId: template.envelopeItems[0]?.id,
documentDataId: initialTemplate.templateDocumentDataId,
version: 'current',
token: undefined,
presignToken: undefined,
})}
scrollParentRef="window"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>
@@ -0,0 +1,355 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
export type ScrollTarget = React.RefObject<HTMLElement | null> | 'window';
export type VirtualListOptions = {
scrollRef: ScrollTarget;
constraintRef?: React.RefObject<HTMLElement | null>;
/**
* Ref to the element that contains the virtual list content.
*
* Used to calculate the offset between the scroll container and the virtual
* list when the scroll container is a parent element higher in the DOM tree.
*
* When the virtual list is not at the top of the scroll container (e.g. there
* are headers, alerts, or other content above it), this offset ensures the
* scroll position is correctly adjusted for virtualization calculations.
*/
contentRef?: React.RefObject<HTMLElement | null>;
itemCount: number;
itemSize: number | ((index: number, constraintWidth: number) => number);
overscan?: number;
};
export type VirtualItem = {
index: number;
start: number;
size: number;
key: string;
};
export type VirtualListResult = {
virtualItems: VirtualItem[];
totalSize: number;
constraintWidth: number;
/**
* Scroll the scroll container so that the item at the given index is visible.
*
* The scroll position is calculated from the precomputed item offsets and
* adjusted for any content offset (e.g. headers above the virtual list).
*/
scrollToItem: (index: number) => void;
};
/**
* A minimal list virtualizer hook that supports fixed item sizes and external scroll containers.
*
* @param options - Configuration options for the virtual list
* @returns Virtual items to render, total size, and constraint width
*/
export const useVirtualList = (options: VirtualListOptions): VirtualListResult => {
const { scrollRef, constraintRef, contentRef, itemCount, itemSize, overscan = 3 } = options;
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
const [constraintWidth, setConstraintWidth] = useState(0);
/**
* The offset of the content element relative to the scroll container.
*
* This is recalculated on scroll to handle cases where dynamic content
* above the virtual list changes size.
*/
const contentOffsetRef = useRef(0);
// Track constraint element width with ResizeObserver
useEffect(() => {
const el = constraintRef?.current;
if (!el) {
return;
}
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setConstraintWidth(entry.contentRect.width);
}
});
observer.observe(el);
// Set initial width
setConstraintWidth(el.getBoundingClientRect().width);
return () => observer.disconnect();
}, [constraintRef]);
// Track scroll container dimensions with ResizeObserver
useEffect(() => {
if (scrollRef === 'window') {
const handleResize = () => {
setViewportHeight(window.innerHeight);
};
window.addEventListener('resize', handleResize);
// Set initial height
setViewportHeight(window.innerHeight);
return () => window.removeEventListener('resize', handleResize);
}
const el = scrollRef.current;
if (!el) {
return;
}
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setViewportHeight(entry.contentRect.height);
}
});
observer.observe(el);
// Set initial height
setViewportHeight(el.getBoundingClientRect().height);
return () => observer.disconnect();
}, [scrollRef]);
// Handle scroll events and calculate content offset
useEffect(() => {
if (scrollRef === 'window') {
const calculateOffset = () => {
const contentEl = contentRef?.current;
if (!contentEl) {
contentOffsetRef.current = 0;
return;
}
// For window scrolling, the offset is the distance from the top of the
// content element to the top of the document, which is its bounding rect
// top plus the current scroll position.
contentOffsetRef.current = contentEl.getBoundingClientRect().top + window.scrollY;
};
const handleScroll = () => {
calculateOffset();
const adjustedScrollTop = Math.max(0, window.scrollY - contentOffsetRef.current);
setScrollTop(adjustedScrollTop);
};
window.addEventListener('scroll', handleScroll, { passive: true });
// Set initial values
calculateOffset();
const adjustedScrollTop = Math.max(0, window.scrollY - contentOffsetRef.current);
setScrollTop(adjustedScrollTop);
return () => window.removeEventListener('scroll', handleScroll);
}
const scrollEl = scrollRef.current;
if (!scrollEl) {
return;
}
const calculateOffset = () => {
const contentEl = contentRef?.current;
if (!contentEl) {
contentOffsetRef.current = 0;
return;
}
const scrollRect = scrollEl.getBoundingClientRect();
const contentRect = contentEl.getBoundingClientRect();
// The offset is the distance from the top of the content element to
// the top of the scroll container, adjusted for current scroll position.
contentOffsetRef.current = contentRect.top - scrollRect.top + scrollEl.scrollTop;
};
const handleScroll = () => {
calculateOffset();
const adjustedScrollTop = Math.max(0, scrollEl.scrollTop - contentOffsetRef.current);
setScrollTop(adjustedScrollTop);
};
scrollEl.addEventListener('scroll', handleScroll, { passive: true });
// Set initial values
calculateOffset();
const adjustedScrollTop = Math.max(0, scrollEl.scrollTop - contentOffsetRef.current);
setScrollTop(adjustedScrollTop);
return () => scrollEl.removeEventListener('scroll', handleScroll);
}, [scrollRef, contentRef]);
// Get item size helper
const getItemSize = useCallback(
(index: number): number => {
if (typeof itemSize === 'function') {
return itemSize(index, constraintWidth);
}
return itemSize;
},
[itemSize, constraintWidth],
);
// Precompute item offsets for O(1) lookup
const { offsets, totalSize } = useMemo(() => {
const result: number[] = [];
let offset = 0;
for (let i = 0; i < itemCount; i++) {
result.push(offset);
offset += getItemSize(i);
}
return { offsets: result, totalSize: offset };
}, [itemCount, getItemSize]);
// Binary search to find the first visible item
const findStartIndex = useCallback(
(scrollTop: number): number => {
let low = 0;
let high = itemCount - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const offset = offsets[mid];
if (offset < scrollTop) {
low = mid + 1;
} else {
high = mid - 1;
}
}
return Math.max(0, low - 1);
},
[offsets, itemCount],
);
// Calculate virtual items to render
const virtualItems = useMemo((): VirtualItem[] => {
if (itemCount === 0 || constraintWidth === 0) {
return [];
}
const startIndex = findStartIndex(scrollTop);
const items: VirtualItem[] = [];
// Apply overscan before visible area
const overscanStart = Math.max(0, startIndex - overscan);
// Find items within the visible area + overscan
for (let i = overscanStart; i < itemCount; i++) {
const start = offsets[i];
const size = getItemSize(i);
// Stop if we've gone past the visible area + overscan
if (start > scrollTop + viewportHeight) {
// Add overscan items after visible area
const overscanEnd = Math.min(itemCount, i + overscan);
for (let j = i; j < overscanEnd; j++) {
items.push({
index: j,
start: offsets[j],
size: getItemSize(j),
key: `virtual-item-${j}`,
});
}
break;
}
items.push({
index: i,
start,
size,
key: `virtual-item-${i}`,
});
}
return items;
}, [
itemCount,
constraintWidth,
scrollTop,
viewportHeight,
overscan,
offsets,
getItemSize,
findStartIndex,
]);
/**
* Imperatively scroll the scroll container so that the item at the given
* index is at the top of the viewport.
*/
const scrollToItem = useCallback(
(index: number) => {
if (index < 0 || index >= itemCount) {
return;
}
const itemOffset = offsets[index] ?? 0;
if (scrollRef === 'window') {
const contentEl = contentRef?.current;
const contentTop = contentEl ? contentEl.getBoundingClientRect().top + window.scrollY : 0;
window.scrollTo({
top: contentTop + itemOffset,
behavior: 'smooth',
});
} else {
const scrollEl = scrollRef.current;
if (!scrollEl) {
return;
}
// Recalculate content offset to get the most up-to-date value.
const contentEl = contentRef?.current;
let contentOffset = 0;
if (contentEl) {
const scrollRect = scrollEl.getBoundingClientRect();
const contentRect = contentEl.getBoundingClientRect();
contentOffset = contentRect.top - scrollRect.top + scrollEl.scrollTop;
}
scrollEl.scrollTo({
top: contentOffset + itemOffset,
behavior: 'smooth',
});
}
},
[scrollRef, contentRef, offsets, itemCount],
);
return {
virtualItems,
totalSize,
constraintWidth,
scrollToItem,
};
};
@@ -3,7 +3,13 @@ import { useMemo } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type Field, type Recipient, type Signature, SigningStatus } from '@prisma/client';
import {
type Field,
type Recipient,
RecipientRole,
type Signature,
SigningStatus,
} from '@prisma/client';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod';
@@ -21,11 +27,27 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
const RECIPIENT_ROLE_LABELS: Record<RecipientRole, string> = {
[RecipientRole.SIGNER]: 'Signer',
[RecipientRole.APPROVER]: 'Approver',
[RecipientRole.CC]: 'CC',
[RecipientRole.VIEWER]: 'Viewer',
[RecipientRole.ASSISTANT]: 'Assistant',
};
const ZAdminUpdateRecipientFormSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
role: z.nativeEnum(RecipientRole),
});
type TAdminUpdateRecipientFormSchema = z.infer<typeof ZAdminUpdateRecipientFormSchema>;
@@ -49,6 +71,7 @@ export const AdminDocumentRecipientItemTable = ({ recipient }: RecipientItemProp
defaultValues: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
},
});
@@ -98,12 +121,17 @@ export const AdminDocumentRecipientItemTable = ({ recipient }: RecipientItemProp
] satisfies DataTableColumnDef<(typeof recipient)['fields'][number]>[];
}, []);
const onUpdateRecipientFormSubmit = async ({ name, email }: TAdminUpdateRecipientFormSchema) => {
const onUpdateRecipientFormSubmit = async ({
name,
email,
role,
}: TAdminUpdateRecipientFormSchema) => {
try {
await updateRecipient({
id: recipient.id,
name,
email,
role,
});
toast({
@@ -167,6 +195,43 @@ export const AdminDocumentRecipientItemTable = ({ recipient }: RecipientItemProp
)}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>
<Trans>Role</Trans>
</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={
form.formState.isSubmitting ||
recipient.signingStatus === SigningStatus.SIGNED
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(RecipientRole).map((role) => (
<SelectItem key={role} value={role}>
{RECIPIENT_ROLE_LABELS[role]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update Recipient</Trans>
@@ -1,8 +1,9 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import {
ArrowRightLeftIcon,
CreditCardIcon,
ExternalLinkIcon,
MoreHorizontalIcon,
@@ -29,6 +30,8 @@ import {
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { AdminSwapSubscriptionDialog } from '~/components/dialogs/admin-swap-subscription-dialog';
type AdminOrganisationsTableOptions = {
ownerUserId?: number;
memberUserId?: number;
@@ -44,6 +47,12 @@ export const AdminOrganisationsTable = ({
}: AdminOrganisationsTableOptions) => {
const { t, i18n } = useLingui();
const [swapSource, setSwapSource] = useState<{
id: string;
name: string;
ownerId: number;
} | null>(null);
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
@@ -131,7 +140,7 @@ export const AdminOrganisationsTable = ({
target="_blank"
className="flex flex-row items-center gap-2"
>
{SUBSCRIPTION_STATUS_MAP[row.original.subscription.status]}
{i18n._(SUBSCRIPTION_STATUS_MAP[row.original.subscription.status])}
<ExternalLinkIcon className="h-4 w-4" />
</Link>
) : (
@@ -143,7 +152,7 @@ export const AdminOrganisationsTable = ({
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
<MoreHorizontalIcon className="h-5 w-5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
@@ -172,12 +181,29 @@ export const AdminOrganisationsTable = ({
{!row.original.customerId && <span>&nbsp;(N/A)</span>}
</Link>
</DropdownMenuItem>
{row.original.subscription &&
(row.original.subscription.status === 'ACTIVE' ||
row.original.subscription.status === 'PAST_DUE') && (
<DropdownMenuItem
onClick={() =>
setSwapSource({
id: row.original.id,
name: row.original.name,
ownerId: row.original.owner.id,
})
}
>
<ArrowRightLeftIcon className="mr-2 h-4 w-4" />
<Trans>Move Subscription</Trans>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
}, [i18n, t, memberUserId, showOwnerColumn]);
return (
<div>
@@ -227,6 +253,20 @@ export const AdminOrganisationsTable = ({
) : null
}
</DataTable>
{swapSource && (
<AdminSwapSubscriptionDialog
sourceOrganisationId={swapSource.id}
sourceOrganisationName={swapSource.name}
userId={swapSource.ownerId}
open={true}
onOpenChange={(open) => {
if (!open) {
setSwapSource(null);
}
}}
/>
)}
</div>
);
};
+3 -3
View File
@@ -148,14 +148,14 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
</TooltipProvider>
</SessionProvider>
<ScrollRestoration />
<Scripts />
<script
dangerouslySetInnerHTML={{
__html: `window.__ENV__ = ${JSON.stringify(publicEnv)}`,
}}
/>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
@@ -1,5 +1,6 @@
import { Trans } from '@lingui/react/macro';
import {
AlertTriangleIcon,
BarChart3,
Building2Icon,
FileStack,
@@ -123,6 +124,20 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) {
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/unsealed-documents') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/unsealed-documents">
<AlertTriangleIcon className="mr-2 h-5 w-5" />
<Trans>Unsealed Documents</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn(
@@ -2,12 +2,16 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import { DownloadIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, redirect } from 'react-router';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { unsafeGetEntireEnvelope } from '@documenso/lib/server-only/admin/get-entire-document';
import { base64 } from '@documenso/lib/universal/base64';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { trpc } from '@documenso/trpc/react';
import { LocalTime } from '@documenso/ui/components/common/local-time';
import {
Accordion,
AccordionContent,
@@ -73,6 +77,31 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
},
});
const { mutateAsync: downloadAuditLogs, isPending: isDownloadAuditLogsLoading } =
trpc.admin.document.downloadAuditLogs.useMutation();
const onDownloadAuditLogsClick = async () => {
try {
const { data, envelopeTitle } = await downloadAuditLogs({
envelopeId: envelope.id,
});
const buffer = new Uint8Array(base64.decode(data));
const blob = new Blob([buffer], { type: 'application/pdf' });
downloadFile({
data: blob,
filename: `${envelopeTitle} - Audit Logs.pdf`,
});
} catch {
toast({
title: _(msg`Something went wrong`),
description: _(msg`Failed to download audit logs. Please try again later.`),
variant: 'destructive',
});
}
};
return (
<div>
<div className="flex items-start justify-between">
@@ -169,6 +198,40 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
</AccordionTrigger>
<AccordionContent className="border-t px-4 pt-4">
<div className="mb-4 grid grid-cols-4 gap-4 text-sm">
<div>
<span className="text-muted-foreground">
<Trans>Send Status</Trans>
</span>
<p className="font-medium">{recipient.sendStatus}</p>
</div>
<div>
<span className="text-muted-foreground">
<Trans>Read Status</Trans>
</span>
<p className="font-medium">{recipient.readStatus}</p>
</div>
<div>
<span className="text-muted-foreground">
<Trans>Signing Status</Trans>
</span>
<p className="font-medium">{recipient.signingStatus}</p>
</div>
<div>
<span className="text-muted-foreground">
<Trans>Completed At</Trans>
</span>
<p className="font-medium">
{recipient.signedAt ? <LocalTime date={recipient.signedAt} /> : '-'}
</p>
</div>
</div>
<hr className="mb-4" />
<AdminDocumentRecipientItemTable recipient={recipient} />
</AccordionContent>
</AccordionItem>
@@ -184,11 +247,26 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
<hr className="my-4" />
<Accordion type="single" collapsible className="w-full">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">
<Trans>Audit Logs</Trans>
</h2>
<Button
variant="outline"
loading={isDownloadAuditLogsLoading}
onClick={() => void onDownloadAuditLogsClick()}
>
{!isDownloadAuditLogsLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
<Trans>Download Audit Logs</Trans>
</Button>
</div>
<Accordion type="single" collapsible className="mt-4 w-full">
<AccordionItem value="audit-logs" className="rounded-lg border">
<AccordionTrigger className="px-4">
<h2 className="text-lg font-semibold">
<Trans>Audit Logs</Trans>
<Trans>View Audit Logs</Trans>
</h2>
</AccordionTrigger>

Some files were not shown because too many files have changed in this diff Show More