Compare commits

...

31 Commits

Author SHA1 Message Date
Lucas Smith 1b171f7d1b fix: update dockerfile 2026-03-06 15:10:06 +11:00
Lucas Smith d05d051053 fix: update dockerfile 2026-03-06 15:06:50 +11:00
Lucas Smith 30c5cf6d70 fix: update dockerfile 2026-03-06 14:57:25 +11:00
Lucas Smith dfac91916c fix: react19 errors 2026-03-06 14:46:42 +11:00
Lucas Smith a5a795cb7b fix: update ci 2026-03-06 14:29:43 +11:00
Lucas Smith b6da33282e fix: make prisma work 2026-03-06 14:29:41 +11:00
Lucas Smith 0d1496bc26 fix: set explicit prisma client output for pnpm compatibility
pnpm's strict node_modules layout causes prisma generate to output to
a deeply nested .pnpm directory, but @prisma/client resolves .prisma/client
from the root node_modules. Setting an explicit output path ensures the
generated client is always found at the expected location.
2026-03-06 14:28:59 +11:00
Lucas Smith b5e1874ced fix: remove remaining double-dash issues in CI and docs
The translate:compile CI scripts had a double `--` that
would pass a spurious `--` to lingui. CLAUDE.md still
referenced the old `with:env --` pattern.
2026-03-06 14:28:59 +11:00
Lucas Smith 0b8d107291 fix: remove double-dash in pnpm with:env script calls
pnpm does not consume `--` the way npm did when passing
args to scripts. The extra `--` was being forwarded to
dotenv-cli which tried to spawn it as a command, causing
ENOENT errors in CI.
2026-03-06 14:28:59 +11:00
Lucas Smith f66f01a09a build: standardise on react 19 and deduplicate deps
Eliminates dual React ecosystems and reduces dependency
duplication by consolidating on a single React version and
expanding the pnpm workspace catalog.

- Upgrade all packages from React 18 to React 19
- Remove @types/react and @types/node overrides (no longer
  needed with single React version)
- Standardise @types/node to ^22 across all packages
- Add prisma-kysely>@prisma/internals override to eliminate
  old prisma 6.16.x subtree
- Fix React 19 type changes: useRef requires initial value,
  JSX namespace needs explicit import, RefObject includes null
- Expand catalog with 14 new entries (ai, dotenv, pino,
  playwright, prisma tooling, nodemailer, pdfjs-dist, etc.)
- Catalog radix-ui, next, lucide-react, postcss, and
  typescript for docs/openpage-api alignment
- Run pnpm dedupe to collapse peer-dep context duplicates
2026-03-06 14:28:58 +11:00
Lucas Smith 960217c78d build: migrate from npm to pnpm with workspace catalogs
- Switch package manager to pnpm 10 via corepack
- Add pnpm-workspace.yaml with 54+ shared dependency catalogs
- Convert all workspace packages to catalog: and workspace:* protocols
- Upgrade Turborepo from 1.x to 2.8.12 (pipeline -> tasks)
- Upgrade apps/openpage-api from Next 15 to Next 16
- Update Docker, CI workflows, and GitHub Actions for pnpm
- Convert patch file to pnpm native format
- Replace deprecated next lint with standalone eslint
- Update all documentation references from npm to pnpm
- Fix stale dependabot config and documentation paths
2026-03-06 14:28:12 +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
286 changed files with 45917 additions and 42749 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.
+7 -21
View File
@@ -1,4 +1,4 @@
name: 'Setup node and cache node_modules'
name: 'Setup node and install dependencies with pnpm'
inputs:
node_version:
required: false
@@ -7,33 +7,19 @@ inputs:
runs:
using: 'composite'
steps:
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Set up Node ${{ inputs.node_version }}
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
- name: Cache npm
uses: actions/cache@v3
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
restore-keys: npm-
- name: Cache node_modules
uses: actions/cache@v3
id: cache-node-modules
with:
path: |
node_modules
packages/*/node_modules
apps/*/node_modules
key: modules-${{ hashFiles('package-lock.json') }}
cache: 'pnpm'
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
shell: bash
run: |
npm ci --no-audit
npm run prisma:generate
pnpm install --frozen-lockfile
pnpm run prisma:generate
env:
HUSKY: '0'
@@ -10,10 +10,10 @@ runs:
path: |
~/.cache/ms-playwright
${{ github.workspace }}/node_modules/playwright
key: playwright-${{ hashFiles('**/package-lock.json') }}
key: playwright-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: playwright-
- name: Install playwright
if: steps.cache-playwright.outputs.cache-hit != 'true'
run: npx playwright install --with-deps
run: pnpm exec playwright install --with-deps
shell: bash
+2 -3
View File
@@ -12,11 +12,10 @@ updates:
open-pull-requests-limit: 0
- package-ecosystem: 'npm'
directory: '/apps/web'
directory: '/'
schedule:
interval: 'weekly'
target-branch: 'main'
labels:
- 'npm dependencies'
- 'frontend'
- 'dependencies'
open-pull-requests-limit: 0
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
run: cp .env.example .env
- name: Build app
run: npm run build
run: pnpm run build
build_docker:
name: Build Docker Image
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- uses: ./.github/actions/node-install
- name: Build app
run: npm run build
run: pnpm run build
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
+5 -5
View File
@@ -23,21 +23,21 @@ jobs:
- uses: ./.github/actions/node-install
- name: Start Services
run: npm run dx:up
run: pnpm run dx:up
- uses: ./.github/actions/playwright-install
- name: Create the database
run: npm run prisma:migrate-dev
run: pnpm run prisma:migrate-dev
- name: Seed the database
run: npm run prisma:seed
run: pnpm run prisma:seed
- name: Install playwright browsers
run: npx playwright install --with-deps
run: pnpm exec playwright install --with-deps
- name: Run Playwright tests
run: npm run ci
run: pnpm run ci
env:
# Needed since we use next start which will set the NODE_ENV to production
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH: './example/cert.p12'
+5 -3
View File
@@ -16,14 +16,16 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: npm
- name: Install Octokit
run: npm install @octokit/rest@18
run: pnpm install @octokit/rest@18
- name: Check Assigned User's Issue Count
id: parse-comment
+5 -2
View File
@@ -16,14 +16,17 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: npm
- name: Install Octokit
run: npm install @octokit/rest@18
run: pnpm install @octokit/rest@18
- name: Check user's PRs awaiting review
id: parse-prs
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
- name: Compile translations
id: compile_translations
run: npm run translate:compile -- -- --strict
run: pnpm run translate:compile -- --strict
continue-on-error: true
- name: Pull translations from Crowdin
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
- uses: ./.github/actions/node-install
- name: Extract translations
run: npm run translate:extract
run: pnpm run translate:extract
- name: Commit changes and push to reserved branch
env:
@@ -78,7 +78,7 @@ jobs:
- name: Compile translations
id: compile_translations
run: npm run translate:compile -- -- --strict
run: pnpm run translate:compile -- --strict
continue-on-error: true
- name: Upload missing translations
+1 -3
View File
@@ -20,9 +20,7 @@ build
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# local env files
.env
+3 -2
View File
@@ -1,2 +1,3 @@
legacy-peer-deps = true
prefer-dedupe = true
# Hoist packages that expect to be resolvable from any workspace.
# Start strict, add patterns here only as needed.
shamefully-hoist=true
+11 -11
View File
@@ -24,8 +24,8 @@ Run these commands to understand where the previous session left off:
```bash
git status # See uncommitted changes
git log --oneline -10 # See recent commits
npm run typecheck -w @documenso/remix # Check for type errors
npm run lint:fix # Check for linting issues
pnpm run typecheck -w @documenso/remix # Check for type errors
pnpm run lint:fix # Check for linting issues
```
Review the code that's already been written to understand:
@@ -67,9 +67,9 @@ Review the code that's already been written to understand:
Work continuously through these steps:
1. **Implement** - Write the code for the current task
2. **Typecheck** - Run `npm run typecheck -w @documenso/remix` to verify types
3. **Lint** - Run `npm run lint:fix` to fix linting issues
4. **Test** - If non-trivial, run E2E tests: `npm run test:dev -w @documenso/app-tests`
2. **Typecheck** - Run `pnpm run typecheck -w @documenso/remix` to verify types
3. **Lint** - Run `pnpm run lint:fix` to fix linting issues
4. **Test** - If non-trivial, run E2E tests: `pnpm run test:dev -w @documenso/app-tests`
5. **Fix** - If tests fail, fix and re-run
6. **Repeat** - Move to next task
@@ -93,18 +93,18 @@ Work continuously through these steps:
```bash
# Type checking
npm run typecheck -w @documenso/remix
pnpm run typecheck -w @documenso/remix
# Linting
npm run lint:fix
pnpm run lint:fix
# E2E Tests (only for non-trivial work)
npm run test:dev -w @documenso/app-tests # Run E2E tests in dev mode
npm run test-ui:dev -w @documenso/app-tests # Run E2E tests with UI
npm run test:e2e # Run full E2E test suite
pnpm run test:dev -w @documenso/app-tests # Run E2E tests in dev mode
pnpm run test-ui:dev -w @documenso/app-tests # Run E2E tests with UI
pnpm run test:e2e # Run full E2E test suite
# Development
npm run dev # Start dev server
pnpm run dev # Start dev server
```
## Begin
+4 -3
View File
@@ -14,6 +14,7 @@ You are creating a new justification file in the `.agents/justifications/` direc
## Usage
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/`
@@ -25,7 +26,7 @@ The script will automatically:
If you have the content ready, run:
```bash
npx tsx scripts/create-justification.ts "$ARGUMENTS" "Your justification content here"
pnpm exec tsx scripts/create-justification.ts "$ARGUMENTS" "Your justification content here"
```
### Option 2: Multi-line Content (Heredoc)
@@ -33,7 +34,7 @@ npx tsx scripts/create-justification.ts "$ARGUMENTS" "Your justification content
For multi-line content, use heredoc:
```bash
npx tsx scripts/create-justification.ts "$ARGUMENTS" << HEREDOC
pnpm exec tsx scripts/create-justification.ts "$ARGUMENTS" << HEREDOC
Your multi-line
justification content
goes here
@@ -45,7 +46,7 @@ HEREDOC
You can also pipe content:
```bash
echo "Your content" | npx tsx scripts/create-justification.ts "$ARGUMENTS"
echo "Your content" | pnpm exec tsx scripts/create-justification.ts "$ARGUMENTS"
```
## File Format
+3 -3
View File
@@ -26,7 +26,7 @@ The script will automatically:
If you have the content ready, run:
```bash
npx tsx scripts/create-plan.ts "$ARGUMENTS" "Your plan content here"
pnpm exec tsx scripts/create-plan.ts "$ARGUMENTS" "Your plan content here"
```
### Option 2: Multi-line Content (Heredoc)
@@ -34,7 +34,7 @@ npx tsx scripts/create-plan.ts "$ARGUMENTS" "Your plan content here"
For multi-line content, use heredoc:
```bash
npx tsx scripts/create-plan.ts "$ARGUMENTS" << HEREDOC
pnpm exec tsx scripts/create-plan.ts "$ARGUMENTS" << HEREDOC
Your multi-line
plan content
goes here
@@ -46,7 +46,7 @@ HEREDOC
You can also pipe content:
```bash
echo "Your content" | npx tsx scripts/create-plan.ts "$ARGUMENTS"
echo "Your content" | pnpm exec tsx scripts/create-plan.ts "$ARGUMENTS"
```
## File Format
+4 -3
View File
@@ -14,6 +14,7 @@ You are creating a new scratch file in the `.agents/scratches/` directory.
## Usage
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/`
@@ -25,7 +26,7 @@ The script will automatically:
If you have the content ready, run:
```bash
npx tsx scripts/create-scratch.ts "$ARGUMENTS" "Your scratch content here"
pnpm exec tsx scripts/create-scratch.ts "$ARGUMENTS" "Your scratch content here"
```
### Option 2: Multi-line Content (Heredoc)
@@ -33,7 +34,7 @@ npx tsx scripts/create-scratch.ts "$ARGUMENTS" "Your scratch content here"
For multi-line content, use heredoc:
```bash
npx tsx scripts/create-scratch.ts "$ARGUMENTS" << HEREDOC
pnpm exec tsx scripts/create-scratch.ts "$ARGUMENTS" << HEREDOC
Your multi-line
scratch content
goes here
@@ -45,7 +46,7 @@ HEREDOC
You can also pipe content:
```bash
echo "Your content" | npx tsx scripts/create-scratch.ts "$ARGUMENTS"
echo "Your content" | pnpm exec tsx scripts/create-scratch.ts "$ARGUMENTS"
```
## File Format
+3 -3
View File
@@ -17,8 +17,8 @@ You are creating proper MDX documentation for a module or feature in Documenso u
Create documentation in the appropriate location:
- **Developer docs**: `apps/documentation/pages/developers/`
- **User docs**: `apps/documentation/pages/users/`
- **Developer docs**: `apps/docs/`
- **User docs**: `apps/docs/`
### File Format
@@ -66,7 +66,7 @@ Brief description of what this module/feature does and when to use it.
If there are specific packages or imports needed:
```bash
npm install @documenso/package-name
pnpm install @documenso/package-name
```
## Quick Start
+9 -9
View File
@@ -55,9 +55,9 @@ You are implementing a specification from the `.agents/plans/` directory. Work a
Work continuously through these steps:
1. **Implement** - Write the code for the current task
2. **Typecheck** - Run `npm run typecheck -w @documenso/remix` to verify types
3. **Lint** - Run `npm run lint:fix` to fix linting issues
4. **Test** - If non-trivial, run E2E tests: `npm run test:dev -w @documenso/app-tests`
2. **Typecheck** - Run `pnpm run typecheck -w @documenso/remix` to verify types
3. **Lint** - Run `pnpm run lint:fix` to fix linting issues
4. **Test** - If non-trivial, run E2E tests: `pnpm run test:dev -w @documenso/app-tests`
5. **Fix** - If tests fail, fix and re-run
6. **Repeat** - Move to next task
@@ -81,18 +81,18 @@ Work continuously through these steps:
```bash
# Type checking
npm run typecheck -w @documenso/remix
pnpm run typecheck -w @documenso/remix
# Linting
npm run lint:fix
pnpm run lint:fix
# E2E Tests (only for non-trivial work)
npm run test:dev -w @documenso/app-tests # Run E2E tests in dev mode
npm run test-ui:dev -w @documenso/app-tests # Run E2E tests with UI
npm run test:e2e # Run full E2E test suite
pnpm run test:dev -w @documenso/app-tests # Run E2E tests in dev mode
pnpm run test-ui:dev -w @documenso/app-tests # Run E2E tests with UI
pnpm run test:e2e # Run full E2E test suite
# Development
npm run dev # Start dev server
pnpm run dev # Start dev server
```
## Begin
@@ -21,13 +21,13 @@ I help you create new justification files in the `.agents/justifications/` direc
Run the script with a slug and content:
```bash
npx tsx scripts/create-justification.ts "decision-name" "Justification content here"
pnpm exec tsx scripts/create-justification.ts "decision-name" "Justification content here"
```
Or use heredoc for multi-line content:
```bash
npx tsx scripts/create-justification.ts "decision-name" << HEREDOC
pnpm exec tsx scripts/create-justification.ts "decision-name" << HEREDOC
Multi-line
justification content
goes here
+2 -2
View File
@@ -21,13 +21,13 @@ I help you create new plan files in the `.agents/plans/` directory. Each plan fi
Run the script with a slug and content:
```bash
npx tsx scripts/create-plan.ts "feature-name" "Plan content here"
pnpm exec tsx scripts/create-plan.ts "feature-name" "Plan content here"
```
Or use heredoc for multi-line content:
```bash
npx tsx scripts/create-plan.ts "feature-name" << HEREDOC
pnpm exec tsx scripts/create-plan.ts "feature-name" << HEREDOC
Multi-line
plan content
goes here
+2 -2
View File
@@ -21,13 +21,13 @@ I help you create new scratch files in the `.agents/scratches/` directory. Each
Run the script with a slug and content:
```bash
npx tsx scripts/create-scratch.ts "note-name" "Scratch content here"
pnpm exec tsx scripts/create-scratch.ts "note-name" "Scratch content here"
```
Or use heredoc for multi-line content:
```bash
npx tsx scripts/create-scratch.ts "note-name" << HEREDOC
pnpm exec tsx scripts/create-scratch.ts "note-name" << HEREDOC
Multi-line
scratch content
goes here
@@ -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.
+9 -9
View File
@@ -2,16 +2,16 @@
## Build/Test/Lint Commands
- `npm run build` - Build all packages
- `npm run lint` - Lint all packages
- `npm run lint:fix` - Auto-fix linting issues
- `npm run test:e2e` - Run E2E tests with Playwright
- `npm run test:dev -w @documenso/app-tests` - Run single E2E test in dev mode
- `npm run test-ui:dev -w @documenso/app-tests` - Run E2E tests with UI
- `npm run format` - Format code with Prettier
- `npm run dev` - Start development server for Remix app
- `pnpm run build` - Build all packages
- `pnpm run lint` - Lint all packages
- `pnpm run lint:fix` - Auto-fix linting issues
- `pnpm run test:e2e` - Run E2E tests with Playwright
- `pnpm run test:dev -w @documenso/app-tests` - Run single E2E test in dev mode
- `pnpm run test-ui:dev -w @documenso/app-tests` - Run E2E tests with UI
- `pnpm run format` - Format code with Prettier
- `pnpm run dev` - Start development server for Remix app
**Important:** Do not run `npm run build` to verify changes unless explicitly asked. Builds take a long time (~2 minutes). Use `npx tsc --noEmit` for type checking specific packages if needed.
**Important:** Do not run `pnpm run build` to verify changes unless explicitly asked. Builds take a long time (~2 minutes). Use `pnpm exec tsc --noEmit` for type checking specific packages if needed.
## Code Style Guidelines
+6 -6
View File
@@ -4,7 +4,7 @@ This document provides a high-level overview of the Documenso codebase to help h
## Overview
Documenso is an open-source document signing platform built as a **monorepo** using npm workspaces and Turborepo. The application enables users to create, send, and sign documents electronically.
Documenso is an open-source document signing platform built as a **monorepo** using pnpm workspaces and Turborepo. The application enables users to create, send, and sign documents electronically.
```
┌─────────────────────────────────────────────────────────────────────────────┐
@@ -324,19 +324,19 @@ documenso/
```bash
# Full setup (install, docker, migrate, seed, dev)
npm run d
pnpm run d
# Start development server
npm run dev
pnpm run dev
# Database GUI
npm run prisma:studio
pnpm run prisma:studio
# Type checking (faster than build)
npx tsc --noEmit
pnpm exec tsc --noEmit
# E2E tests
npm run test:e2e
pnpm run test:e2e
```
### Docker Services (Development)
+1 -1
View File
@@ -50,7 +50,7 @@ The development branch is <code>main</code>. All pull requests should be made ag
You can build the project with:
```bash
npm run build
pnpm run build
```
## AI-Assisted Development with OpenCode
+17 -20
View File
@@ -119,16 +119,15 @@ git clone https://github.com/<your-username>/documenso
2. Set up your `.env` file using the recommendations in the `.env.example` file. Alternatively, just run `cp .env.example .env` to get started with our handpicked defaults.
3. Run `npm run dx` in the root directory
3. Run `pnpm run dx` in the root directory
- This will spin up a postgres database and inbucket mailserver in a docker container.
4. Run `npm run dev` in the root directory
4. Run `pnpm run dev` in the root directory
5. Want it even faster? Just use
```sh
npm run d
pnpm run d
```
#### Access Points for Your Application
@@ -136,7 +135,6 @@ npm run d
1. **App** - http://localhost:3000
2. **Incoming Mail Access** - http://localhost:9000
3. **Database Connection Details**
- **Port**: 54320
- **Connection**: Use your favorite database client to connect using the provided port.
@@ -156,12 +154,11 @@ After forking the repository, clone it to your local device by using the followi
git clone https://github.com/<your-username>/documenso
```
2. Run `npm i` in the root directory
2. Run `pnpm install` in the root directory
3. Create your `.env` from the `.env.example`. You can use `cp .env.example .env` to get started with our handpicked defaults.
4. Set the following environment variables:
- NEXTAUTH_SECRET
- NEXT_PUBLIC_WEBAPP_URL
- NEXT_PRIVATE_DATABASE_URL
@@ -169,17 +166,17 @@ git clone https://github.com/<your-username>/documenso
- NEXT_PRIVATE_SMTP_FROM_NAME
- NEXT_PRIVATE_SMTP_FROM_ADDRESS
5. Create the database schema by running `npm run prisma:migrate-dev`
5. Create the database schema by running `pnpm run prisma:migrate-dev`
6. Run `npm run translate:compile` in the root directory to compile lingui
6. Run `pnpm run translate:compile` in the root directory to compile lingui
7. Run `npm run dev` in the root directory to start
7. Run `pnpm run dev` in the root directory to start
8. Register a new user at http://localhost:3000/signup
---
- Optional: Seed the database using `npm run prisma:seed -w @documenso/prisma` to create a test user and document.
- Optional: Seed the database using `pnpm run prisma:seed -w @documenso/prisma` to create a test user and document.
- Optional: Create your own signing certificate.
- To generate your own using these steps and a Linux Terminal or Windows Subsystem for Linux (WSL), see **[Create your own signing certificate](./SIGNING.md)**.
@@ -244,16 +241,16 @@ The following environment variables must be set:
Now you can install the dependencies and build it:
```
npm i
npm run build
npm run prisma:migrate-deploy
pnpm install
pnpm run build
pnpm run prisma:migrate-deploy
```
Finally, you can start it with:
```
cd apps/remix
npm run start
pnpm run start
```
This will start the server on `localhost:3000`. For now, any reverse proxy can then do the frontend and SSL termination.
@@ -313,7 +310,7 @@ If you are deploying to a cluster that uses only IPv6, You can use a custom comm
For local docker run
```bash
docker run -it documenso:latest npm run start -- -H ::
docker run -it documenso:latest pnpm run start -- -H ::
```
For k8s or docker-compose
@@ -324,7 +321,7 @@ containers:
image: documenso:latest
imagePullPolicy: IfNotPresent
command:
- npm
- pnpm
args:
- run
- start
@@ -338,13 +335,13 @@ containers:
Wrap your package script with the `with:env` script like such:
```
npm run with:env -- npm run myscript
pnpm run with:env pnpm run myscript
```
The same can be done when using `npx` for one of the bin scripts:
The same can be done when using `pnpm exec` for one of the bin scripts:
```
npm run with:env -- npx myscript
pnpm run with:env pnpm exec myscript
```
This will load environment variables from your `.env` and `.env.local` files.
@@ -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
+11 -11
View File
@@ -10,27 +10,27 @@
"postinstall": "fumadocs-mdx"
},
"dependencies": {
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tabs": "catalog:",
"fumadocs-core": "16.5.0",
"fumadocs-mdx": "14.2.6",
"fumadocs-ui": "16.5.0",
"lucide-react": "^0.563.0",
"mermaid": "^11.12.2",
"next": "16.1.6",
"lucide-react": "catalog:",
"mermaid": "^11.12.3",
"next": "catalog:",
"next-plausible": "^3.12.5",
"next-themes": "^0.4.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react": "catalog:",
"react-dom": "catalog:",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@types/mdx": "^2.0.13",
"@types/node": "^25.1.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.6",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"postcss": "catalog:",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
"typescript": "catalog:"
}
}
@@ -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;
+9 -7
View File
@@ -6,17 +6,19 @@
"dev": "next dev -p 3003",
"build": "next build",
"start": "next start -p 3003",
"lint:fix": "next lint --fix",
"lint:fix": "eslint . --fix",
"clean": "rimraf .next && rimraf node_modules"
},
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.7.2",
"next": "15.5.12"
"@documenso/prisma": "workspace:*",
"luxon": "catalog:",
"next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "18.3.27",
"typescript": "5.6.2"
"@types/node": "catalog:",
"@types/react": "catalog:",
"typescript": "catalog:"
}
}
+19 -5
View File
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -19,9 +23,19 @@
}
],
"paths": {
"@/*": ["./*"]
"@/*": [
"./*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
+3 -3
View File
@@ -17,13 +17,13 @@ cd "$WEB_APP_DIR"
start_time=$(date +%s)
echo "[Build]: Extracting and compiling translations"
npm run translate --prefix ../../
pnpm run --filter @documenso/root translate
echo "[Build]: Building app"
npm run build:app
pnpm run build:app
echo "[Build]: Building server"
npm run build:server
pnpm run build:server
# Copy over the entry point for the server.
cp server/main.js build/server/main.js
-26
View File
@@ -1,26 +0,0 @@
FROM node:20-alpine AS dependencies-env
RUN npm i -g pnpm
COPY . /app
FROM dependencies-env AS development-dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
WORKDIR /app
RUN pnpm i --frozen-lockfile
FROM dependencies-env AS production-dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
WORKDIR /app
RUN pnpm i --prod --frozen-lockfile
FROM dependencies-env AS build-env
COPY ./package.json pnpm-lock.yaml /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN pnpm build
FROM dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["pnpm", "start"]
@@ -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 { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
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 && (
<PDFViewer 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 { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
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() !== '');
@@ -341,10 +340,16 @@ export const EmbedDirectTemplateClientPage = ({
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */}
<div className="flex-1">
<PDFViewerLazy
envelopeItem={envelopeItems[0]}
token={recipient.token}
version="signed"
<PDFViewer
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 { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
import { injectCss } from '~/utils/css-vars';
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
@@ -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[];
@@ -100,14 +101,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();
@@ -287,10 +288,16 @@ export const EmbedSignDocumentV1ClientPage = ({
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */}
<div className="embed--DocumentViewer flex-1">
<PDFViewerLazy
envelopeItem={envelopeItems[0]}
token={token}
version="signed"
<PDFViewer
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>
@@ -491,15 +498,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';
@@ -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 { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
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) => {
@@ -226,10 +229,16 @@ export const MultiSignDocumentSigningView = ({
'md:mx-auto md:max-w-2xl': document.status === DocumentStatus.COMPLETED,
})}
>
<PDFViewerLazy
envelopeItem={document.envelopeItems[0]}
token={token}
version="signed"
<PDFViewer
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?.();
@@ -362,19 +371,13 @@ export const MultiSignDocumentSigningView = ({
)}
</div>
{hasDocumentLoaded && (
{hasDocumentLoaded && showPendingFieldTooltip && pendingFields.length > 0 && (
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pendingFields[0].page}"]`}
>
{showPendingFieldTooltip && pendingFields.length > 0 && (
<FieldToolTip
key={pendingFields[0].id}
field={pendingFields[0]}
color="warning"
>
<Trans>Click to insert field</Trans>
</FieldToolTip>
)}
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
<Trans>Click to insert field</Trans>
</FieldToolTip>
</ElementVisible>
)}
@@ -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: 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}
@@ -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 { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
import {
DirectTemplateConfigureForm,
@@ -151,11 +152,17 @@ export const DirectTemplatePageView = ({
gradient
>
<CardContent className="p-2">
<PDFViewerLazy
<PDFViewer
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 { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
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;
@@ -274,11 +273,17 @@ export const DocumentSigningPageViewV1 = ({
<div className="flex-1">
<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"
<PDFViewer
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,4 +1,4 @@
import { lazy, useMemo } from 'react';
import { useMemo, useRef } from 'react';
import { Plural, Trans } from '@lingui/react/macro';
import { EnvelopeType, RecipientRole } from '@prisma/client';
@@ -8,8 +8,8 @@ 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 { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
@@ -23,6 +23,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 +35,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,
@@ -199,7 +199,10 @@ export const DocumentSigningPageViewV2 = () => {
</div>
</div>
<div className="embed--DocumentContainer flex-1 overflow-y-auto">
<div
className="embed--DocumentContainer flex-1 overflow-y-auto"
ref={scrollableContainerRef}
>
<div className="flex flex-col">
{/* Horizontal envelope item selector */}
{envelopeItems.length > 1 && (
@@ -228,15 +231,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, useRef, 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 { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
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
@@ -149,11 +150,17 @@ export const DocumentCertificateQRView = ({
</div>
<div className="mt-12 w-full">
<PDFViewerLazy
key={envelopeItems[0].id}
envelopeItem={envelopeItems[0]}
token={token}
version="signed"
<PDFViewer
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,9 @@ const DocumentCertificateQrV2 = ({
formattedDate,
token,
}: DocumentCertificateQrV2Props) => {
const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender();
const { envelopeItems } = useCurrentEnvelopeRender();
const scrollableContainerRef = useRef<HTMLDivElement>(null);
return (
<div className="flex min-h-screen flex-col items-start">
@@ -207,10 +216,14 @@ const DocumentCertificateQrV2 = ({
/>
</div>
<div className="mt-12 w-full">
<div className="mt-12 max-h-[80vh] w-full overflow-y-auto" ref={scrollableContainerRef}>
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
<PDFViewerKonvaLazy renderer="preview" customPageRenderer={EnvelopeGenericPageRenderer} />
<EnvelopePdfViewer
customPageRenderer={EnvelopeGenericPageRenderer}
scrollParentRef={scrollableContainerRef}
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 { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
import { useCurrentTeam } from '~/providers/team';
export type DocumentEditFormProps = {
@@ -440,11 +441,17 @@ export const DocumentEditForm = ({
gradient
>
<CardContent className="p-2">
<PDFViewerLazy
key={document.envelopeItems[0].id}
envelopeItem={document.envelopeItems[0]}
token={undefined}
version="signed"
<PDFViewer
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,12 @@ 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" ref={scrollableContainerRef}>
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector 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 +172,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 +244,40 @@ export const EnvelopeEditorFieldsPage = () => {
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
{editorConfig.fields?.allowAIDetection && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</>
)}
</section>
{/* Field details section. */}
@@ -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,4 +1,4 @@
import { lazy, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { faker } from '@faker-js/faker/locale/en';
import { Trans } from '@lingui/react/macro';
@@ -11,21 +11,20 @@ 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 { EnvelopeRendererFileSelector } from './envelope-file-selector';
import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
const EnvelopeGenericPageRenderer = lazy(
async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'),
);
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
// Todo: Envelopes - Dynamically import faker
export const EnvelopeEditorPreviewPage = () => {
@@ -33,6 +32,8 @@ export const EnvelopeEditorPreviewPage = () => {
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
'recipient',
);
@@ -200,7 +201,9 @@ 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) => ({
@@ -212,12 +215,12 @@ export const EnvelopeEditorPreviewPage = () => {
}}
>
<div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto">
<div className="flex w-full flex-col overflow-y-auto" ref={scrollableContainerRef}>
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector 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">
<Alert variant="warning" className="mb-4 max-w-[800px]">
<AlertTitle>
<Trans>Preview Mode</Trans>
@@ -228,9 +231,10 @@ export const EnvelopeEditorPreviewPage = () => {
</Alert>
{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>
);
}
};
@@ -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,63 @@
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 { PDFViewer, type PDFViewerProps } from './pdf-viewer';
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 (
<PDFViewer
{...props}
className={cn('h-full w-full max-w-[800px]', className)}
data={currentEnvelopeItem.data}
/>
);
};
export default EnvelopePdfViewer;
@@ -0,0 +1,34 @@
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, '')} 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,494 @@
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 const 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 [pdf, setPdf] = useState<pdfjsLib.PDFDocumentProxy | null>(null);
const [pages, setPages] = useState<PageMeta[]>([]);
useEffect(() => {
if (!data) {
return;
}
const fetchMetadata = async () => {
try {
setLoadingState('loading');
setPages([]);
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());
}
const loadedPdf = await pdfjsLib.getDocument({ data: result! }).promise;
if (pdf) {
await pdf.destroy();
}
setPdf(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,
};
},
);
setPages(pages);
setLoadingState('loaded');
} catch (err) {
console.error(err);
setLoadingState('error');
toast({
title: t`Error`,
description: t`An error occurred while loading the document.`,
variant: 'destructive',
});
}
};
void fetchMetadata();
return () => {
if (pdf) {
void pdf.destroy();
}
};
}, [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="text-muted-foreground py-32 text-center text-sm">
<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 && pdf && (
<VirtualizedPageList
scrollParentRef={scrollParentRef}
constraintRef={$el}
numPages={pages.length}
pages={pages}
pdf={pdf}
customPageRenderer={customPageRenderer}
/>
)}
</div>
);
};
type VirtualizedPageListProps = {
scrollParentRef: ScrollTarget;
constraintRef: React.RefObject<HTMLDivElement | null>;
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="text-muted-foreground/80 my-2 text-center text-[11px]">
<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="border-border relative w-full rounded 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 { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
import { useCurrentTeam } from '~/providers/team';
export type TemplateEditFormProps = {
@@ -312,11 +313,17 @@ export const TemplateEditForm = ({
gradient
>
<CardContent className="p-2">
<PDFViewerLazy
key={template.envelopeItems[0].id}
envelopeItem={template.envelopeItems[0]}
token={undefined}
version="signed"
<PDFViewer
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>

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