Compare commits

..
Author SHA1 Message Date
David Nguyen 724f5f62c5 Merge branch 'main' into feat/add-content-fields 2026-09-29 17:03:35 +10:00
David Nguyen 58b5e6b5d3 fix: embed z-index 2026-09-29 16:53:28 +10:00
David Nguyen 573c928a0e feat: add recipient grouping (#3319) 2026-09-29 16:52:43 +10:00
David Nguyen d87ac2b552 feat: add embed content support 2026-09-29 16:21:48 +10:00
Lucas Smith be94bddd40 fix: use legacy pdfjs build for older devices (#3410) 2026-09-29 16:13:39 +10:00
Lucas Smith 5a123be46c fix: embed signing completion and reload states (#3409)
Send completed/rejected events when reopening an actioned v1 embed,
show the completed page after signing in v2, and tidy the completed
page.
2026-09-29 15:44:25 +10:00
Lucas Smith 586b1f5cb5 v2.19.0 2026-09-29 14:37:03 +10:00
David Nguyen b90aa86e24 fix: test 2026-09-27 22:04:46 +10:00
David Nguyen 3c84d25d5a fix: various improvements 2026-09-27 20:08:47 +10:00
David Nguyen c96ee7c84f fix: correctly deselect settings when changing envelope items 2026-09-26 16:47:27 +10:00
David Nguyen 941f4a1e4c fix: auto show and hide editor action bar 2026-09-26 16:33:03 +10:00
David Nguyen 30a0979573 fix: stuff 2026-09-26 12:23:47 +10:00
Lucas Smith a1d4bec143 fix: accept owner-password protected pdfs (#3396)
Strip encryption from PDFs that open with an empty user password via
libpdf's ignorePermissions, still rejecting user-password PDFs.

Upgrade @libpdf/core to 0.5.1, which also keeps overlapping and layered
text intact during text extraction.

Resolves #3303
2026-09-26 11:23:33 +10:00
David Nguyen 94793c7cce feat: add content fields 2026-09-25 12:02:52 +10:00
Lucas Smith 39ae85483a fix: add date-fns to packages/ui (#3388) 2026-09-24 10:29:26 +10:00
Ephraim Duncan 638e92d534 feat: add team document analytics dashboard (#3355) 2026-09-24 09:50:35 +10:00
Catalin Pit c81bc72c4c fix: bulk download dialog overflow on long titles (#3380) 2026-09-21 19:23:11 +10:00
Zonaib Bokhari 1164d9578e Respect hidden sender details in document invite emails (#3061) 2026-09-21 09:20:39 +03:00
David Nguyen e658cc5818 feat: add inbox filters (#3372) 2026-09-17 17:21:34 +10:00
Ephraim Duncan 0c6249744a feat(ui): redesign recipient avatar stack hover popover (#3072) 2026-09-17 13:36:15 +10:00
Ephraim Duncan cf326f825d feat: replace template view tabs with a filter pill (#3148) 2026-09-17 10:24:19 +10:00
Ephraim Duncan da934c01a6 feat: allow signing reason override (#2848) 2026-09-16 14:41:55 +10:00
Ephraim Duncan 0693a4195b refactor(openpage): simplify the cors policy (#3338) 2026-09-16 14:10:55 +10:00
Ephraim Duncan 2618d4d4be chore(embedding): remove unregistered multi-sign mutation (#3339) 2026-09-16 14:07:04 +10:00
Ephraim Duncan c5a6ff42ee chore(ui): remove unused application skeletons (#3340) 2026-09-16 14:06:03 +10:00
Ephraim Duncan 132c4b08c5 fix(trpc): v2 field position updates silently dropped; rewrite fields API docs (#3136) 2026-09-16 13:59:40 +10:00
Ephraim Duncan 9542512ce9 refactor(lib): reuse the http url validator (#3346) 2026-09-16 13:54:52 +10:00
Ephraim Duncan 0f71a8b67d chore(prisma): remove unregistered middleware (#3345) 2026-09-16 13:53:19 +10:00
Ephraim Duncan b795883fc7 chore(lib): remove unused timezone labels (#3342) 2026-09-16 13:48:03 +10:00
Ephraim Duncan 643954ddd1 refactor(server): simplify request context middleware (#3341) 2026-09-16 13:46:01 +10:00
Catalin Pit a20e8a2376 feat: add document naming options when using templates (#3086) 2026-09-16 01:00:40 +10:00
638 changed files with 39498 additions and 12333 deletions
+2
View File
@@ -80,6 +80,8 @@ NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET=
NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL=
# OPTIONAL: Comma-separated list of timestamp authority URLs for PDF signing (enables LTV and archival timestamps).
NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY=
# OPTIONAL: Reason to embed in PDF signatures. Defaults to "Signed by Documenso".
NEXT_PRIVATE_SIGNING_REASON=
# OPTIONAL: Contact info to embed in PDF signatures. Defaults to the webapp URL.
NEXT_PUBLIC_SIGNING_CONTACT_INFO=
# OPTIONAL: Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached.
-20
View File
@@ -30,26 +30,6 @@ jobs:
- name: Build app
run: npm run build
unit_tests:
name: Unit Tests
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: ./.github/actions/node-install
- name: Copy env
run: cp .env.example .env
# Includes the 2FA enforcement drift guard (packages/trpc), which is the
# only check that catches a session route without enforcement middleware.
- name: Run unit tests
run: npm run test -w @documenso/lib -w @documenso/trpc
build_docker:
name: Build Docker Image
runs-on: ubuntu-latest
@@ -15,6 +15,8 @@ This guide provides a comprehensive troubleshooting matrix for the standard erro
| `INVALID_REQUEST` | The overall request is malformed or invalid. | Review your API call parameters, including the URL, query parameters, and headers. Correct the request syntax. |
| `RECIPIENT_EXPIRED` | The signing link or recipient access has expired. | Generate and resend a new invitation to the affected recipient. |
| `LIMIT_EXCEEDED` | Your account usage quota has been exceeded. | Check your current plan limits. Upgrade your subscription or wait until your billing cycle renews. |
| `MISSING_ENV_VAR` | A required environment variable is not configured on the server (500). | Primarily affects self-hosted instances: set the environment variable named in the error message and restart. On Documenso Cloud, contact support. |
| `MISSING_SIGNATURE_FIELD` | A signer has no signature field placed on the document (400). Returned when distributing an envelope. | Add at least one signature field for every recipient with a signing role before calling `/envelope/distribute`. |
| `NOT_FOUND` | The requested resource could not be found (404). | Verify the resource ID (envelope, document, webhook) passed in the URL. Ensure the resource has not been deleted. |
| `NOT_IMPLEMENTED` | The requested feature is not currently supported by the server. | Consult the API documentation to verify available methods. Do not use this endpoint at this time. |
| `NOT_SETUP` | The required configuration for this action is incomplete. | Access your account or integration settings and complete the necessary configuration before retrying. |
@@ -37,7 +39,28 @@ The following errors occur when attempting to perform actions on an envelope tha
| `ENVELOPE_DRAFT` | The action cannot be performed because the envelope is still in a draft state. | Finalize the envelope configuration and transition it to the `PENDING` (sent) state before attempting this operation. |
| `ENVELOPE_COMPLETED` | The action cannot be performed because the envelope is already completed. | No further modifications (e.g., adding signers, modifying documents) can be made to an envelope once the signing process is finished. |
| `ENVELOPE_REJECTED` | The action cannot be performed because the envelope was rejected by a recipient. | The signing flow is permanently halted. Create a new envelope if you wish to resubmit the document. |
| `ENVELOPE_CANCELLED` | The action cannot be performed because the envelope was cancelled (400). | Create a new envelope if you need to restart the signing process. |
| `ENVELOPE_LEGACY` | The action cannot be performed because the envelope uses an obsolete format. | This envelope was created with a legacy version of the system. Recreate the envelope using the current API version to interact with it. |
| `ENVELOPE_TSP_LOCKED` | An AES/QES envelope cannot be modified after it leaves the draft state (400). | Make changes while the envelope is in `DRAFT`, or create a new envelope. |
## CSC Signing Errors
These errors apply to Cloud Signature Consortium (CSC) signing flows.
| Error Code | Description | Recommended Action |
| :--- | :--- | :--- |
| `CSC_INSTANCE_MODE_MISMATCH` | The requested signature level does not match the instance's CSC mode (400). | Use the signature level supported by the instance's signing configuration. |
| `CSC_UNLICENSED` | CSC signing is not licensed for this instance (403). | Enable the CSC signing license before retrying. |
| `CSC_PROVIDER_INFO_FAILED` | The CSC provider's discovery request failed or returned unusable information (500). | Check the provider URL, availability, and OAuth configuration. |
| `CSC_PROVIDER_NO_TSA` | A timestamp authority is unavailable or unusable for CSC signing (500). | Configure `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` and verify provider timestamp access. |
| `CSC_CREDENTIAL_LIST_EMPTY` | The CSC provider returned no signing credentials for the authenticated user (400). | Enrol a signing credential with the provider, then authenticate again. |
| `CSC_CERT_INVALID` | The selected signing certificate is missing, invalid, or outside its validity period (400). | Select or renew a valid certificate, then authenticate again. |
| `CSC_ALGORITHM_REFUSED` | The signing credential uses an unsupported key or digest algorithm (400). | Select a credential that satisfies the instance's CSC algorithm policy. |
| `CSC_SAD_EXPIRED_PRE_SIGN` | The signature activation data is missing, expired, or unreadable before signing (400). | Repeat the credential authorization flow. |
| `CSC_TSP_TIMEOUT` | The trust service provider did not complete the signing request before the timeout (408). | Retry the signing request after checking provider availability. |
| `CSC_EMBED_FAILED` | The returned CSC signature could not be embedded into the envelope items (400). | Restart the signing attempt. If it fails again, contact support. |
| `CSC_BASE_DOCUMENT_MUTATED` | The document changed between signature preparation and signing (500). | Restart signing from the current envelope state. |
| `CSC_REQUEST_FAILED` | A CSC provider request failed without a more specific CSC error (500). | Check provider availability and configuration, then retry. |
## See Also
+193 -88
View File
@@ -6,6 +6,8 @@ description: Add signature and form fields to documents via API.
import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<EnvelopeWarning />
<Callout type="warn">
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
see the [OpenAPI Reference](https://openapi.documenso.com).
@@ -19,13 +21,13 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
| `secondaryId` | string | Secondary identifier for audit logs |
| `type` | string | Field type (see [Field Types](#field-types)) |
| `recipientId` | number | ID of the recipient assigned to this field |
| `envelopeId` | number | ID of the parent envelope |
| `envelopeId` | string | ID of the parent envelope |
| `envelopeItemId` | string | ID of the PDF item the field is placed on |
| `page` | number | Page number (1-indexed) |
| `positionX` | number | X coordinate as percentage (0-100) |
| `positionY` | number | Y coordinate as percentage (0-100) |
| `width` | number | Width as percentage of page (0-100) |
| `height` | number | Height as percentage of page (0-100) |
| `positionX` | string | X coordinate as percentage (0-100), a decimal serialized as a string |
| `positionY` | string | Y coordinate as percentage (0-100), a decimal serialized as a string |
| `width` | string | Width as percentage of page (0-100), a decimal serialized as a string |
| `height` | string | Height as percentage of page (0-100), a decimal serialized as a string |
| `customText` | string | Value entered by the recipient |
| `inserted` | boolean | Whether the field has been completed |
| `fieldMeta` | object \| null | Type-specific configuration options |
@@ -38,18 +40,19 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
"secondaryId": "field_abc123",
"type": "SIGNATURE",
"recipientId": 123,
"envelopeId": 789,
"envelopeItemId": "envelope_item_xyz",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"page": 1,
"positionX": 10,
"positionY": 80,
"width": 30,
"height": 5,
"positionX": "10",
"positionY": "80",
"width": "30",
"height": "5",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "signature",
"required": true
"required": true,
"overflow": "auto"
}
}
```
@@ -61,7 +64,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
| Type | Description | Auto-filled |
| ---------------- | ----------------------------------------- | ----------- |
| `SIGNATURE` | Drawn, typed, or uploaded signature | No |
| `FREE_SIGNATURE` | Unrestricted signature without validation | No |
| `FREE_SIGNATURE` | Legacy free-form signature. Accepted by the v2 create schema but rejected by the v1 API and unsupported in the signing UI — avoid in new integrations | No |
| `INITIALS` | Recipient's initials | No |
| `NAME` | Recipient's full name | Yes |
| `EMAIL` | Recipient's email address | Yes |
@@ -134,10 +137,12 @@ POST /envelope/field/create-many
### Request Body
| Field | Type | Required | Description |
| ----------- | ------ | -------- | ------------------------------- |
| `documentId`| number | Yes | The document ID |
| `fields` | array | Yes | Array of field configurations |
| Field | Type | Required | Description |
| ------------ | ------ | -------- | ------------------------------- |
| `envelopeId` | string | Yes | The envelope ID |
| `data` | array | Yes | Array of field configurations |
Each entry in `data` requires a `type`, a `recipientId`, and a position — either explicit coordinates (`page`, `positionX`, `positionY`, `width`, `height`) or a [text placeholder](#placeholder-based-field-positioning) (`placeholder` with optional `width`, `height`, and `matchAll`). Optional per-entry properties: `envelopeItemId` (which PDF in the envelope to place the field on; defaults to the first item) and `fieldMeta`.
### Code Examples
@@ -148,32 +153,32 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/field/create-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"documentId": 123,
"fields": [
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
{
"type": "SIGNATURE",
"recipientId": 456,
"pageNumber": 1,
"pageX": 10,
"pageY": 80,
"page": 1,
"positionX": 10,
"positionY": 80,
"width": 30,
"height": 5
},
{
"type": "DATE",
"recipientId": 456,
"pageNumber": 1,
"pageX": 50,
"pageY": 80,
"page": 1,
"positionX": 50,
"positionY": 80,
"width": 20,
"height": 3
},
{
"type": "TEXT",
"recipientId": 456,
"pageNumber": 1,
"pageX": 10,
"pageY": 70,
"page": 1,
"positionX": 10,
"positionY": 70,
"width": 40,
"height": 4,
"fieldMeta": {
@@ -199,32 +204,32 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
documentId: 123,
fields: [
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{
type: 'SIGNATURE',
recipientId: 456,
pageNumber: 1,
pageX: 10,
pageY: 80,
page: 1,
positionX: 10,
positionY: 80,
width: 30,
height: 5,
},
{
type: 'DATE',
recipientId: 456,
pageNumber: 1,
pageX: 50,
pageY: 80,
page: 1,
positionX: 50,
positionY: 80,
width: 20,
height: 3,
},
{
type: 'TEXT',
recipientId: 456,
pageNumber: 1,
pageX: 10,
pageY: 70,
page: 1,
positionX: 10,
positionY: 70,
width: 40,
height: 4,
fieldMeta: {
@@ -239,8 +244,8 @@ const response = await fetch(
}
);
const { fields } = await response.json();
console.log(`Created ${fields.length} fields`);
const { data } = await response.json();
console.log(`Created ${data.length} fields`);
````
</Tab>
@@ -250,36 +255,68 @@ console.log(`Created ${fields.length} fields`);
```json
{
"fields": [
"data": [
{
"id": 101,
"secondaryId": "field_abc123",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "SIGNATURE",
"recipientId": 456,
"page": 1,
"positionX": 10,
"positionY": 80,
"width": 30,
"height": 5
"positionX": "10",
"positionY": "80",
"width": "30",
"height": "5",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "signature",
"fontSize": 18,
"overflow": "auto"
}
},
{
"id": 102,
"secondaryId": "field_def456",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "DATE",
"recipientId": 456,
"page": 1,
"positionX": 50,
"positionY": 80,
"width": 20,
"height": 3
"positionX": "50",
"positionY": "80",
"width": "20",
"height": "3",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "date",
"fontSize": 12,
"textAlign": "left",
"overflow": "auto"
}
},
{
"id": 103,
"secondaryId": "field_ghi789",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "TEXT",
"recipientId": 456,
"page": 1,
"positionX": 10,
"positionY": 70,
"width": 40,
"height": 4
"positionX": "10",
"positionY": "70",
"width": "40",
"height": "4",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "text",
"label": "Job Title",
"placeholder": "Enter your job title",
"required": true
}
}
]
}
@@ -299,8 +336,10 @@ POST /envelope/field/update-many
| Field | Type | Required | Description |
| ------------ | ------ | -------- | ----------------------------- |
| `documentId` | number | Yes | The document ID |
| `fields` | array | Yes | Array of field update objects |
| `envelopeId` | string | Yes | The envelope ID |
| `data` | array | Yes | Array of field update objects |
Each entry in `data` requires the field `id` and `type`. Position properties (`page`, `positionX`, `positionY`, `width`, `height`), `envelopeItemId`, and `fieldMeta` are optional — only supplied values are updated. Placeholder positioning is not supported when updating; use coordinates.
### Code Examples
@@ -311,17 +350,17 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/field/update-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"documentId": 123,
"fields": [
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
{
"id": 101,
"type": "SIGNATURE",
"pageY": 85
"positionY": 85
},
{
"id": 102,
"type": "DATE",
"pageY": 85
"positionY": 85
}
]
}'
@@ -338,16 +377,16 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
documentId: 123,
fields: [
{ id: 101, type: 'SIGNATURE', pageY: 85 },
{ id: 102, type: 'DATE', pageY: 85 },
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{ id: 101, type: 'SIGNATURE', positionY: 85 },
{ id: 102, type: 'DATE', positionY: 85 },
],
}),
}
);
const { fields } = await response.json();
const { data } = await response.json();
````
</Tab>
@@ -357,9 +396,48 @@ const { fields } = await response.json();
```json
{
"fields": [
{ "id": 101, "type": "SIGNATURE", "positionY": 85 },
{ "id": 102, "type": "DATE", "positionY": 85 }
"data": [
{
"id": 101,
"secondaryId": "field_abc123",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "SIGNATURE",
"recipientId": 456,
"page": 1,
"positionX": "10",
"positionY": "85",
"width": "30",
"height": "5",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "signature",
"fontSize": 18,
"overflow": "auto"
}
},
{
"id": 102,
"secondaryId": "field_def456",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "DATE",
"recipientId": 456,
"page": 1,
"positionX": "50",
"positionY": "85",
"width": "20",
"height": "3",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "date",
"fontSize": 12,
"textAlign": "left",
"overflow": "auto"
}
}
]
}
````
@@ -443,8 +521,8 @@ Fields use percentage-based coordinates relative to the PDF page dimensions.
(0,0) ─────────────────────────── (100,0)
│ │
│ ┌─────────┐ │
│ │ Field │ (pageX: 10, │
│ │ │ pageY: 20, │
│ │ Field │ (positionX: 10, │
│ │ │ positionY: 20, │
│ └─────────┘ width: 30, │
│ height: 5) │
│ │
@@ -457,9 +535,9 @@ Fields use percentage-based coordinates relative to the PDF page dimensions.
const field = {
type: 'SIGNATURE',
recipientId: 123,
pageNumber: 1,
pageX: 60, // 60% from left
pageY: 85, // 85% from top (near bottom)
page: 1,
positionX: 60, // 60% from left
positionY: 85, // 85% from top (near bottom)
width: 30, // 30% of page width
height: 8, // 8% of page height
};
@@ -479,6 +557,33 @@ This approach is useful when generating PDFs programmatically or using templates
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.
### Placeholder Positioning via the API
`POST /envelope/field/create-many` accepts a placeholder position in place of coordinates. Instead of `page`, `positionX`, `positionY`, `width`, and `height`, pass:
| Field | Type | Required | Description |
| ------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------ |
| `placeholder` | string | Yes | Text to search for in the PDF (e.g. `{{name}}`). The field is placed at the bounding box of the first match. |
| `width` | number | No | Override the field width. Defaults to the width of the matched text. |
| `height` | number | No | Override the field height. Defaults to the height of the matched text. |
| `matchAll` | boolean | No | Create a field at every occurrence of the placeholder instead of only the first. |
```json
{
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
{
"type": "SIGNATURE",
"recipientId": 456,
"placeholder": "{{signature}}",
"matchAll": true
}
]
}
```
`POST /envelope/field/update-many` does not accept placeholders — field updates are coordinate-only.
---
## Field Meta Options
@@ -643,15 +748,15 @@ All field types support these base options:
Create a document with a signature block containing multiple field types:
```typescript
async function addSignatureBlock(documentId: number, recipientId: number) {
const fields = [
async function addSignatureBlock(envelopeId: string, recipientId: number) {
const data = [
// Signature
{
type: 'SIGNATURE',
recipientId,
pageNumber: 1,
pageX: 10,
pageY: 80,
page: 1,
positionX: 10,
positionY: 80,
width: 30,
height: 8,
fieldMeta: {
@@ -663,9 +768,9 @@ async function addSignatureBlock(documentId: number, recipientId: number) {
{
type: 'NAME',
recipientId,
pageNumber: 1,
pageX: 10,
pageY: 90,
page: 1,
positionX: 10,
positionY: 90,
width: 30,
height: 4,
fieldMeta: {
@@ -677,9 +782,9 @@ async function addSignatureBlock(documentId: number, recipientId: number) {
{
type: 'DATE',
recipientId,
pageNumber: 1,
pageX: 50,
pageY: 80,
page: 1,
positionX: 50,
positionY: 80,
width: 20,
height: 4,
fieldMeta: {
@@ -691,9 +796,9 @@ async function addSignatureBlock(documentId: number, recipientId: number) {
{
type: 'TEXT',
recipientId,
pageNumber: 1,
pageX: 50,
pageY: 90,
page: 1,
positionX: 50,
positionY: 90,
width: 30,
height: 4,
fieldMeta: {
@@ -710,7 +815,7 @@ async function addSignatureBlock(documentId: number, recipientId: number) {
Authorization: 'api_xxxxxxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({ documentId, fields }),
body: JSON.stringify({ envelopeId, data }),
});
return response.json();
@@ -60,8 +60,8 @@ Authorization: api_xxxxxxxxxxxxxxxx
href="/docs/developers/api/templates"
/>
<Card
title="Teams"
description="Manage teams and team members."
title="Team-scoped access"
description="Use team-scoped API tokens with envelope endpoints."
href="/docs/developers/api/teams"
/>
</Cards>
@@ -6,6 +6,8 @@ description: Add and manage envelope recipients via API.
import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<EnvelopeWarning />
<Callout type="warn">
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
see the [OpenAPI Reference](https://openapi.documenso.com).
@@ -16,7 +18,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
```json
{
"id": 123,
"envelopeId": "clu1abc2def3ghi4jkl",
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -134,7 +136,7 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/recipient/create-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"envelopeId": "clu1abc2def3ghi4jkl",
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
{
"email": "signer@example.com",
@@ -164,7 +166,7 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'clu1abc2def3ghi4jkl',
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{
email: 'signer@example.com',
@@ -196,7 +198,7 @@ const { data: recipients } = await response.json();
"data": [
{
"id": 789,
"envelopeId": "clu1abc2def3ghi4jkl",
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -209,7 +211,7 @@ const { data: recipients } = await response.json();
},
{
"id": 790,
"envelopeId": "clu1abc2def3ghi4jkl",
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "approver@example.com",
"name": "Jane Smith",
"role": "APPROVER",
@@ -262,7 +264,7 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/recipient/update-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"envelopeId": "clu1abc2def3ghi4jkl",
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
{
"id": 789,
@@ -284,7 +286,7 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'clu1abc2def3ghi4jkl',
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{
id: 789,
@@ -387,7 +389,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/recipien
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'clu1abc2def3ghi4jkl',
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{
email: 'approver@example.com',
@@ -462,7 +464,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/recipien
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'clu1abc2def3ghi4jkl',
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{
email: 'signer@example.com',
+29 -42
View File
@@ -1,44 +1,29 @@
---
title: Teams API
description: Manage team resources, documents, and templates with team-scoped API tokens.
title: Team-Scoped API Access
description: Use team-scoped API tokens with document and template envelopes.
---
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<EnvelopeWarning />
<Callout type="warn">
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
see the [OpenAPI Reference](https://openapi.documenso.com).
</Callout>
## Team Object
## Team Context
A team object contains the following properties:
<Callout type="info">
The V2 REST API does not expose `/team/*` endpoints. Create and manage teams, members, and team
settings in the Documenso web application. This page explains how a team-scoped token applies
that team context to supported API resources.
</Callout>
| Property | Type | Description |
| ----------------- | -------------- | --------------------------------------------------- |
| `id` | number | Unique team identifier |
| `name` | string | Team display name |
| `url` | string | Unique team URL slug |
| `createdAt` | string | ISO 8601 timestamp |
| `avatarImageId` | string \| null | ID of the team's avatar image |
| `organisationId` | string | ID of the parent organisation |
| `currentTeamRole` | string | Your role in the team: `ADMIN`, `MANAGER`, `MEMBER` |
### Example Team Object
```json
{
"id": 123,
"name": "Engineering",
"url": "engineering",
"createdAt": "2025-01-15T10:30:00.000Z",
"avatarImageId": null,
"organisationId": "org_abc123",
"currentTeamRole": "ADMIN"
}
```
The API resolves the team from your token. You do not pass a team ID when creating, listing, or
using envelopes. The token's team ID determines which resources the request can access.
## Team-Scoped API Tokens
@@ -156,26 +141,26 @@ Retrieve all documents belonging to the team:
<Tab value="curl">
```bash
# List all team documents
curl -X GET "https://app.documenso.com/api/v2/envelope" \
curl -X GET "https://app.documenso.com/api/v2/envelope?type=DOCUMENT" \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
# Filter by status
curl -X GET "https://app.documenso.com/api/v2/envelope?status=PENDING" \
curl -X GET "https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=PENDING" \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
````
</Tab>
<Tab value="TypeScript">
```typescript
const response = await fetch('https://app.documenso.com/api/v2/envelope', {
const response = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT', {
method: 'GET',
headers: {
Authorization: TEAM_API_TOKEN,
},
});
const { data, pagination } = await response.json();
console.log(`Found ${pagination.totalItems} team documents`);
const { data, count } = await response.json();
console.log(`Found ${count} team documents`);
````
</Tab>
@@ -190,10 +175,11 @@ Templates created with a team token are shared across the team.
<Tabs items={['curl', 'TypeScript']}>
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/template/create" \
curl -X POST "https://app.documenso.com/api/v2/envelope/create" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: multipart/form-data" \
-F 'payload={
"type": "TEMPLATE",
"title": "NDA Template",
"recipients": [
{
@@ -223,6 +209,7 @@ curl -X POST "https://app.documenso.com/api/v2/template/create" \
const form = new FormData();
const payload = {
type: 'TEMPLATE',
title: 'NDA Template',
recipients: [
{
@@ -249,7 +236,7 @@ form.append('files', fs.createReadStream('./nda-template.pdf'), {
contentType: 'application/pdf',
});
const response = await fetch('https://app.documenso.com/api/v2/template/create', {
const response = await fetch('https://app.documenso.com/api/v2/envelope/create', {
method: 'POST',
headers: {
Authorization: TEAM_API_TOKEN,
@@ -257,8 +244,8 @@ const response = await fetch('https://app.documenso.com/api/v2/template/create',
body: form,
});
const template = await response.json();
console.log('Created team template:', template.id);
const { id } = await response.json();
console.log('Created team template envelope:', id);
````
</Tab>
</Tabs>
@@ -268,14 +255,14 @@ console.log('Created team template:', template.id);
<Tabs items={['curl', 'TypeScript']}>
<Tab value="curl">
```bash
curl -X GET "https://app.documenso.com/api/v2/template" \
curl -X GET "https://app.documenso.com/api/v2/envelope?type=TEMPLATE" \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
````
</Tab>
<Tab value="TypeScript">
```typescript
const response = await fetch('https://app.documenso.com/api/v2/template', {
const response = await fetch('https://app.documenso.com/api/v2/envelope?type=TEMPLATE', {
method: 'GET',
headers: {
Authorization: TEAM_API_TOKEN,
@@ -330,19 +317,19 @@ const SALES_TEAM_TOKEN = process.env.SALES_TEAM_API_TOKEN;
const LEGAL_TEAM_TOKEN = process.env.LEGAL_TEAM_API_TOKEN;
// Get pending documents from sales team
const salesResponse = await fetch('https://app.documenso.com/api/v2/envelope?status=PENDING', {
const salesResponse = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=PENDING', {
headers: { Authorization: SALES_TEAM_TOKEN },
});
const salesDocs = await salesResponse.json();
// Get completed documents from legal team
const legalResponse = await fetch('https://app.documenso.com/api/v2/envelope?status=COMPLETED', {
const legalResponse = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=COMPLETED', {
headers: { Authorization: LEGAL_TEAM_TOKEN },
});
const legalDocs = await legalResponse.json();
console.log(`Sales team: ${salesDocs.pagination.totalItems} pending`);
console.log(`Legal team: ${legalDocs.pagination.totalItems} completed`);
console.log(`Sales team: ${salesDocs.count} pending`);
console.log(`Legal team: ${legalDocs.count} completed`);
```
## Error Responses
@@ -13,7 +13,194 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
see the [OpenAPI Reference](https://openapi.documenso.com).
</Callout>
## Template Object
## Use a Template Envelope
New integrations should create a document from a template envelope with the Envelope API.
```
POST /envelope/use
Content-Type: multipart/form-data
```
The request uses `multipart/form-data`:
| Part | Type | Required | Description |
| --------- | ------- | -------- | ------------------------------------------------------------------ |
| `payload` | JSON | Yes | Template envelope ID, recipient details, and document settings |
| `files` | File(s) | No | Replacement PDFs referenced by entries in `customDocumentData` |
### Payload Schema
| Field | Type | Required | Description |
| -------------------- | ------- | -------- | ------------------------------------------------------------------------ |
| `envelopeId` | string | Yes | ID of the template envelope |
| `externalId` | string | No | Your identifier for the created document envelope |
| `recipients` | array | No | Recipient details mapped to recipients in the template |
| `distributeDocument` | boolean | No | If `true`, create the document as pending and distribute it |
| `customDocumentData` | array | No | Maps uploaded replacement PDFs to template envelope items |
| `folderId` | string | No | Folder in which to create the document |
| `prefillFields` | array | No | Field values to prefill before distribution |
| `override` | object | No | Template values to override for the created document |
| `attachments` | array | No | Link attachments to add to the document |
| `formValues` | object | No | PDF form values to apply |
Each recipient entry accepts the following fields:
| Field | Type | Required | Description |
| -------------- | ------- | -------- | -------------------------------------------- |
| `id` | number | Yes | Recipient ID from the template envelope |
| `email` | string | Yes | Recipient email address |
| `name` | string | No | Recipient display name |
| `signingOrder` | number | No | Recipient position in sequential signing |
Each `customDocumentData` entry maps an uploaded file to a template item:
| Field | Type | Required | Description |
| ---------------- | ---------------- | -------- | --------------------------------------------------------------- |
| `identifier` | string \| number | Yes | Uploaded filename or zero-based file index |
| `envelopeItemId` | string | Yes | Template envelope item whose PDF the uploaded file replaces |
### Code Examples
<Tabs items={['curl', 'TypeScript']}>
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/envelope/use" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-F 'payload={
"envelopeId": "envelope_template123",
"externalId": "contract-2025-001",
"recipients": [
{
"id": 1,
"email": "john.doe@example.com",
"name": "John Doe"
}
],
"prefillFields": [
{
"id": 101,
"type": "text",
"value": "Senior Software Engineer"
}
],
"distributeDocument": false
}'
```
</Tab>
<Tab value="TypeScript">
```typescript
const form = new FormData();
form.append(
'payload',
JSON.stringify({
envelopeId: 'envelope_template123',
externalId: 'contract-2025-001',
recipients: [
{
id: 1,
email: 'john.doe@example.com',
name: 'John Doe',
},
],
prefillFields: [
{
id: 101,
type: 'text',
value: 'Senior Software Engineer',
},
],
distributeDocument: false,
}),
);
const response = await fetch('https://app.documenso.com/api/v2/envelope/use', {
method: 'POST',
headers: {
Authorization: 'api_xxxxxxxxxxxxxxxx',
},
body: form,
});
const document = await response.json();
console.log('Created document envelope:', document.id);
```
</Tab>
</Tabs>
### Response
```json
{
"id": "envelope_document123",
"recipients": [
{
"id": 1,
"name": "John Doe",
"email": "john.doe@example.com",
"token": "recipient_token",
"role": "SIGNER",
"signingOrder": 1,
"signingUrl": "https://app.documenso.com/sign/recipient_token"
}
]
}
```
### Distribute the Created Envelope
If you leave `distributeDocument` unset or set it to `false`, distribute the created document with
`POST /envelope/distribute`. Its response confirms delivery and includes each recipient's signing URL.
```typescript
const distributionResponse = await fetch(
'https://app.documenso.com/api/v2/envelope/distribute',
{
method: 'POST',
headers: {
Authorization: 'api_xxxxxxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: document.id,
}),
},
);
const distribution = await distributionResponse.json();
console.log('Signing URL:', distribution.recipients[0].signingUrl);
```
```json
{
"success": true,
"id": "envelope_document123",
"recipients": [
{
"id": 1,
"name": "John Doe",
"email": "john.doe@example.com",
"token": "recipient_token",
"role": "SIGNER",
"signingOrder": 1,
"signingUrl": "https://app.documenso.com/sign/recipient_token"
}
]
}
```
---
## Deprecated Template Endpoint Reference
<Callout type="warn">
Every `/template/*` endpoint below is deprecated. Use the Envelope API for new integrations and
follow [Migrating to Envelopes](/docs/developers/api/migrate-to-envelopes) to replace existing calls.
The legacy reference remains here to support migrations.
</Callout>
## Legacy Template Object
A template object contains the following properties:
@@ -91,7 +278,7 @@ A template object contains the following properties:
}
```
## List Templates
## List Templates (Deprecated)
Retrieve a paginated list of templates.
@@ -139,8 +326,8 @@ const response = await fetch(`${BASE_URL}/template`, {
},
});
const { data, pagination } = await response.json();
console.log(`Found ${pagination.totalItems} templates`);
const { data, count } = await response.json();
console.log(`Found ${count} templates`);
// Filter by type
const privateResponse = await fetch(
@@ -181,18 +368,16 @@ const privateTemplates = await privateResponse.json();
]
}
],
"pagination": {
"page": 1,
"perPage": 10,
"totalPages": 3,
"totalItems": 25
}
"count": 25,
"currentPage": 1,
"perPage": 10,
"totalPages": 3
}
```
---
## Get Template
## Get Template (Deprecated)
Retrieve a single template by ID.
@@ -238,9 +423,9 @@ Returns the full template object including recipients, fields, and metadata.
---
## Create Document from Template
## Create Document from Template (Deprecated)
Create a new document using a template. This is the primary way to use templates programmatically.
Create a new document using the deprecated template endpoint.
<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.
@@ -415,32 +600,57 @@ const prefilledDocument = await prefillResponse.json();
### Response
Returns the created document object with recipients and signing URLs.
The endpoint returns the full legacy document object. The selected fields below show both the numeric
legacy `id` and canonical `envelopeId`. Recipient entries do not include a `signingUrl`.
```json
{
"id": "envelope_xyz789",
"type": "DOCUMENT",
"id": 789,
"envelopeId": "envelope_xyz789",
"status": "PENDING",
"title": "Employment Contract",
"source": "TEMPLATE",
"title": "Employment Contract",
"externalId": "contract-2025-001",
"recipients": [
{
"id": 1,
"envelopeId": "envelope_xyz789",
"documentId": 789,
"templateId": null,
"email": "john.doe@example.com",
"name": "John Doe",
"role": "SIGNER",
"signingStatus": "NOT_SIGNED",
"signingUrl": "https://app.documenso.com/sign/abc123"
"signingOrder": 1
}
]
}
````
```
To send a document created with `distributeDocument: false` and receive signing links, call
`POST /envelope/distribute` with its `envelopeId`:
```typescript
const document = await response.json();
const distributionResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
method: 'POST',
headers: {
Authorization: API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: document.envelopeId,
}),
});
const distribution = await distributionResponse.json();
console.log('Signing URL:', distribution.recipients[0].signingUrl);
```
---
## Override Template Settings
## Override Template Settings (Deprecated)
When creating a document from a template, you can override various settings:
@@ -488,7 +698,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
---
## Prefill Fields
## Prefill Fields (Deprecated)
Prefill field values when creating a document from a template. This is useful for populating known data before sending.
@@ -577,7 +787,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
---
## Update Template
## Update Template (Deprecated)
Update a template's properties.
@@ -643,7 +853,7 @@ const template = await response.json();
---
## Duplicate Template
## Duplicate Template (Deprecated)
Create a copy of an existing template.
@@ -695,7 +905,7 @@ console.log('New template ID:', duplicatedTemplate.id);
---
## Delete Template
## Delete Template (Deprecated)
Delete a template.
@@ -754,7 +964,7 @@ const { success } = await response.json();
---
## Direct Link Templates
## Direct Link Templates (Deprecated)
Direct link templates allow recipients to create and sign documents without requiring you to explicitly create each document. When a recipient visits the direct link, a new document is automatically created from the template.
@@ -898,7 +1108,7 @@ const { success } = await response.json();
---
## Custom Document Data
## Custom Document Data (Deprecated)
When creating a document from a template, you can replace the template's PDF with a custom PDF by using the `customDocumentData` parameter. This is useful when you need to generate the PDF dynamically while reusing the template's recipient and field configuration.
@@ -913,7 +1123,7 @@ See the [OpenAPI Reference](https://openapi.documenso.com) for the full request
---
## Template Types
## Template Types (Legacy)
| Type | Description |
| --------- | ------------------------------------------------------------------ |
@@ -922,7 +1132,7 @@ See the [OpenAPI Reference](https://openapi.documenso.com) for the full request
---
## Complete Example: Contract Workflow
## Complete Legacy Example: Contract Workflow (Deprecated)
This example demonstrates a complete workflow for using templates to send contracts.
@@ -996,16 +1206,29 @@ async function sendEmploymentContract(employeeData: {
subject: `Employment Contract for ${employeeData.name}`,
message: `Hi ${employeeData.name},\n\nPlease review and sign your employment contract.`,
},
distributeDocument: true,
distributeDocument: false,
externalId: `emp-contract-${Date.now()}`,
}),
});
const document = await documentResponse.json();
// 5. Distribute the envelope and get recipient signing links
const distributionResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
method: 'POST',
headers: {
Authorization: API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: document.envelopeId,
}),
});
const distribution = await distributionResponse.json();
return {
documentId: document.id,
signingUrl: document.recipients[0].signingUrl,
envelopeId: document.envelopeId,
signingUrl: distribution.recipients[0].signingUrl,
};
}
@@ -1018,7 +1241,7 @@ const result = await sendEmploymentContract({
startDate: '2025-03-01',
});
console.log('Document created:', result.documentId);
console.log('Document created:', result.envelopeId);
console.log('Signing URL:', result.signingUrl);
````
@@ -120,20 +120,25 @@ Specific parts of the embed can be targeted with CSS classes for granular stylin
### Component Classes
| Class | Description |
| --------------------------------- | --------------------------------------------- |
| `.embed--Root` | Main container for the embedded experience |
| `.embed--DocumentContainer` | Container for the document and signing widget |
| `.embed--DocumentViewer` | Container for the document viewer |
| `.embed--DocumentWidget` | The signing widget container |
| `.embed--DocumentWidgetContainer` | Outer container for the signing widget |
| `.embed--DocumentWidgetHeader` | Header section of the signing widget |
| `.embed--DocumentWidgetContent` | Main content area of the signing widget |
| `.embed--DocumentWidgetForm` | Form section within the signing widget |
| `.embed--DocumentWidgetFooter` | Footer section of the signing widget |
| `.embed--WaitingForTurn` | Waiting screen when it is not the user's turn |
| `.embed--DocumentCompleted` | Completion screen after signing |
| `.field--FieldRootContainer` | Base container for document fields |
| Class | Description |
| ---------------------------------------- | --------------------------------------------- |
| `.embed--Root` | Main container for the embedded experience |
| `.embed--DocumentContainer` | Container for the document and signing widget |
| `.embed--DocumentViewer` | Container for the document viewer |
| `.embed--DocumentWidget` | The signing widget container |
| `.embed--DocumentWidgetContainer` | Outer container for the signing widget |
| `.embed--DocumentWidgetHeader` | Header section of the signing widget |
| `.embed--DocumentWidgetContent` | Main content area of the signing widget |
| `.embed--DocumentWidgetForm` | Form section within the signing widget |
| `.embed--DocumentWidgetFooter` | Footer section of the signing widget |
| `.embed--WaitingForTurn` | Waiting screen when it is not the user's turn |
| `.embed--DocumentCompleted` | Completion screen after signing |
| `.embed--DocumentCompletedCard` | Signature card on the completion screen |
| `.embed--DocumentCompletedTitle` | Title on the completion screen |
| `.embed--DocumentCompletedStatus` | Status line on the completion screen |
| `.embed--DocumentCompletedDescription` | Description text on the completion screen |
| `.embed--DocumentRejected` | Rejection screen after rejecting the document |
| `.field--FieldRootContainer` | Base container for document fields |
### Field Data Attributes
@@ -262,8 +262,11 @@ Use templates for repeatable document workflows. This example creates an employm
Map template fields by label and build a <code>prefillFields</code> array
</Step>
<Step>
Call <code>POST /template/use</code> with recipients, prefill data, and{' '}
<code>distributeDocument: true</code>
Call <code>POST /template/use</code> with recipients and prefill data
</Step>
<Step>
Distribute the returned envelope via <code>POST /envelope/distribute</code> and read its signing
links
</Step>
</Steps>
@@ -297,7 +300,7 @@ type TemplateRecipient = {
async function sendEmploymentContract(
templateId: number,
employee: EmployeeData,
): Promise<{ documentId: string; signingUrl: string }> {
): Promise<{ documentId: number; signingUrl: string }> {
const templateResponse = await fetch(`${BASE_URL}/template/${templateId}`, {
headers: { Authorization: API_TOKEN },
});
@@ -374,7 +377,6 @@ async function sendEmploymentContract(
subject: `Your Employment Contract at ${employee.department}`,
message: `Hi ${employee.name},\n\nPlease review and sign your employment contract for the ${employee.position} position.\n\nStart date: ${employee.startDate}`,
},
distributeDocument: true,
externalId: `emp-${Date.now()}-${employee.email.split('@')[0]}`,
}),
});
@@ -386,9 +388,25 @@ async function sendEmploymentContract(
const document = await createResponse.json();
const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
method: 'POST',
headers: {
Authorization: API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({ envelopeId: document.envelopeId }),
});
if (!distributeResponse.ok) {
const error = await distributeResponse.json();
throw new Error(`Failed to send document: ${error.message}`);
}
const distributeResult = await distributeResponse.json();
return {
documentId: document.id,
signingUrl: document.recipients[0].signingUrl,
signingUrl: distributeResult.recipients[0].signingUrl,
};
}
@@ -453,12 +471,17 @@ RESPONSE=$(curl -s -X POST "${BASE_URL}/template/use" \
\"subject\": \"Your Employment Contract\",
\"message\": \"Please review and sign your employment contract.\"
},
\"distributeDocument\": true,
\"externalId\": \"emp-$(date +%s)-alice\"
}")
echo "Document created:"
echo $RESPONSE | jq '{id, signingUrl: .recipients[0].signingUrl}'
ENVELOPE_ID=$(echo $RESPONSE | jq -r '.envelopeId')
DISTRIBUTE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/distribute" \
-H "Authorization: ${API_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"envelopeId\": \"${ENVELOPE_ID}\"}")
echo "Document created: $(echo $RESPONSE | jq -r '.id')"
echo "Signing URL: $(echo $DISTRIBUTE_RESPONSE | jq -r '.recipients[0].signingUrl')"
````
</Tab>
@@ -476,8 +499,10 @@ Send the same document to multiple recipients in parallel. Useful for policy ack
Fetch the template and get the signer recipient slot ID
</Step>
<Step>
For each recipient, call <code>POST /template/use</code> with{' '}
<code>distributeDocument: true</code>
For each recipient, call <code>POST /template/use</code>
</Step>
<Step>
Distribute each returned envelope via <code>POST /envelope/distribute</code>
</Step>
<Step>
Process in batches with a short delay to respect rate limits (e.g. 1000 requests/minute)
@@ -540,7 +565,6 @@ async function bulkSendFromTemplate(
recipients: [
{ id: signerSlot.id, email: recipient.email, name: recipient.name },
],
distributeDocument: true,
externalId: `bulk-${Date.now()}-${recipient.email}`,
}),
});
@@ -551,10 +575,26 @@ async function bulkSendFromTemplate(
}
const document = await response.json();
const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
method: 'POST',
headers: {
Authorization: API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({ envelopeId: document.envelopeId }),
});
if (!distributeResponse.ok) {
const error = await distributeResponse.json();
throw new Error(error.message || 'Failed to distribute document');
}
const distributeResult = await distributeResponse.json();
return {
email: recipient.email,
envelopeId: document.id,
signingUrl: document.recipients[0].signingUrl,
envelopeId: document.envelopeId,
signingUrl: distributeResult.recipients[0].signingUrl,
};
}),
);
@@ -627,14 +667,24 @@ for RECIPIENT in "${RECIPIENTS[@]}"; do
\"email\": \"${EMAIL}\",
\"name\": \"${NAME}\"
}],
\"distributeDocument\": true,
\"externalId\": \"bulk-$(date +%s)-${EMAIL}\"
}")
if echo $RESPONSE | jq -e '.id' > /dev/null 2>&1; then
echo " Success: $(echo $RESPONSE | jq -r '.id')"
if echo $RESPONSE | jq -e '.envelopeId' > /dev/null 2>&1; then
ENVELOPE_ID=$(echo $RESPONSE | jq -r '.envelopeId')
DISTRIBUTE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/distribute" \
-H "Authorization: ${API_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"envelopeId\": \"${ENVELOPE_ID}\"}")
if echo $DISTRIBUTE_RESPONSE | jq -e '.success' > /dev/null 2>&1; then
echo " Success: ${ENVELOPE_ID}"
echo " Signing URL: $(echo $DISTRIBUTE_RESPONSE | jq -r '.recipients[0].signingUrl')"
else
echo " Failed to distribute: $(echo $DISTRIBUTE_RESPONSE | jq -r '.message')"
fi
else
echo " Failed: $(echo $RESPONSE | jq -r '.message')"
echo " Failed to create: $(echo $RESPONSE | jq -r '.message')"
fi
# Rate limiting delay
@@ -837,13 +887,15 @@ After a document is completed, download the signed PDF with all signatures embed
</Step>
</Steps>
The `version` query parameter accepts `original`, `pending`, or `signed`.
<Tabs items={['TypeScript', 'curl']}>
<Tab value="TypeScript">
```typescript
const API_TOKEN = process.env.DOCUMENSO_API_TOKEN;
const BASE_URL = 'https://app.documenso.com/api/v2';
type DownloadVersion = 'signed' | 'original';
type DownloadVersion = 'original' | 'pending' | 'signed';
async function downloadDocument(
envelopeId: string,
@@ -897,7 +949,7 @@ async function downloadAllCompletedDocuments(outputDir: string): Promise<void> {
{ headers: { Authorization: API_TOKEN } },
);
const { data, pagination } = await response.json();
const { data, count, currentPage, perPage, totalPages } = await response.json();
for (const envelope of data) {
try {
@@ -911,8 +963,8 @@ async function downloadAllCompletedDocuments(outputDir: string): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 500));
}
hasMore = page < pagination.totalPages;
page++;
hasMore = currentPage < totalPages;
page = currentPage + 1;
}
}
@@ -24,20 +24,18 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
{/* prettier-ignore */}
<Steps>
<Step>
### Open settings
### Select a team
- Log in to your Documenso account
- Click your avatar in the top right corner
- Select **Settings** from the dropdown menu
![User dropdown menu](/public-api-images/documenso-user-dropdown-menu.webp)
Log in to Documenso and open the team that the integration should access. API tokens are scoped to a
team.
</Step>
<Step>
### Navigate to the API Tokens tab
### Open API Tokens
Go to **Settings** and open the **API Tokens** tab.
Go to **Team Settings** → **API Tokens**, or open
`/t/{teamUrl}/settings/tokens` after replacing `{teamUrl}` with your team's URL.
![API tokens page](/public-api-images/api-tokens-page-documenso.webp)
@@ -48,7 +46,7 @@ Go to **Settings** and open the **API Tokens** tab.
- Click **Create Token**
- Enter a descriptive name (e.g., `production-backend`, `zapier-integration`)
- Select an expiration period: never expires, 7 days, 1 month, 3 months, 6 months, or 1 year
- Select an expiration period: 7 days, 1 month, 3 months, 6 months, 12 months, or Never
- Click **Create Token**
</Step>
@@ -75,21 +73,21 @@ Include the token in the `Authorization` header of your HTTP requests.
### cURL
```bash
curl https://app.documenso.com/api/v2/document \
curl https://app.documenso.com/api/v2/envelope \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
```
### JavaScript / TypeScript
```typescript
const response = await fetch('https://app.documenso.com/api/v2/document', {
const response = await fetch('https://app.documenso.com/api/v2/envelope', {
method: 'GET',
headers: {
Authorization: 'api_xxxxxxxxxxxxxxxx',
},
});
const documents = await response.json();
const envelopes = await response.json();
```
### Using the TypeScript SDK
@@ -129,7 +127,7 @@ SDKs are available for [TypeScript](https://github.com/documenso/sdk-typescript)
## Token Security
API tokens grant full access to your account. Follow these practices to keep them secure:
API tokens grant full API access to the team they were created for. Follow these practices to keep them secure:
- **Never commit tokens to version control.** Use environment variables instead.
- **Use descriptive names.** Names like `zapier-prod` or `backend-staging` help you identify token usage.
@@ -155,12 +153,11 @@ const client = new Documenso({
## Token Scope
API tokens have full access to your account, including:
API tokens have full API access to the team they were created for, including:
- Creating, reading, updating, and deleting documents
- Managing recipients and fields
- Accessing templates
- Managing team resources (if the token owner has team access)
There is currently no way to create tokens with limited scopes or permissions.
@@ -171,7 +168,7 @@ To revoke a token:
{/* prettier-ignore */}
<Steps>
<Step>
Go to **Settings** > **API Tokens**
Go to **Team Settings** → **API Tokens**
</Step>
<Step>
Find the token you want to revoke
@@ -194,7 +191,7 @@ Revoked tokens stop working immediately. Any integrations using that token will
</Accordion>
<Accordion title="401 Unauthorized — Expired token">Create a new token in settings.</Accordion>
<Accordion title="403 Forbidden — Token doesn't have access to the resource">
Ensure you're accessing resources owned by the token's account.
Ensure you're accessing resources owned by the token's team.
</Accordion>
</Accordions>
@@ -78,12 +78,10 @@ A successful response returns a list of your documents (envelopes):
"createdAt": "2025-01-15T10:30:00.000Z"
}
],
"pagination": {
"page": 1,
"perPage": 10,
"totalPages": 1,
"totalItems": 1
}
"count": 1,
"currentPage": 1,
"perPage": 10,
"totalPages": 1
}
````
@@ -228,9 +226,12 @@ After creating a document, it's in `DRAFT` status. To send it to recipients, use
<Tabs items={['curl', 'JavaScript']}>
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/envelope/envelope_abc123/distribute" \
curl -X POST "https://app.documenso.com/api/v2/envelope/distribute" \
-H "Authorization: YOUR_API_TOKEN" \
-H "Content-Type: application/json"
-H "Content-Type: application/json" \
-d '{
"envelopeId": "envelope_abc123"
}'
````
</Tab>
@@ -238,16 +239,14 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/envelope_abc123/distribu
```javascript
const envelopeId = 'envelope_abc123';
const response = await fetch(
`https://app.documenso.com/api/v2/envelope/${envelopeId}/distribute`,
{
method: 'POST',
headers: {
Authorization: 'YOUR_API_TOKEN',
'Content-Type': 'application/json',
},
const response = await fetch('https://app.documenso.com/api/v2/envelope/distribute', {
method: 'POST',
headers: {
Authorization: 'YOUR_API_TOKEN',
'Content-Type': 'application/json',
},
);
body: JSON.stringify({ envelopeId }),
});
const data = await response.json();
console.log('Document sent:', data);
@@ -337,16 +336,14 @@ async function createAndSendDocument(pdfPath, recipientEmail, recipientName) {
console.log('Created envelope:', envelope.id);
// Step 2: Send the document for signing
const distributeResponse = await fetch(
`${BASE_URL}/envelope/${envelope.id}/distribute`,
{
method: 'POST',
headers: {
'Authorization': API_TOKEN,
'Content-Type': 'application/json',
},
}
);
const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
method: 'POST',
headers: {
'Authorization': API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({ envelopeId: envelope.id }),
});
if (!distributeResponse.ok) {
const error = await distributeResponse.json();
@@ -422,9 +419,12 @@ echo "Created envelope: ${ENVELOPE_ID}"
# Step 2: Send the document for signing
echo "Sending document..."
curl -s -X POST "${BASE_URL}/envelope/${ENVELOPE_ID}/distribute" \
curl -s -X POST "${BASE_URL}/envelope/distribute" \
-H "Authorization: ${API_TOKEN}" \
-H "Content-Type: application/json"
-H "Content-Type: application/json" \
-d "{
\"envelopeId\": \"${ENVELOPE_ID}\"
}"
echo "Document sent for signing!"
@@ -441,7 +441,7 @@ The API returns standard HTTP status codes and JSON error responses:
| `400` | Bad request - check your request payload |
| `401` | Unauthorized - invalid or missing API token |
| `404` | Not found - resource doesn't exist |
| `429` | Rate limited - wait 60 seconds and retry |
| `429` | Rate limit or plan quota. If `Retry-After` is present, retry the request. |
| `500` | Server error - retry or contact support |
### Error Response Format
@@ -485,7 +485,9 @@ The API returns standard HTTP status codes and JSON error responses:
### Handling Rate Limits
The API allows 1000 requests per minute per IP address. Your organisation may have its own lower rate limits. When rate limited, wait at least 60 seconds before retrying:
The API has a limit of 1000 requests per minute for each IP address, and your organisation can have a lower limit. Each response includes `X-RateLimit-Remaining` and `X-RateLimit-Reset` (an epoch timestamp in seconds). A windowed rate-limit `429` response includes `Retry-After`. Wait for that number of seconds before you send the request again. A quota `429` response does not include `Retry-After` because a wait cannot correct the quota error. If `Retry-After` is not present, do not send the request again automatically.
Refer to [Error Handling Patterns](/docs/developers/examples/common-workflows#error-handling-patterns) for more retry information.
```javascript
async function fetchWithRetry(url, options, maxRetries = 3) {
@@ -493,8 +495,15 @@ async function fetchWithRetry(url, options, maxRetries = 3) {
const response = await fetch(url, options);
if (response.status === 429) {
console.log('Rate limited, waiting 60 seconds...');
await new Promise((resolve) => setTimeout(resolve, 60000));
const retryAfter = response.headers.get('Retry-After');
if (!retryAfter) {
return response;
}
const retryAfterSeconds = Number.parseInt(retryAfter, 10);
console.log(`Rate limit. Wait ${retryAfterSeconds} seconds...`);
await new Promise((resolve) => setTimeout(resolve, retryAfterSeconds * 1000));
continue;
}
@@ -9,7 +9,6 @@
"background-jobs",
"signing-certificate",
"telemetry",
"two-factor-enforcement",
"organisation-limits",
"advanced"
]
@@ -1,54 +0,0 @@
---
title: Two-Factor Enforcement
description: Require two-factor authentication instance-wide or per organisation, with grace periods and important limitations.
---
import { Callout } from 'fumadocs-ui/components/callout';
## Overview
Documenso can require users to enable two-factor authentication (2FA) at two levels:
- **Instance-wide enforcement** — configured by an instance administrator under **Admin → Site Settings**. Requires a Documenso license that includes the feature. Once a user's grace period expires, they are redirected to a forced enrolment page before they can continue using the app.
- **Per-organisation enforcement** — available to everyone, configured by organisation admins in the organisation settings. Once a member's grace period expires, only access to that organisation (and its teams) is blocked; the rest of the app stays usable.
A user satisfies enforcement when they have 2FA enabled **and** their current session has passed a second factor (a TOTP/backup-code challenge, a user-verified passkey sign-in, or enabling 2FA during the session). Sessions created before the user enabled 2FA must sign out and back in to verify.
## Grace Periods
Both levels support a grace period of 0–365 days:
- **Instance**: the window starts at the later of the user's grace start (typically account creation, restarted by an admin 2FA reset) and the moment enforcement was enabled.
- **Organisation**: the window starts at the latest of joining the organisation, the moment the organisation enabled enforcement, and the user's grace start.
A grace period of **0 days** enforces immediately: for the instance policy, users are forced to enrol right after signing up or signing in; for the organisation policy, members are blocked from the organisation until they enrol.
**Joining is never blocked; access is.** Invitations and SSO sign-ins always succeed — the grace window starts at join. Members who never comply still occupy a seat and count towards member limits; organisation admins can see per-member 2FA compliance in the members list.
Reducing an active grace period requires an explicit acknowledgement in the settings UI, since it can immediately block users who have not yet enrolled.
Enabling enforcement requires the acting administrator to already satisfy the policy themselves (2FA enabled and verified on their current session). This prevents administrators from locking themselves out with a 0-day grace period.
## Known Limitation: API Tokens Are Exempt
<Callout type="warn">
Enforcement applies to interactive (session-based) access only. **API tokens minted before a
user's deadline keep working after it.** A blocked user cannot mint new tokens, but existing
tokens are not revoked by enforcement. If you need to cut off a non-compliant user's API access,
revoke their tokens explicitly.
</Callout>
## Licensing
Instance-wide enforcement is license-gated:
- Without the license, the instance-wide section in Admin → Site Settings is visible but disabled.
- If enforcement was configured while licensed and the license later lapses, the stored configuration becomes **inactive** (nothing is enforced) and the only permitted change is disabling it.
Per-organisation enforcement does not require a license. When instance-wide enforcement is active, it takes precedence over organisation policies.
---
## See Also
- [License](/docs/self-hosting/configuration/license) - Configuring your Documenso license
+9 -128
View File
@@ -1,142 +1,23 @@
/**
* Multi purpose CORS lib.
* Note: Based on the `cors` package in npm but using only web APIs.
* Taken from: https://github.com/vercel/examples/blob/main/edge-functions/cors/lib/cors.ts
* Apply the public statistics API's wildcard CORS policy, without credentials.
*/
type StaticOrigin = boolean | string | RegExp | (boolean | string | RegExp)[];
type OriginFn = (origin: string | undefined, req: Request) => StaticOrigin | Promise<StaticOrigin>;
interface CorsOptions {
origin?: StaticOrigin | OriginFn;
methods?: string | string[];
allowedHeaders?: string | string[];
exposedHeaders?: string | string[];
credentials?: boolean;
maxAge?: number;
preflightContinue?: boolean;
optionsSuccessStatus?: number;
}
const defaultOptions: CorsOptions = {
origin: '*',
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
preflightContinue: false,
optionsSuccessStatus: 204,
};
function isOriginAllowed(origin: string, allowed: StaticOrigin): boolean {
return Array.isArray(allowed)
? allowed.some((o) => isOriginAllowed(origin, o))
: typeof allowed === 'string'
? origin === allowed
: allowed instanceof RegExp
? allowed.test(origin)
: !!allowed;
}
function getOriginHeaders(reqOrigin: string | undefined, origin: StaticOrigin) {
const headers = new Headers();
if (origin === '*') {
headers.set('Access-Control-Allow-Origin', '*');
} else if (typeof origin === 'string') {
headers.set('Access-Control-Allow-Origin', origin);
headers.append('Vary', 'Origin');
} else {
const allowed = isOriginAllowed(reqOrigin ?? '', origin);
if (allowed && reqOrigin) {
headers.set('Access-Control-Allow-Origin', reqOrigin);
}
headers.append('Vary', 'Origin');
}
return headers;
}
async function originHeadersFromReq(req: Request, origin: StaticOrigin | OriginFn) {
const reqOrigin = req.headers.get('Origin') || undefined;
const value = typeof origin === 'function' ? await origin(reqOrigin, req) : origin;
if (!value) {
return;
}
return getOriginHeaders(reqOrigin, value);
}
function getAllowedHeaders(req: Request, allowed?: string | string[]) {
const headers = new Headers();
if (!allowed) {
allowed = req.headers.get('Access-Control-Request-Headers')!;
headers.append('Vary', 'Access-Control-Request-Headers');
} else if (Array.isArray(allowed)) {
allowed = allowed.join(',');
}
if (allowed) {
headers.set('Access-Control-Allow-Headers', allowed);
}
return headers;
}
export default async function cors(req: Request, res: Response, options?: CorsOptions) {
const opts = { ...defaultOptions, ...options };
export default async function cors(req: Request, res: Response): Promise<Response> {
const { headers } = res;
const originHeaders = await originHeadersFromReq(req, opts.origin ?? false);
const mergeHeaders = (v: string, k: string) => {
if (k === 'Vary') {
headers.append(k, v);
} else {
headers.set(k, v);
}
};
headers.set('Access-Control-Allow-Origin', '*');
// If there's no origin we won't touch the response
if (!originHeaders) {
return res;
}
originHeaders.forEach(mergeHeaders);
if (opts.credentials) {
headers.set('Access-Control-Allow-Credentials', 'true');
}
const exposed = Array.isArray(opts.exposedHeaders) ? opts.exposedHeaders.join(',') : opts.exposedHeaders;
if (exposed) {
headers.set('Access-Control-Expose-Headers', exposed);
}
// Handle the preflight request
if (req.method === 'OPTIONS') {
if (opts.methods) {
const methods = Array.isArray(opts.methods) ? opts.methods.join(',') : opts.methods;
headers.set('Access-Control-Allow-Methods', 'GET,HEAD,PUT,PATCH,POST,DELETE');
headers.set('Vary', 'Access-Control-Request-Headers');
headers.set('Access-Control-Allow-Methods', methods);
}
const allowedHeaders = req.headers.get('Access-Control-Request-Headers');
getAllowedHeaders(req, opts.allowedHeaders).forEach(mergeHeaders);
if (typeof opts.maxAge === 'number') {
headers.set('Access-Control-Max-Age', String(opts.maxAge));
}
if (opts.preflightContinue) {
return res;
if (allowedHeaders) {
headers.set('Access-Control-Allow-Headers', allowedHeaders);
}
headers.set('Content-Length', '0');
return new Response(null, { status: opts.optionsSuccessStatus, headers });
return new Response(null, { status: 204, headers });
}
// If we got here, it's a normal request
return res;
}
export function initCors(options?: CorsOptions) {
return async (req: Request, res: Response) => cors(req, res, options);
}
@@ -56,7 +56,7 @@ export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserRese
.with(AppErrorCode.NOT_FOUND, () => msg`User not found.`)
.with(
AppErrorCode.UNAUTHORIZED,
() => msg`You are not authorized to reset two factor authentication for this user.`,
() => msg`You are not authorized to reset two factor authentcation for this user.`,
)
.otherwise(() => msg`An error occurred while resetting two factor authentication for the user.`);
@@ -85,8 +85,7 @@ export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserRese
<AlertDescription className="mr-2">
<Trans>
Reset the users two factor authentication. This action is irreversible and will disable two factor
authentication for the user. Their two-factor enforcement grace period will restart from the moment of the
reset.
authentication for the user.
</Trans>
</AlertDescription>
</div>
@@ -109,8 +108,7 @@ export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserRese
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
<Trans>
This action is irreversible. Please ensure you have informed the user before proceeding. Any
two-factor enforcement grace period for this user will restart from the moment of the reset.
This action is irreversible. Please ensure you have informed the user before proceeding.
</Trans>
</AlertDescription>
</Alert>
@@ -4,6 +4,7 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { getContentsMissingImages, resolveEnvelopeContentLimits } from '@documenso/lib/utils/envelope-content';
import { hasOverlappingFields } from '@documenso/lib/utils/fields-overlap';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { zEmail } from '@documenso/lib/utils/zod';
@@ -31,7 +32,7 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
import { AlertTriangleIcon, InfoIcon } from 'lucide-react';
@@ -162,6 +163,25 @@ export const EnvelopeDistributeDialog = ({
[envelope.fields],
);
/**
* Image related contents without an image render nothing once sent.
*/
const contentsMissingImages = useMemo(() => getContentsMissingImages(envelope.contents), [envelope.contents]);
/**
* An envelope can hold more contents than the organisation's plan allows,
* e.g. after the plan was lowered or a template was copied, so this is
* reported here rather than letting the send fail.
*/
const contentLimits = useMemo(
() =>
resolveEnvelopeContentLimits(
envelope.contents.map((content) => content.contentMeta.type),
organisation.organisationClaim,
),
[envelope.contents, organisation.organisationClaim],
);
const invalidEnvelopeCode = useMemo(() => {
if (recipientsMissingSignatureFields.length > 0) {
return 'MISSING_SIGNATURES';
@@ -175,8 +195,26 @@ export const EnvelopeDistributeDialog = ({
return 'MISSING_REQUIRED_EMAIL';
}
if (contentsMissingImages.length > 0) {
return 'MISSING_CONTENT_IMAGES';
}
if (contentLimits.isContentLimitExceeded) {
return 'ENVELOPE_CONTENT_LIMIT_EXCEEDED';
}
if (contentLimits.isImageLimitExceeded) {
return 'ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED';
}
return null;
}, [envelope.recipients, recipientsMissingRequiredEmail, recipientsMissingSignatureFields]);
}, [
envelope.recipients,
recipientsMissingRequiredEmail,
recipientsMissingSignatureFields,
contentsMissingImages,
contentLimits,
]);
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
try {
@@ -520,6 +558,33 @@ export const EnvelopeDistributeDialog = ({
</ul>
</AlertDescription>
))
.with('MISSING_CONTENT_IMAGES', () => (
<AlertDescription>
<Plural
value={contentsMissingImages.length}
one="An image content has no image. Upload an image for it, or remove it."
other="# image contents have no image. Upload an image for each, or remove them."
/>
</AlertDescription>
))
.with('ENVELOPE_CONTENT_LIMIT_EXCEEDED', () => (
<AlertDescription>
<Plural
value={contentLimits.contentLimit}
one="This envelope cannot have more than # content item. Remove some, or contact support if you need more."
other="This envelope cannot have more than # content items. Remove some, or contact support if you need more."
/>
</AlertDescription>
))
.with('ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED', () => (
<AlertDescription>
<Plural
value={contentLimits.imageLimit}
one="This envelope cannot have more than # image content item. Remove some, or contact support if you need more."
other="This envelope cannot have more than # image content items. Remove some, or contact support if you need more."
/>
</AlertDescription>
))
.exhaustive()}
</Alert>
@@ -44,6 +44,7 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
defaultValues: {
includeRecipients: true,
includeFields: true,
includeContents: true,
},
});
@@ -67,13 +68,14 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
});
const onDuplicate = async () => {
const { includeRecipients, includeFields } = form.getValues();
const { includeRecipients, includeFields, includeContents } = form.getValues();
try {
await duplicateEnvelope({
envelopeId,
includeRecipients,
includeFields: includeRecipients && includeFields,
includeContents,
});
} catch {
toast({
@@ -159,6 +161,23 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
</div>
)}
/>
<Controller
control={form.control}
name="includeContents"
render={({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="envelopeDuplicateIncludeContents"
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
<Label htmlFor="envelopeDuplicateIncludeContents">
<Trans>Include Contents</Trans>
</Label>
</div>
)}
/>
</div>
<DialogFooter>
@@ -1,6 +1,6 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { formatFileSize, megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { trpc } from '@documenso/trpc/react';
import { ZDocumentTitleSchema } from '@documenso/trpc/server/document-router/schema';
import type { TReplaceEnvelopeItemPdfPayload } from '@documenso/trpc/server/envelope-router/replace-envelope-item-pdf.types';
@@ -122,7 +122,7 @@ export const EnvelopeItemEditDialog = ({
toast({
title: t`Failed to read file`,
description: t`The file is not a valid PDF.`,
description: t`The file is not a valid PDF or is password protected.`,
variant: 'destructive',
});
}
@@ -195,18 +195,6 @@ export const EnvelopeItemEditDialog = ({
}
}, [isOpen, form, envelopeItem.title]);
const formatFileSize = (bytes: number) => {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<Dialog {...props} open={isOpen} onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
@@ -44,7 +44,10 @@ export const EnvelopesBulkDeleteDialog = ({
if (isDocument) {
await trpcUtils.document.findDocumentsInternal.invalidate();
} else {
await trpcUtils.template.findTemplates.invalidate();
await Promise.all([
trpcUtils.template.findTemplates.invalidate(),
trpcUtils.template.findTemplatesInternal.invalidate(),
]);
}
if (result.failedIds.length > 0) {
@@ -299,7 +299,7 @@ export const EnvelopesBulkDownloadDialog = ({
</Alert>
)}
<fieldset disabled={isDownloading} className="space-y-4">
<fieldset disabled={isDownloading} className="min-w-0 space-y-4">
<div className="-mx-3 max-h-96 overflow-y-auto px-3">
<div className="divide-y divide-border rounded-lg border border-border">
{envelopes.map((envelope) => {
@@ -96,7 +96,10 @@ export const EnvelopesBulkMoveDialog = ({
if (isDocument) {
await trpcUtils.document.findDocumentsInternal.invalidate();
} else {
await trpcUtils.template.findTemplates.invalidate();
await Promise.all([
trpcUtils.template.findTemplates.invalidate(),
trpcUtils.template.findTemplatesInternal.invalidate(),
]);
}
await onSuccess?.(data.folderId);
@@ -6,8 +6,9 @@ import {
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { DOCUMENT_TITLE_MAX_LENGTH } from '@documenso/trpc/server/document-router/schema';
import type { TUseEnvelopePayload } from '@documenso/trpc/server/envelope-router/use-envelope.types';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
@@ -23,6 +24,7 @@ import {
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -38,33 +40,70 @@ import { useNavigate } from 'react-router';
import * as z from 'zod';
import { getTemplateUseErrorMessage } from '~/utils/toast-error-messages';
const ZAddRecipientsForNewDocumentSchema = z.object({
distributeDocument: z.boolean(),
useCustomDocument: z.boolean().default(false),
customDocumentData: z
.array(
const DOCUMENT_NAME_SOURCE = {
TEMPLATE: 'template',
UPLOAD: 'upload',
CUSTOM: 'custom',
} as const;
const getUploadedDocumentTitle = (file: File) => {
return file.name.replace(/\.[^/.]+$/, '').trim();
};
/**
* Whether the file name can be used as a document title.
*/
const isUploadedFileNameUsable = (file?: File): file is File => {
if (!file) {
return false;
}
const title = getUploadedDocumentTitle(file);
return title.length > 0 && title.length <= DOCUMENT_TITLE_MAX_LENGTH;
};
const ZAddRecipientsForNewDocumentSchema = z
.object({
distributeDocument: z.boolean(),
includeContents: z.boolean().default(true),
useCustomDocument: z.boolean().default(false),
documentNameSource: z.enum([
DOCUMENT_NAME_SOURCE.TEMPLATE,
DOCUMENT_NAME_SOURCE.UPLOAD,
DOCUMENT_NAME_SOURCE.CUSTOM,
]),
customDocumentName: z
.string()
.trim()
.max(DOCUMENT_TITLE_MAX_LENGTH, { message: msg`Document name is too long`.id }),
customDocumentData: z
.array(
z.object({
title: z.string(),
data: z.instanceof(File).optional(),
envelopeItemId: z.string(),
}),
)
.optional(),
recipients: z.array(
z.object({
title: z.string(),
data: z.instanceof(File).optional(),
envelopeItemId: z.string(),
id: z.number(),
email: ZRecipientEmailSchema,
name: z.string(),
signingOrder: z.number().optional(),
}),
)
.optional(),
recipients: z.array(
z.object({
id: z.number(),
email: ZRecipientEmailSchema,
name: z.string(),
signingOrder: z.number().optional(),
}),
),
});
),
})
.refine((data) => data.documentNameSource !== DOCUMENT_NAME_SOURCE.CUSTOM || data.customDocumentName.length > 0, {
message: msg`Document name is required`.id,
path: ['customDocumentName'],
});
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
export type TemplateUseDialogProps = {
envelopeId: string;
templateId: number;
templateSigningOrder?: DocumentSigningOrder | null;
recipients: TRecipientLite[];
documentDistributionMethod?: DocumentDistributionMethod;
@@ -77,7 +116,6 @@ export function TemplateUseDialog({
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
documentRootPath,
envelopeId,
templateId,
templateSigningOrder,
trigger,
}: TemplateUseDialogProps) {
@@ -87,6 +125,7 @@ export function TemplateUseDialog({
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [lastUploadedFile, setLastUploadedFile] = useState<File>();
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
{
@@ -105,7 +144,10 @@ export function TemplateUseDialog({
const generateDefaultFormValues = () => {
return {
distributeDocument: false,
includeContents: true,
useCustomDocument: false,
documentNameSource: DOCUMENT_NAME_SOURCE.TEMPLATE,
customDocumentName: '',
customDocumentData: envelopeItems.map((item) => ({
title: item.title,
data: undefined,
@@ -138,32 +180,66 @@ export function TemplateUseDialog({
name: 'customDocumentData',
});
const { mutateAsync: createDocumentFromTemplate } = trpc.template.createDocumentFromTemplate.useMutation();
const { mutateAsync: createDocumentFromTemplate } = trpc.envelope.use.useMutation();
/**
* Track the most recently uploaded file so its name can be used as the document name.
* Files with an unusable name are ignored, and the document name source is reset if
* no usable file remains.
*/
const updateLastUploadedFile = (file?: File) => {
const usableFile = isUploadedFileNameUsable(file) ? file : undefined;
setLastUploadedFile(usableFile);
if (!usableFile && form.getValues('documentNameSource') === DOCUMENT_NAME_SOURCE.UPLOAD) {
form.setValue('documentNameSource', DOCUMENT_NAME_SOURCE.TEMPLATE);
}
};
const getDocumentTitle = (data: TAddRecipientsForNewDocumentSchema) => {
if (data.documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM) {
return data.customDocumentName;
}
if (data.documentNameSource === DOCUMENT_NAME_SOURCE.UPLOAD && lastUploadedFile) {
return getUploadedDocumentTitle(lastUploadedFile);
}
return undefined;
};
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
try {
const customFilesToUpload = (data.customDocumentData || []).filter(
(item): item is { data: File; envelopeItemId: string; title: string } =>
item.data !== undefined && item.envelopeItemId !== undefined && item.title !== undefined,
const documentTitle = getDocumentTitle(data);
const customFilesToUpload = (data.customDocumentData ?? []).filter(
(item): item is typeof item & { data: File } => item.data !== undefined,
);
const customDocumentData = await Promise.all(
customFilesToUpload.map(async (item) => {
const customDocumentData = await putPdfFile(item.data);
return {
documentDataId: customDocumentData.id,
envelopeItemId: item.envelopeItemId,
};
}),
);
const { envelopeId } = await createDocumentFromTemplate({
templateId,
// The files are sent alongside the payload and mapped to their envelope
// items by index, which is robust against duplicate file names.
const payload: TUseEnvelopePayload = {
envelopeId,
recipients: data.recipients,
distributeDocument: data.distributeDocument,
customDocumentData,
});
includeContents: data.includeContents,
customDocumentData: customFilesToUpload.map((item, index) => ({
identifier: index,
envelopeItemId: item.envelopeItemId,
})),
...(documentTitle ? { override: { title: documentTitle } } : {}),
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
for (const item of customFilesToUpload) {
formData.append('files', item.data);
}
const { id: createdEnvelopeId } = await createDocumentFromTemplate(formData);
toast({
title: _(msg`Document created`),
@@ -171,7 +247,7 @@ export function TemplateUseDialog({
duration: 5000,
});
let documentPath = `${documentRootPath}/${envelopeId}`;
let documentPath = `${documentRootPath}/${createdEnvelopeId}`;
if (data.distributeDocument && documentDistributionMethod === DocumentDistributionMethod.NONE) {
documentPath += '?action=view-signing-links';
@@ -195,9 +271,14 @@ export function TemplateUseDialog({
name: 'recipients',
});
const useCustomDocument = form.watch('useCustomDocument');
const documentNameSource = form.watch('documentNameSource');
const canUseUploadedDocumentName = Boolean(lastUploadedFile);
useEffect(() => {
if (open) {
form.reset(generateDefaultFormValues());
setLastUploadedFile(undefined);
}
}, [open, form]);
@@ -238,9 +319,9 @@ export function TemplateUseDialog({
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
<form className="min-w-0" onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="flex h-full min-w-0 flex-col" disabled={form.formState.isSubmitting}>
<div className="custom-scrollbar -m-1 max-h-[60vh] w-full min-w-0 max-w-full space-y-4 overflow-y-auto overflow-x-hidden p-1">
{formRecipients.map((recipient, index) => (
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
{templateSigningOrder === DocumentSigningOrder.SEQUENTIAL && (
@@ -388,6 +469,46 @@ export function TemplateUseDialog({
</div>
)}
<FormField
control={form.control}
name="includeContents"
render={({ field }) => (
<FormItem>
<div className="flex flex-row items-center">
<Checkbox
id="includeContents"
className="h-5 w-5"
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
<label
className="ml-2 flex items-center text-muted-foreground text-sm"
htmlFor="includeContents"
>
<Trans>Include contents</Trans>
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>
Copy the text, shapes, images and other contents from the template onto the new
document.
</Trans>
</p>
<p>
<Trans>Uncheck this to create the document without them.</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="useCustomDocument"
@@ -401,7 +522,17 @@ export function TemplateUseDialog({
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {
form.setValue('customDocumentData', undefined);
const customDocumentData = form.getValues('customDocumentData');
form.setValue(
'customDocumentData',
customDocumentData?.map((item) => ({
...item,
data: undefined,
})),
);
form.clearErrors('customDocumentData');
updateLastUploadedFile(undefined);
}
}}
/>
@@ -428,7 +559,7 @@ export function TemplateUseDialog({
)}
/>
{form.watch('useCustomDocument') && (
{useCustomDocument && (
<div className="my-4 space-y-2">
{isLoadingEnvelopeItems ? (
<SpinnerBox className="py-16" />
@@ -443,7 +574,7 @@ export function TemplateUseDialog({
<FormControl>
<div
key={item.id}
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
className="flex w-full min-w-0 items-center gap-4 overflow-hidden rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
>
<div className="flex-shrink-0">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
@@ -451,13 +582,15 @@ export function TemplateUseDialog({
</div>
</div>
<div className="min-w-0 flex-1">
<h4 className="truncate font-medium text-foreground text-sm">{item.title}</h4>
<div className="min-w-0 flex-1 overflow-hidden">
<h4 className="truncate font-medium text-foreground text-sm">
{field.value ? getUploadedDocumentTitle(field.value) : item.title}
</h4>
<p className="mt-0.5 text-muted-foreground text-xs">
{field.value ? (
<div>
<span>
<Trans>Custom {(field.value.size / (1024 * 1024)).toFixed(2)} MB file</Trans>
</div>
</span>
) : (
<Trans>Default file</Trans>
)}
@@ -475,6 +608,18 @@ export function TemplateUseDialog({
onClick={(e) => {
e.preventDefault();
field.onChange(undefined);
if (field.value === lastUploadedFile) {
// Fall back to any other uploaded file so the option stays available.
const remainingUploadedFile = form
.getValues('customDocumentData')
?.find(
(item) =>
item.data !== field.value && isUploadedFileNameUsable(item.data),
)?.data;
updateLastUploadedFile(remainingUploadedFile);
}
}}
>
<X className="mr-2 h-4 w-4" />
@@ -517,7 +662,7 @@ export function TemplateUseDialog({
}
if (file.type !== 'application/pdf') {
form.setError('customDocumentData', {
form.setError(`customDocumentData.${i}.data`, {
type: 'manual',
message: _(msg`Please select a PDF file`),
});
@@ -526,7 +671,7 @@ export function TemplateUseDialog({
}
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) {
form.setError('customDocumentData', {
form.setError(`customDocumentData.${i}.data`, {
type: 'manual',
message: _(
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
@@ -537,6 +682,8 @@ export function TemplateUseDialog({
}
field.onChange(file);
form.clearErrors(`customDocumentData.${i}.data`);
updateLastUploadedFile(file);
}}
/>
</div>
@@ -550,6 +697,112 @@ export function TemplateUseDialog({
)}
</div>
)}
<FormField
control={form.control}
name="documentNameSource"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Document name</Trans>
</FormLabel>
<FormControl>
<RadioGroup
aria-label={_(msg`Document name`)}
value={field.value}
onValueChange={field.onChange}
className="space-y-2"
>
<div className="flex items-center gap-2">
<RadioGroupItem id="document-name-source-template" value={DOCUMENT_NAME_SOURCE.TEMPLATE} />
<label className="text-sm" htmlFor="document-name-source-template">
<Trans>Use template name</Trans>
</label>
</div>
<div className="flex items-start gap-2">
<RadioGroupItem
id="document-name-source-upload"
value={DOCUMENT_NAME_SOURCE.UPLOAD}
disabled={!canUseUploadedDocumentName}
className="mt-0.5"
/>
<div className="min-w-0">
<div className="flex items-center gap-1">
<label
className={cn('text-sm', {
'cursor-not-allowed text-muted-foreground': !canUseUploadedDocumentName,
})}
htmlFor="document-name-source-upload"
>
<Trans>Use uploaded file name</Trans>
</label>
<Tooltip>
<TooltipTrigger
type="button"
aria-label={_(msg`About uploaded file naming`)}
className="text-muted-foreground"
>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="z-[99999] max-w-xs">
<Trans>
The document name will use the most recently uploaded file name without its
extension.
</Trans>
</TooltipContent>
</Tooltip>
</div>
{lastUploadedFile && (
<p
className="max-w-sm truncate text-muted-foreground text-xs"
title={lastUploadedFile.name}
>
{lastUploadedFile.name}
</p>
)}
{!canUseUploadedDocumentName && (
<p className="text-muted-foreground text-xs">
<Trans>Upload a custom document to use its file name.</Trans>
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem id="document-name-source-custom" value={DOCUMENT_NAME_SOURCE.CUSTOM} />
<label className="text-sm" htmlFor="document-name-source-custom">
<Trans>Enter custom document name</Trans>
</label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM && (
<FormField
control={form.control}
name="customDocumentName"
render={({ field }) => (
<FormItem className="ml-6">
<FormControl>
<Input
{...field}
aria-label={_(msg`Custom document name`)}
placeholder={_(msg`Enter a document name`)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
<DialogFooter className="mt-4">
@@ -1,4 +1,5 @@
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { formatFileSize } from '@documenso/lib/universal/unit-convertions';
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -98,16 +99,6 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
form.unregister('documentData');
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) {
return '0 Bytes';
}
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / 1024 ** i).toFixed(2))} ${sizes[i]}`;
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
@@ -2,6 +2,7 @@ import signingCelebration from '@documenso/assets/images/signing-celebration.png
import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { Trans } from '@lingui/react/macro';
import type { Signature } from '@prisma/client';
import { CheckCircle2Icon } from 'lucide-react';
export type EmbedDocumentCompletedPageProps = {
name?: string;
@@ -10,12 +11,8 @@ export type EmbedDocumentCompletedPageProps = {
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
return (
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<h3 className="font-semibold text-2xl text-foreground">
<Trans>Document Completed!</Trans>
</h3>
<div className="mt-8 w-full max-w-md">
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center overflow-hidden p-6">
<div className="embed--DocumentCompletedCard w-full max-w-sm md:max-w-md">
<SigningCard3D
className="mx-auto w-full"
name={name || 'Documenso'}
@@ -24,10 +21,19 @@ export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentComplet
/>
</div>
<p className="mt-8 max-w-[50ch] text-center text-muted-foreground text-sm">
<Trans>
The document is now completed, please follow any instructions provided within the parent application.
</Trans>
<h2 className="embed--DocumentCompletedTitle mt-8 max-w-[35ch] text-center font-semibold text-2xl text-foreground leading-normal md:text-3xl">
<Trans>Document Completed</Trans>
</h2>
<div className="embed--DocumentCompletedStatus mt-4 flex items-center text-center text-documenso-700">
<CheckCircle2Icon className="mr-2 h-5 w-5" />
<span className="text-sm">
<Trans>No further action is required</Trans>
</span>
</div>
<p className="embed--DocumentCompletedDescription mt-2.5 max-w-[50ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
<Trans>Please follow any instructions provided within the parent application.</Trans>
</p>
</div>
);
@@ -55,6 +55,7 @@ export type EmbedSignDocumentV1ClientPageProps = {
completedFields: DocumentField[];
metadata?: DocumentMeta | null;
isCompleted?: boolean;
isRejected?: boolean;
hidePoweredBy?: boolean;
allowWhitelabelling?: boolean;
allRecipients?: RecipientWithFields[];
@@ -70,6 +71,7 @@ export const EmbedSignDocumentV1ClientPage = ({
completedFields,
metadata,
isCompleted,
isRejected,
hidePoweredBy = false,
allowWhitelabelling = false,
allRecipients = [],
@@ -83,7 +85,9 @@ export const EmbedSignDocumentV1ClientPage = ({
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
const [hasRejectedDocument, setHasRejectedDocument] = useState(recipient.signingStatus === SigningStatus.REJECTED);
const [hasRejectedDocument, setHasRejectedDocument] = useState(
isRejected ?? recipient.signingStatus === SigningStatus.REJECTED,
);
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
allRecipients.length > 0 ? allRecipients[0].id : null,
);
@@ -263,6 +267,44 @@ export const EmbedSignDocumentV1ClientPage = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!window.parent) {
return;
}
if (hasRejectedDocument) {
window.parent.postMessage(
{
action: 'document-rejected',
data: {
token,
documentId,
recipientId: recipient.id,
},
},
'*',
);
return;
}
if (hasCompletedDocument) {
window.parent.postMessage(
{
action: 'document-completed',
data: {
token,
documentId,
recipientId: recipient.id,
},
},
'*',
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (hasFinishedInit && hasDocumentLoaded && window.parent) {
window.parent.postMessage(
@@ -40,12 +40,18 @@ export const EmbedSignDocumentV2ClientPage = ({
const [isNameLocked, setIsNameLocked] = useState(false);
const [isEmailLocked, setIsEmailLocked] = useState(envelope.type === EnvelopeType.DOCUMENT && !!email);
// The signing provider's envelope data isn't refreshed on revalidation.
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
const [hasRejectedDocument, setHasRejectedDocument] = useState(isRejected);
const onDocumentCompleted = (data: {
token: string;
documentId: number;
envelopeId: string;
recipientId: number;
}) => {
setHasCompletedDocument(true);
if (window.parent) {
window.parent.postMessage(
{
@@ -112,6 +118,8 @@ export const EmbedSignDocumentV2ClientPage = ({
recipientId: number;
reason?: string;
}) => {
setHasRejectedDocument(true);
if (window.parent) {
window.parent.postMessage(
{
@@ -219,23 +227,26 @@ export const EmbedSignDocumentV2ClientPage = ({
}
}, [isRejected, envelope.id, recipient.id, recipient.token]);
if (isRejected) {
if (hasRejectedDocument) {
return <EmbedDocumentRejected />;
}
if (isCompleted) {
if (hasCompletedDocument) {
const completedSignature =
recipient.fields.find((field) => field.signature)?.signature ?? recipientSignature ?? null;
return (
<EmbedDocumentCompleted
name={fullName}
signature={
recipientSignature
completedSignature
? {
id: 1,
fieldId: 1,
recipientId: recipient.id,
created: new Date(),
signatureImageAsBase64: recipientSignature.signatureImageAsBase64,
typedSignature: recipientSignature.typedSignature,
signatureImageAsBase64: completedSignature.signatureImageAsBase64,
typedSignature: completedSignature.typedSignature,
}
: undefined
}
@@ -1,6 +1,5 @@
import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { AppError } from '@documenso/lib/errors/app-error';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -69,23 +68,15 @@ export const DisableAuthenticatorAppDialog = () => {
const { isSubmitting: isDisable2FASubmitting } = disable2FAForm.formState;
// Todo: (2FA enforcement, step 5) Once org enforcement state is available
// client-side, warn BEFORE disabling that org/team access will block at the
// org 2FA deadline. Until then the warning is shown after the fact based on
// the server response.
const onDisable2FAFormSubmit = async ({ totpCode, backupCode }: TDisable2FAForm) => {
try {
const { orgEnforcementApplies } = await authClient.twoFactor.disable({ totpCode, backupCode });
await authClient.twoFactor.disable({ totpCode, backupCode });
toast({
title: _(msg`Two-factor authentication disabled`),
description: orgEnforcementApplies
? _(
msg`Two-factor authentication has been disabled for your account. One of your organisations requires two-factor authentication: access to it will be blocked at its deadline until you re-enable 2FA.`,
)
: _(
msg`Two-factor authentication has been disabled for your account. You will no longer be required to enter a code from your authenticator app when signing in.`,
),
description: _(
msg`Two-factor authentication has been disabled for your account. You will no longer be required to enter a code from your authenticator app when signing in.`,
),
});
flushSync(() => {
@@ -93,19 +84,7 @@ export const DisableAuthenticatorAppDialog = () => {
});
await refreshSession();
} catch (err) {
const error = AppError.parseError(err);
if (error.code === 'TWO_FACTOR_DISABLE_FORBIDDEN') {
toast({
title: _(msg`Unable to disable two-factor authentication`),
description: _(msg`Two-factor authentication is required by this instance and cannot be disabled.`),
variant: 'destructive',
});
return;
}
} catch (_err) {
toast({
title: _(msg`Unable to disable two-factor authentication`),
description: _(
@@ -1,7 +1,6 @@
import { authClient } from '@documenso/auth/client';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { AppError } from '@documenso/lib/errors/app-error';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -70,18 +69,11 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
setSetup2FAData(data);
} catch (err) {
const error = AppError.parseError(err);
toast({
title: _(msg`Unable to setup two-factor authentication`),
description:
error.code === 'TWO_FACTOR_ALREADY_ENABLED'
? _(
msg`Two-factor authentication is already enabled for your account. Disable it before setting it up again.`,
)
: _(
msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`,
),
description: _(
msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`,
),
variant: 'destructive',
});
}
@@ -0,0 +1,175 @@
import {
CONTENT_MAX_STROKE_WIDTH,
CONTENT_MIN_STROKE_WIDTH,
DEFAULT_CONTENT_TEXT_COLOR,
} from '@documenso/lib/types/envelope-content-meta';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Slider } from '@documenso/ui/primitives/slider';
import { Trans, useLingui } from '@lingui/react/macro';
import type { Control } from 'react-hook-form';
// Can't seem to get the non-any type to work with correct types.
// Eg Control<{ fontSize?: number } doesn't seem to work when there are required items.
// biome-ignore lint/suspicious/noExplicitAny: See above
type FormControlType = Control<any>;
type GenericContentFieldProps = {
formControl: FormControlType;
className?: string;
};
export const EditorContentColorField = ({
formControl,
className,
name = 'color',
label,
defaultColor = DEFAULT_CONTENT_TEXT_COLOR,
}: GenericContentFieldProps & {
name?: string;
label?: React.ReactNode;
defaultColor?: string;
}) => {
return (
<FormField
control={formControl}
name={name}
render={({ field }) => (
<FormItem className={className}>
<FormLabel>{label ?? <Trans>Color</Trans>}</FormLabel>
<FormControl>
<div className="flex items-center gap-x-3">
<ColorPicker
data-testid={`content-form-${name}`}
value={field.value ?? ''}
defaultValue={defaultColor}
onChange={(color) => field.onChange(color)}
/>
<span className="font-mono text-muted-foreground text-xs uppercase">{field.value ?? defaultColor}</span>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export const EditorContentStrokeWidthField = ({ formControl, className }: GenericContentFieldProps) => {
const { t } = useLingui();
return (
<FormField
control={formControl}
name="strokeWidth"
render={({ field }) => (
<FormItem className={className}>
<FormLabel>
<Trans>Thickness</Trans>
</FormLabel>
<FormControl>
<Input
data-testid="content-form-strokeWidth"
type="number"
min={CONTENT_MIN_STROKE_WIDTH}
max={CONTENT_MAX_STROKE_WIDTH}
step={0.5}
className="bg-background"
placeholder={t`Thickness`}
{...field}
onChange={(e) => {
field.onChange(Number(e.target.value));
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export const EditorContentStrokeStyleField = ({ formControl, className }: GenericContentFieldProps) => {
const { t } = useLingui();
return (
<FormField
control={formControl}
name="strokeStyle"
render={({ field }) => (
<FormItem className={className}>
<FormLabel>
<Trans>Style</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger data-testid="content-form-strokeStyle">
<SelectValue placeholder={t`Select style`} />
</SelectTrigger>
<SelectContent>
<SelectItem value="solid">
<Trans>Solid</Trans>
</SelectItem>
<SelectItem value="dashed">
<Trans>Dashed</Trans>
</SelectItem>
<SelectItem value="dotted">
<Trans>Dotted</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
/**
* An opacity slider. The form value is a fraction (0-1) while the slider and
* label display a percentage.
*/
export const EditorContentOpacityField = ({
formControl,
className,
name = 'fillOpacity',
label,
}: GenericContentFieldProps & {
name?: string;
label?: React.ReactNode;
}) => {
return (
<FormField
control={formControl}
name={name}
render={({ field }) => {
const percentage = Math.round((field.value ?? 1) * 100);
return (
<FormItem className={className}>
<FormLabel className="flex items-center justify-between">
{label ?? <Trans>Opacity</Trans>}
<span className="font-mono text-muted-foreground tabular-nums">{percentage}%</span>
</FormLabel>
<FormControl>
<Slider
data-testid={`content-form-${name}`}
className="py-2"
min={0}
max={100}
step={1}
value={[percentage]}
onValueChange={([value]) => field.onChange(value / 100)}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
);
};
@@ -0,0 +1,31 @@
import { DEFAULT_CONTENT_HIGHLIGHT_COLOR } from '@documenso/lib/types/envelope-content-meta';
import {
type TContentHighlightFormSchema,
useContentSettingsForm,
} from '~/components/general/envelope-editor/content-settings-form-provider';
import { EditorContentColorField, EditorContentOpacityField } from './editor-content-generic-field-forms';
/**
* Settings form for highlight contents, bound to the selected content's
* settings form. Only the presentational settings are editable here;
* geometry (page, position and size) is managed on the canvas.
*/
export const EditorContentHighlightForm = () => {
const { form, isReady } = useContentSettingsForm<TContentHighlightFormSchema>();
// Mount the inputs only once the form holds this content's values.
if (!isReady) {
return null;
}
return (
<div>
<fieldset className="flex flex-col gap-2">
<EditorContentColorField formControl={form.control} defaultColor={DEFAULT_CONTENT_HIGHLIGHT_COLOR} />
<EditorContentOpacityField formControl={form.control} name="fillOpacity" />
</fieldset>
</div>
);
};
@@ -0,0 +1,136 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import {
APP_CONTENT_IMAGE_MIME_TYPES,
APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT,
} from '@documenso/lib/constants/envelope-content';
import { formatFileSize, megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Trans, useLingui } from '@lingui/react/macro';
import { FileImageIcon, ImageUpIcon, RefreshCwIcon, TrashIcon } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { ContentImageUploadDialog } from '~/components/general/envelope-editor/content-image-upload-dialog';
type EditorContentImageSettingsProps = {
formId: string;
dataContentId?: string | null;
};
/**
* Settings for image contents: the image itself.
*
* Uploads go through the shared upload dialog. This panel only offers the
* ways to start one (drop, click, replace) and to remove the current image.
* Geometry (page, position, size and rotation) is managed on the canvas.
*/
export const EditorContentImageSettings = ({ formId, dataContentId }: EditorContentImageSettingsProps) => {
const { t } = useLingui();
const { editorContents } = useCurrentEnvelopeEditor();
const { contentImages } = useCurrentEnvelopeRender();
const image = dataContentId ? contentImages.images.get(dataContentId) : undefined;
const details = dataContentId ? contentImages.details.get(dataContentId) : undefined;
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: Object.fromEntries(APP_CONTENT_IMAGE_MIME_TYPES.map((mimeType) => [mimeType, []])),
multiple: false,
maxSize: megabytesToBytes(APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT),
// A dropped file goes straight to the dialog; a click opens the picker
// through it instead of the dropzone's own input.
noClick: true,
onDrop: (acceptedFiles) => {
const [file] = acceptedFiles;
if (file) {
void ContentImageUploadDialog.call({ formId, file });
}
},
// A file which is too large or not a supported image goes to the dialog
// as well, which shows why it cannot be used. When several files were
// dropped, the first is used.
onDropRejected: (fileRejections) => {
const [rejection] = fileRejections;
if (rejection) {
void ContentImageUploadDialog.call({ formId, file: rejection.file });
}
},
});
if (dataContentId) {
return (
<div className="mt-2 flex items-center gap-3 rounded-lg border border-border p-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-md bg-muted/40">
<FileImageIcon className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground text-xs">
<Trans>Image</Trans>
</p>
<p className="truncate text-muted-foreground text-xs">
{[details ? formatFileSize(details.fileSize) : null, image ? `${image.width} * ${image.height}` : null]
.filter((part) => part !== null)
.join(' · ')}
</p>
</div>
<div className="flex shrink-0 flex-row gap-1">
<Button
type="button"
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
title={t`Replace image`}
onClick={() => void ContentImageUploadDialog.call({ formId })}
>
<RefreshCwIcon className="h-4 w-4" strokeWidth={1.5} />
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
title={t`Remove image`}
onClick={() => editorContents.updateContentByFormId(formId, { dataContentId: null, data: undefined })}
>
<TrashIcon className="h-4 w-4" strokeWidth={1.5} />
</Button>
</div>
</div>
);
}
return (
<div
{...getRootProps({
role: 'button',
tabIndex: 0,
onClick: () => void ContentImageUploadDialog.call({ formId }),
onKeyDown: (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
void ContentImageUploadDialog.call({ formId });
}
},
})}
className={cn(
'mt-2 flex cursor-pointer flex-col items-center justify-center gap-1.5 rounded-lg border border-border border-dashed px-4 py-6 text-center transition-colors hover:border-primary/60',
isDragActive && 'border-primary bg-primary/5',
)}
>
<input {...getInputProps()} />
<ImageUpIcon className="h-6 w-6 text-muted-foreground" strokeWidth={1.5} />
<p className="text-foreground text-sm">
<Trans>Click to upload or drag and drop</Trans>
</p>
<p className="text-muted-foreground text-xs">
<Trans>PNG, JPEG or WebP up to {APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT} MB</Trans>
</p>
</div>
);
};
@@ -0,0 +1,43 @@
import { DEFAULT_CONTENT_STROKE_COLOR } from '@documenso/lib/types/envelope-content-meta';
import {
type TContentLineFormSchema,
useContentSettingsForm,
} from '~/components/general/envelope-editor/content-settings-form-provider';
import {
EditorContentColorField,
EditorContentStrokeStyleField,
EditorContentStrokeWidthField,
} from './editor-content-generic-field-forms';
/**
* Settings form for line contents, bound to the selected content's settings
* form. Only the presentational settings are editable here; geometry (page
* and endpoints) is managed on the canvas.
*/
export const EditorContentLineForm = () => {
const { form, isReady } = useContentSettingsForm<TContentLineFormSchema>();
// Mount the inputs only once the form holds this content's values.
if (!isReady) {
return null;
}
return (
<div>
<fieldset className="flex flex-col gap-2">
<div className="flex w-full flex-row gap-x-4">
<EditorContentStrokeWidthField className="w-full" formControl={form.control} />
<EditorContentStrokeStyleField className="w-full" formControl={form.control} />
</div>
<EditorContentColorField
formControl={form.control}
name="strokeColor"
defaultColor={DEFAULT_CONTENT_STROKE_COLOR}
/>
</fieldset>
</div>
);
};
@@ -0,0 +1,105 @@
import {
DEFAULT_CONTENT_STROKE_COLOR,
EnvelopeContentShapeType,
EnvelopeContentType,
} from '@documenso/lib/types/envelope-content-meta';
import { FormControl, FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Trans } from '@lingui/react/macro';
import { useWatch } from 'react-hook-form';
import { match } from 'ts-pattern';
import {
type TContentShapeFormSchema,
useContentSettingsForm,
} from '~/components/general/envelope-editor/content-settings-form-provider';
import {
EditorContentColorField,
EditorContentOpacityField,
EditorContentStrokeStyleField,
EditorContentStrokeWidthField,
} from './editor-content-generic-field-forms';
/**
* The fill color used when the fill is first enabled.
*
* Todo: Contents
*/
export const DEFAULT_ENABLED_FILL_COLOR = '#e5e7eb';
/**
* Settings form for shape contents, bound to the selected content's settings
* form, with the fields for the content's shape. Only the presentational
* settings are editable here; geometry (page, position and size) is managed
* on the canvas.
*/
export const EditorContentShapeForm = () => {
const { form, content, isReady } = useContentSettingsForm<TContentShapeFormSchema>();
const fillColor = useWatch({ control: form.control, name: 'fillColor' });
const hasFill = Boolean(fillColor);
const onFillToggle = (enabled: boolean) => {
// The fill is represented purely by the fill color, so toggling sets it
// or clears it to `null`.
form.setValue('fillColor', enabled ? DEFAULT_ENABLED_FILL_COLOR : null, {
shouldDirty: true,
shouldValidate: true,
});
};
// Mount the inputs only once the form holds this content's values.
if (!isReady || content?.contentMeta.type !== EnvelopeContentType.SHAPE) {
return null;
}
return (
<div>
{match(content.contentMeta.shape)
.with(EnvelopeContentShapeType.RECTANGLE, () => (
<fieldset className="flex flex-col gap-2">
<div className="flex w-full flex-row gap-x-4">
<EditorContentStrokeWidthField className="w-full" formControl={form.control} />
<EditorContentStrokeStyleField className="w-full" formControl={form.control} />
</div>
<EditorContentColorField
formControl={form.control}
name="strokeColor"
label={<Trans>Border Color</Trans>}
defaultColor={DEFAULT_CONTENT_STROKE_COLOR}
/>
<FormItem className="mt-1 flex flex-row items-center justify-between">
<FormLabel>
<Trans>Fill</Trans>
</FormLabel>
<FormControl>
<Switch data-testid="content-form-fill" checked={hasFill} onCheckedChange={onFillToggle} />
</FormControl>
</FormItem>
{hasFill && (
<>
<EditorContentColorField
formControl={form.control}
name="fillColor"
label={<Trans>Fill Color</Trans>}
defaultColor={DEFAULT_ENABLED_FILL_COLOR}
/>
<EditorContentOpacityField
formControl={form.control}
name="fillOpacity"
label={<Trans>Fill Opacity</Trans>}
/>
</>
)}
</fieldset>
))
.exhaustive()}
</div>
);
};
@@ -0,0 +1,78 @@
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { Trans, useLingui } from '@lingui/react/macro';
import {
type TContentTextFormSchema,
useContentSettingsForm,
} from '~/components/general/envelope-editor/content-settings-form-provider';
import { EditorContentColorField } from './editor-content-generic-field-forms';
import {
EditorGenericFontSizeField,
EditorGenericLetterSpacingField,
EditorGenericLineHeightField,
EditorGenericTextAlignField,
EditorGenericVerticalAlignField,
} from './editor-field-generic-field-forms';
/**
* Settings form for text contents, bound to the selected content's settings
* form. Only the presentational settings are editable here; geometry (page,
* position and size) is managed on the canvas.
*/
export const EditorContentTextForm = () => {
const { t } = useLingui();
const { form, isReady } = useContentSettingsForm<TContentTextFormSchema>();
// Mount the inputs only once the form holds this content's values.
if (!isReady) {
return null;
}
return (
<div>
<fieldset className="flex flex-col gap-2">
<FormField
control={form.control}
name="text"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Text</Trans>
</FormLabel>
<FormControl>
<Textarea
data-testid="content-form-text"
className="h-auto"
placeholder={t`Add text to the document`}
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex w-full flex-row gap-x-4">
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<EditorContentColorField className="w-full" formControl={form.control} />
</div>
<div className="flex w-full flex-row gap-x-4">
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
<EditorGenericVerticalAlignField className="w-full" formControl={form.control} />
</div>
<div className="flex w-full flex-row gap-x-4">
<EditorGenericLineHeightField className="w-full" formControl={form.control} />
<EditorGenericLetterSpacingField className="w-full" formControl={form.control} />
</div>
</fieldset>
</div>
);
};
@@ -15,7 +15,7 @@ import { type Control, useFormContext } from 'react-hook-form';
// Can't seem to get the non-any type to work with correct types.
// Eg Control<{ fontSize?: number } doesn't seem to work when there are required items.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: See above
type FormControlType = Control<any>;
export const EditorGenericFontSizeField = ({
@@ -1,282 +0,0 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isTwoFactorGracePeriodReduction } from '@documenso/lib/utils/two-factor';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZTwoFactorEnforcementFormSchema = z.object({
twoFactorRequired: z.boolean(),
twoFactorGracePeriodDays: z.coerce.number().int().min(0).max(365),
acknowledgeGracePeriodReduction: z.boolean(),
});
type TTwoFactorEnforcementFormSchema = z.infer<typeof ZTwoFactorEnforcementFormSchema>;
/**
* Organisation 2FA enforcement settings (require toggle + grace period).
*
* Rendered only for MANAGE_ORGANISATION_SECURITY holders (ADMIN). When
* instance-wide enforcement is active the fields are shown disabled — not
* hidden — with a banner explaining that the instance policy takes
* precedence, so a configured organisation policy stays visible instead of
* resurfacing already-expired later.
*/
export const OrganisationTwoFactorEnforcementForm = () => {
const { _, i18n } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const { twoFactorEnforcement: instanceTwoFactorEnforcement } = useSession();
const isInstanceEnforcementActive = instanceTwoFactorEnforcement.required;
const { data: organisationWithSettings, isLoading } = trpc.organisation.get.useQuery({
organisationReference: organisation.url,
});
const utils = trpc.useUtils();
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
const settings = organisationWithSettings?.organisationGlobalSettings;
const form = useForm<TTwoFactorEnforcementFormSchema>({
values: {
twoFactorRequired: settings?.twoFactorRequired ?? false,
twoFactorGracePeriodDays: settings?.twoFactorGracePeriodDays ?? 7,
acknowledgeGracePeriodReduction: false,
},
resolver: zodResolver(ZTwoFactorEnforcementFormSchema),
});
const watchedValues = form.watch();
// Client-side mirror of the server's grace-reduction detection so we can
// surface the acknowledgement checkbox before submitting.
const isGraceReduction =
settings !== undefined &&
isTwoFactorGracePeriodReduction({
previous: settings.twoFactorRequired
? {
anchors: [settings.twoFactorEnforcedFrom],
gracePeriodDays: settings.twoFactorGracePeriodDays,
}
: null,
next: watchedValues.twoFactorRequired
? {
anchors: [settings.twoFactorRequired ? settings.twoFactorEnforcedFrom : new Date()],
gracePeriodDays: watchedValues.twoFactorGracePeriodDays,
}
: null,
now: new Date(),
});
const onSubmit = async (data: TTwoFactorEnforcementFormSchema) => {
try {
await updateOrganisationSettings({
organisationId: organisation.id,
acknowledgeGracePeriodReduction: data.acknowledgeGracePeriodReduction,
data: {
twoFactorRequired: data.twoFactorRequired,
twoFactorGracePeriodDays: data.twoFactorGracePeriodDays,
},
});
await utils.organisation.get.invalidate();
toast({
title: _(msg`Two-factor enforcement settings updated`),
});
form.setValue('acknowledgeGracePeriodReduction', false);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.TWO_FACTOR_REQUIRED) {
toast({
title: _(msg`Two-factor authentication required`),
description: _(
msg`You must have two-factor authentication enabled and verified on this session before requiring it for the organisation.`,
),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to update the two-factor enforcement settings. Please try again.`),
variant: 'destructive',
});
}
};
if (isLoading || !settings) {
return (
<div className="flex justify-center rounded-lg border py-16">
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
disabled={form.formState.isSubmitting || isInstanceEnforcementActive}
className="flex flex-col gap-y-4"
>
{isInstanceEnforcementActive && (
<Alert variant="neutral">
<AlertTitle>
<Trans>Instance policy takes precedence</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Two-factor authentication is enforced instance-wide by your administrator, so the organisation policy
below is not editable while the instance policy is active.
</Trans>
</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="twoFactorRequired"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5 pr-4">
<FormLabel>
<Trans>Require two-factor authentication</Trans>
</FormLabel>
<FormDescription>
<Trans>
Members must enable two-factor authentication to access this organisation. Joining is never
blocked — the grace period starts when a member joins.
</Trans>
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="twoFactorGracePeriodDays"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Grace period (days)</Trans>
</FormLabel>
<FormControl>
<Input type="number" min={0} max={365} {...field} />
</FormControl>
<FormDescription>
<Trans>
Number of days a member has to enable two-factor authentication after joining. 0 blocks organisation
access immediately until they enrol.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{settings.twoFactorRequired && settings.twoFactorEnforcedFrom && (
<p className="text-muted-foreground text-sm">
<Trans>
Enforcement has been active since {i18n.date(settings.twoFactorEnforcedFrom, { dateStyle: 'long' })}.
</Trans>
</p>
)}
<Alert variant="neutral">
<AlertDescription>
<ul className="list-disc space-y-1 pl-4">
<li>
<Trans>
API tokens are exempt: tokens minted before a member's deadline keep working after it. Blocked
members cannot mint new tokens.
</Trans>
</li>
<li>
<Trans>
Members who have not yet complied still occupy a seat and count towards your member limit.
</Trans>
</li>
</ul>
</AlertDescription>
</Alert>
{isGraceReduction && (
<Alert variant="warning">
<AlertTitle>
<Trans>This change reduces an active grace period</Trans>
</AlertTitle>
<AlertDescription className="flex flex-col gap-y-3">
<Trans>
Members who have not yet enabled two-factor authentication will have less time to comply — possibly
none, which blocks their organisation access immediately.
</Trans>
<FormField
control={form.control}
name="acknowledgeGracePeriodReduction"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-x-2 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
</FormControl>
<FormLabel className="font-normal">
<Trans>I understand that this reduces the remaining grace period for members</Trans>
</FormLabel>
</FormItem>
)}
/>
</AlertDescription>
</Alert>
)}
<div className="flex justify-end">
<Button
type="submit"
loading={form.formState.isSubmitting}
disabled={isGraceReduction && !watchedValues.acknowledgeGracePeriodReduction}
>
<Trans>Update</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
);
};
@@ -54,6 +54,8 @@ export const SubscriptionClaimForm = ({
memberCount: subscriptionClaim.memberCount,
envelopeItemCount: subscriptionClaim.envelopeItemCount,
recipientCount: subscriptionClaim.recipientCount,
envelopeContentCount: subscriptionClaim.envelopeContentCount,
envelopeContentImageCount: subscriptionClaim.envelopeContentImageCount,
flags: subscriptionClaim.flags,
documentRateLimits: subscriptionClaim.documentRateLimits,
documentQuota: subscriptionClaim.documentQuota,
@@ -185,6 +187,54 @@ export const SubscriptionClaimForm = ({
)}
/>
<FormField
control={form.control}
name="envelopeContentCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Content Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of contents per envelope allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="envelopeContentImageCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Content Image Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of image contents per envelope allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel>
<Trans>Feature Flags</Trans>
@@ -1,332 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isTwoFactorGracePeriodReduction } from '@documenso/lib/utils/two-factor';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { LoaderIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZTwoFactorEnforcementFormSchema = z.object({
enabled: z.boolean(),
gracePeriodDays: z.coerce.number().int().min(0).max(365),
acknowledgeGracePeriodReduction: z.boolean(),
});
type TTwoFactorEnforcementFormSchema = z.infer<typeof ZTwoFactorEnforcementFormSchema>;
/**
* Instance-wide 2FA enforcement settings for the admin site-settings page.
*
* License-gated states:
*
* - Licensed: full form.
* - Unlicensed + unconfigured: section visible but disabled with a "requires
* license" note.
* - Unlicensed + configured ("configured but inactive", e.g. license lapsed):
* stored values shown read-only with a disable-only affordance — the only
* permitted unlicensed update is turning the stored policy off.
*/
export const AdminTwoFactorEnforcementSection = () => {
const { _, i18n } = useLingui();
const { toast } = useToast();
const { data: enforcementConfig, isLoading } = trpc.admin.getTwoFactorEnforcement.useQuery();
const utils = trpc.useUtils();
const { mutateAsync: updateTwoFactorEnforcement, isPending: isUpdatePending } =
trpc.admin.updateTwoFactorEnforcement.useMutation();
const form = useForm<TTwoFactorEnforcementFormSchema>({
values: {
enabled: enforcementConfig?.enabled ?? false,
gracePeriodDays: enforcementConfig?.gracePeriodDays ?? 7,
acknowledgeGracePeriodReduction: false,
},
resolver: zodResolver(ZTwoFactorEnforcementFormSchema),
});
const watchedValues = form.watch();
const isLicensed = enforcementConfig?.isLicensed ?? false;
const isConfiguredButInactive = !isLicensed && (enforcementConfig?.enabled ?? false);
// Client-side mirror of the server's grace-reduction detection so we can
// surface the acknowledgement checkbox before submitting. The server resets
// `enforcedFrom` to now on an off→on transition, hence the `new Date()`
// anchor when the stored policy is currently disabled.
const isGraceReduction =
enforcementConfig !== undefined &&
isTwoFactorGracePeriodReduction({
previous: enforcementConfig.enabled
? {
anchors: [enforcementConfig.enforcedFrom ? new Date(enforcementConfig.enforcedFrom) : null],
gracePeriodDays: enforcementConfig.gracePeriodDays,
}
: null,
next: watchedValues.enabled
? {
anchors: [
enforcementConfig.enabled && enforcementConfig.enforcedFrom
? new Date(enforcementConfig.enforcedFrom)
: new Date(),
],
gracePeriodDays: watchedValues.gracePeriodDays,
}
: null,
now: new Date(),
});
const onUpdate = async (data: {
enabled: boolean;
gracePeriodDays: number;
acknowledgeGracePeriodReduction?: boolean;
}) => {
try {
await updateTwoFactorEnforcement(data);
await utils.admin.getTwoFactorEnforcement.invalidate();
toast({
title: _(msg`Two-factor enforcement settings updated`),
});
form.setValue('acknowledgeGracePeriodReduction', false);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.TWO_FACTOR_REQUIRED) {
toast({
title: _(msg`Two-factor authentication required`),
description: _(
msg`Enable two-factor authentication on your own account and verify it on this session before requiring it for the instance.`,
),
variant: 'destructive',
});
return;
}
if (error.code === AppErrorCode.FORBIDDEN) {
toast({
title: _(msg`License required`),
description: _(
msg`Your license does not include instance-wide two-factor enforcement. Only disabling the stored configuration is permitted.`,
),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to update the two-factor enforcement settings. Please try again.`),
variant: 'destructive',
});
}
};
const onSubmit = async (data: TTwoFactorEnforcementFormSchema) => {
await onUpdate(data);
};
// Disable-only affordance for the "configured but inactive" state: submits
// an enabled→disabled transition with the stored values unchanged, which is
// the only unlicensed update the server accepts.
const onDisableOnly = async () => {
if (!enforcementConfig) {
return;
}
await onUpdate({
enabled: false,
gracePeriodDays: enforcementConfig.gracePeriodDays,
});
};
return (
<div>
<h2 className="font-semibold">
<Trans>Instance Two-Factor Enforcement</Trans>
</h2>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
Require every user on this instance, including administrators, to enable two-factor authentication within a
grace period.
</Trans>
</p>
{isLoading || !enforcementConfig ? (
<div className="mt-4 flex justify-center rounded-lg border py-16">
<LoaderIcon className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={form.formState.isSubmitting || !isLicensed} className="mt-4 flex flex-col gap-y-4">
{!isLicensed && !isConfiguredButInactive && (
<Alert variant="neutral">
<AlertTitle>
<Trans>Requires a license</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Instance-wide two-factor enforcement requires a Documenso license that includes this feature.
</Trans>
</AlertDescription>
</Alert>
)}
{isConfiguredButInactive && (
<Alert variant="warning">
<AlertTitle>
<Trans>Configured but inactive</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Two-factor enforcement is configured but your current license does not include this feature, so it
is not being enforced. You can disable the stored configuration below; changing it requires a
license.
</Trans>
</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5 pr-4">
<FormLabel>
<Trans>Require two-factor authentication</Trans>
</FormLabel>
<FormDescription>
<Trans>
Users who have not enabled two-factor authentication by their deadline are redirected to a
forced enrolment page before they can continue.
</Trans>
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="gracePeriodDays"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Grace period (days)</Trans>
</FormLabel>
<FormControl>
<Input type="number" min={0} max={365} {...field} />
</FormControl>
<FormDescription>
<Trans>
Number of days a user has to enable two-factor authentication. 0 forces enrolment immediately
after signing up or signing in.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{enforcementConfig.isActive && enforcementConfig.enforcedFrom && (
<p className="text-muted-foreground text-sm">
<Trans>
Enforcement has been active since{' '}
{i18n.date(new Date(enforcementConfig.enforcedFrom), { dateStyle: 'long' })}.
</Trans>
</p>
)}
<Alert variant="neutral">
<AlertDescription>
<Trans>
API tokens are exempt: tokens minted before a user's deadline keep working after it. Blocked users
cannot mint new tokens.
</Trans>
</AlertDescription>
</Alert>
{isGraceReduction && (
<Alert variant="warning">
<AlertTitle>
<Trans>This change reduces an active grace period</Trans>
</AlertTitle>
<AlertDescription className="flex flex-col gap-y-3">
<Trans>
Users who have not yet enabled two-factor authentication will have less time to comply — possibly
none, which blocks their access immediately.
</Trans>
<FormField
control={form.control}
name="acknowledgeGracePeriodReduction"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-x-2 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
</FormControl>
<FormLabel className="font-normal">
<Trans>I understand that this reduces the remaining grace period for users</Trans>
</FormLabel>
</FormItem>
)}
/>
</AlertDescription>
</Alert>
)}
<div className="flex justify-end">
<Button
type="submit"
loading={form.formState.isSubmitting}
disabled={isGraceReduction && !watchedValues.acknowledgeGracePeriodReduction}
>
<Trans>Update</Trans>
</Button>
</div>
</fieldset>
{isConfiguredButInactive && (
<div className="mt-4 flex justify-end">
<Button type="button" variant="destructive" loading={isUpdatePending} onClick={onDisableOnly}>
<Trans>Disable enforcement</Trans>
</Button>
</div>
)}
</form>
</Form>
)}
</div>
);
};
@@ -0,0 +1,371 @@
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Input } from '@documenso/ui/primitives/input';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@documenso/ui/primitives/table';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { LucideIcon } from 'lucide-react';
import { SearchIcon, UsersIcon } from 'lucide-react';
import type { MouseEvent, ReactNode } from 'react';
import { useState } from 'react';
import { Link, useNavigate } from 'react-router';
import type { AnalyticsQueryResult } from '~/utils/analytics';
import { formatRelativeDate } from '~/utils/analytics';
import { AnalyticsQueryError } from './analytics-query-error';
/** A single scope-agnostic row: a team member on the team page, a team on the organisation page. */
export type AnalyticsActivityRow = {
key: string | number;
avatar: {
imageId: string | null;
fallback: string;
};
title: string;
subtitle?: string | null;
sent: number;
completed: number;
pending: number;
/** 0-100, null when nothing was sent. */
completionRate: number | null;
lastActiveAt: Date | null;
/** When set the whole row navigates here and the title becomes a link. */
href?: string;
};
export type AnalyticsActivityTableCardProps = {
query: AnalyticsQueryResult<unknown>;
/** Rows derived from `query.data`; empty while loading. */
rows: AnalyticsActivityRow[];
/** Identifies the current window (preset or custom span), so "Show all" resets whenever it changes. */
rangeKey: string;
title: ReactNode;
description: ReactNode;
/** Header of the first column, e.g. "Member". */
columnLabel: ReactNode;
/** Rendered next to the title once rows are loaded, e.g. "3 members · 2 active this period". */
renderSummary: (count: number, activeCount: number) => ReactNode;
/** Rendered next to "Show all", e.g. "Showing 8 of 9 members". */
renderShowing: (visibleCount: number, totalCount: number) => ReactNode;
emptyLabel: ReactNode;
emptyIcon?: LucideIcon;
/** Placeholder for the search input, e.g. "Search members". */
searchPlaceholder: string;
/** Rendered when the search matches nothing, e.g. "No members match your search". */
noSearchResultsLabel: ReactNode;
/** Builds the `analytics-{prefix}-*` test ids, e.g. `member` or `team`. */
testIdPrefix: string;
className?: string;
};
export const AnalyticsActivityTableCard = ({
query,
rows,
rangeKey,
title,
description,
columnLabel,
renderSummary,
renderShowing,
emptyLabel,
emptyIcon: EmptyIcon = UsersIcon,
searchPlaceholder,
noSearchResultsLabel,
testIdPrefix,
className,
}: AnalyticsActivityTableCardProps) => {
const { i18n } = useLingui();
// Tracks which window "Show all" was pressed for, so it resets whenever the window changes.
const [expandedRangeKey, setExpandedRangeKey] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const isExpanded = expandedRangeKey === rangeKey;
const { data, isLoading, isError, refetch } = query;
const activeCount = rows.filter((row) => row.sent > 0).length;
const normalisedSearchTerm = searchTerm.trim().toLowerCase();
const isSearching = normalisedSearchTerm.length > 0;
// Search always shows every match; the preview limit only applies to the unfiltered list.
const filteredRows = isSearching ? rows.filter((row) => matchesSearch(row, normalisedSearchTerm)) : rows;
const visibleRows = isExpanded || isSearching ? filteredRows : filteredRows.slice(0, ROW_PREVIEW_LIMIT);
const hasHiddenRows = filteredRows.length > visibleRows.length;
const testId = (suffix: string) => `analytics-${testIdPrefix}-${suffix}`;
return (
<Card className={className} data-testid={testId('activity')}>
<CardHeader className="gap-4 space-y-0 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col space-y-1.5">
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</div>
{data !== undefined && rows.length > 0 && (
<p className="shrink-0 text-muted-foreground text-sm tabular-nums" data-testid={testId('summary')}>
{renderSummary(rows.length, activeCount)}
</p>
)}
</CardHeader>
<CardContent>
{isError ? (
<AnalyticsQueryError onRetry={refetch} />
) : isLoading || data === undefined ? (
<ul className="flex flex-col gap-y-3">
{Array.from({ length: 4 }, (_, index) => (
<li key={index} className="flex items-center gap-x-3">
<Skeleton className="h-9 w-9 shrink-0 rounded-full" />
<div className="flex flex-1 flex-col gap-y-1.5">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3 w-1/4" />
</div>
<Skeleton className="h-4 w-10" />
<Skeleton className="hidden h-4 w-10 md:block" />
<Skeleton className="hidden h-4 w-10 md:block" />
<Skeleton className="h-4 w-28" />
<Skeleton className="hidden h-4 w-20 md:block" />
</li>
))}
</ul>
) : rows.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-y-3 py-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<EmptyIcon className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
</div>
<p className="max-w-sm text-muted-foreground text-sm">{emptyLabel}</p>
</div>
) : (
<div className="flex flex-col gap-y-3">
<div className="relative sm:max-w-xs">
<SearchIcon
className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
type="search"
className="pl-9"
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
data-testid={testId('search')}
/>
</div>
{filteredRows.length === 0 ? (
<p className="py-8 text-center text-muted-foreground text-sm" data-testid={testId('no-results')}>
{noSearchResultsLabel}
</p>
) : (
<>
{/* Pull the table out to the card edge so the first/last cell gutters (px-6) line up with the header. */}
<div className="-mx-6">
<Table className="[&_td]:px-2 md:[&_td]:px-4 [&_th]:px-2 md:[&_th]:px-4">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className={FIRST_CELL_CLASS}>{columnLabel}</TableHead>
<TableHead className="text-right">
<Trans>Sent</Trans>
</TableHead>
<TableHead className="hidden text-right md:table-cell">
<Trans>Completed</Trans>
</TableHead>
<TableHead className="hidden text-right md:table-cell">
<Trans>Pending</Trans>
</TableHead>
<TableHead className={cn('text-right', LAST_CELL_ON_MOBILE_CLASS)}>
<Trans>Completion rate</Trans>
</TableHead>
<TableHead className={cn('hidden text-right md:table-cell', LAST_CELL_CLASS)}>
<Trans>Last active</Trans>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows.map((row) => (
<ActivityRow key={row.key} row={row} locale={i18n.locale} testIdPrefix={testIdPrefix} />
))}
</TableBody>
</Table>
</div>
{hasHiddenRows && (
<div className="flex items-center justify-between gap-x-4 border-border border-t pt-3">
<p className="text-muted-foreground text-sm" data-testid={testId('showing')}>
{renderShowing(visibleRows.length, filteredRows.length)}
</p>
<Button
variant="ghost"
size="sm"
className="-mr-2"
onClick={() => setExpandedRangeKey(rangeKey)}
data-testid={testId('show-all')}
>
<Trans>Show all</Trans>
</Button>
</div>
)}
</>
)}
</div>
)}
</CardContent>
</Card>
);
};
type ActivityRowProps = {
row: AnalyticsActivityRow;
locale: string;
testIdPrefix: string;
};
const ActivityRow = ({ row, locale, testIdPrefix }: ActivityRowProps) => {
const { _ } = useLingui();
const navigate = useNavigate();
const isActive = row.sent > 0;
const testId = (suffix: string) => `analytics-${testIdPrefix}-${suffix}`;
// Only "Completed" and the rate are emphasised; supporting counts stay muted. Inactive rows are muted throughout.
const primaryNumberClass = cn('text-right tabular-nums', isActive ? 'text-foreground' : 'text-muted-foreground');
const secondaryNumberClass = 'text-right text-muted-foreground tabular-nums';
// role="img" so the aria-label is valid (a bare span has no role that supports it).
const notAvailable = (
<span role="img" aria-label={_(msg`Not available`)}>
—
</span>
);
/**
* The title link is the accessible target; clicking anywhere else on the row
* navigates too. Modifier clicks and clicks on the link itself are left to the
* browser so open-in-new-tab keeps working, and drag-selecting text does not
* navigate.
*/
const handleRowClick = (event: MouseEvent<HTMLTableRowElement>) => {
if (!row.href || event.defaultPrevented || event.button !== 0) {
return;
}
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
if (event.target instanceof Element && event.target.closest('a')) {
return;
}
if (window.getSelection()?.toString()) {
return;
}
void navigate(row.href);
};
return (
<TableRow
className={cn(row.href && 'cursor-pointer')}
onClick={handleRowClick}
data-testid={testId('row')}
data-active={isActive ? 'true' : 'false'}
>
{/* w-full + max-w-0 lets this cell absorb the remaining width while still truncating its content. */}
<TableCell truncate={false} className={cn('w-full max-w-0', FIRST_CELL_CLASS)}>
<div className="flex min-w-0 items-center gap-x-3">
<Avatar className="h-9 w-9 shrink-0">
{row.avatar.imageId && <AvatarImage src={formatAvatarUrl(row.avatar.imageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{row.avatar.fallback}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
{row.href ? (
<Link
to={row.href}
className={cn(
'truncate font-medium text-sm hover:underline',
isActive ? 'text-foreground' : 'text-foreground/80',
)}
>
{row.title}
</Link>
) : (
<span className={cn('truncate font-medium text-sm', isActive ? 'text-foreground' : 'text-foreground/80')}>
{row.title}
</span>
)}
{row.subtitle && <span className="truncate text-muted-foreground text-xs">{row.subtitle}</span>}
</div>
</div>
</TableCell>
<TableCell className={secondaryNumberClass} data-testid={testId('sent')}>
{row.sent.toLocaleString(locale)}
</TableCell>
<TableCell className={cn('hidden md:table-cell', primaryNumberClass)} data-testid={testId('completed')}>
{row.completed.toLocaleString(locale)}
</TableCell>
<TableCell className={cn('hidden md:table-cell', secondaryNumberClass)} data-testid={testId('pending')}>
{row.pending.toLocaleString(locale)}
</TableCell>
<TableCell className={cn(primaryNumberClass, LAST_CELL_ON_MOBILE_CLASS)} data-testid={testId('completion-rate')}>
{row.completionRate === null ? (
notAvailable
) : (
<div className="flex items-center justify-end gap-x-2">
<div className="hidden h-1.5 w-16 overflow-hidden rounded-full bg-muted sm:block" aria-hidden="true">
<div className="h-full rounded-full bg-primary" style={{ width: `${row.completionRate}%` }} />
</div>
<span className="w-9 text-right">{Math.round(row.completionRate)}%</span>
</div>
)}
</TableCell>
<TableCell
className={cn('hidden md:table-cell', secondaryNumberClass, LAST_CELL_CLASS)}
data-testid={testId('last-active')}
>
{row.lastActiveAt === null ? notAvailable : formatRelativeDate(row.lastActiveAt, locale)}
</TableCell>
</TableRow>
);
};
const ROW_PREVIEW_LIMIT = 8;
const matchesSearch = (row: AnalyticsActivityRow, term: string) => {
return row.title.toLowerCase().includes(term) || (row.subtitle ?? '').toLowerCase().includes(term);
};
/**
* The table is pulled out to the card edge (-mx-6), so the outer cells get the
* card's px-6 gutter to line up with the header. "Last active" is hidden below
* md, so "Completion rate" takes the right gutter there.
*/
const FIRST_CELL_CLASS = '!pl-6';
const LAST_CELL_CLASS = '!pr-6';
const LAST_CELL_ON_MOBILE_CLASS = '!pr-6 md:!pr-4';
@@ -0,0 +1,203 @@
import type { TGetTeamAnalyticsDocumentsOverTimeResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { BarChart3Icon } from 'lucide-react';
import { DateTime } from 'luxon';
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { AnalyticsQueryResult, AnalyticsRangeValue } from '~/utils/analytics';
import { getAnalyticsDateRangeDays } from '~/utils/analytics';
import { AnalyticsQueryError } from './analytics-query-error';
export type AnalyticsDocumentsOverTimeCardProps = {
range: AnalyticsRangeValue;
query: AnalyticsQueryResult<TGetTeamAnalyticsDocumentsOverTimeResponse>;
className?: string;
};
type Bucket = TGetTeamAnalyticsDocumentsOverTimeResponse['range']['bucket'];
export const AnalyticsDocumentsOverTimeCard = ({ range, query, className }: AnalyticsDocumentsOverTimeCardProps) => {
const { i18n } = useLingui();
const { data, isLoading, isError, refetch } = query;
// The backend decides the bucket, and it must match the points being rendered so
// the tick and tooltip formatting line up. Before data arrives it is guessed from
// the requested range.
const bucket: Bucket = data ? data.range.bucket : guessBucket(range);
const tickInterval = data ? getTickInterval(data.points.length, bucket) : 0;
return (
<Card className={cn('flex flex-col', className)} data-testid="analytics-documents-over-time">
<CardHeader className="flex flex-row items-start justify-between gap-x-4 space-y-0">
<div className="space-y-1.5">
<CardTitle>
<Trans>Documents created</Trans>
</CardTitle>
<CardDescription>{bucket === 'month' ? <Trans>Monthly</Trans> : <Trans>Daily</Trans>}</CardDescription>
</div>
{data && (
<p className="shrink-0 whitespace-nowrap" data-testid="analytics-documents-over-time-total">
<span className="font-semibold text-2xl text-foreground tabular-nums tracking-tight">
{data.total.toLocaleString(i18n.locale)}
</span>{' '}
<span className="text-muted-foreground text-sm">
<Trans>total</Trans>
</span>
</p>
)}
</CardHeader>
{/* flex-1 + justify-center keeps the fixed-height chart vertically level with the status breakdown card. */}
<CardContent className="flex flex-1 flex-col justify-center">
{isError ? (
<AnalyticsQueryError onRetry={refetch} />
) : isLoading || !data ? (
<Skeleton className="w-full" style={{ height: CHART_HEIGHT }} />
) : data.total === 0 ? (
<div
className="flex flex-col items-center justify-center gap-y-3 text-center"
style={{ height: CHART_HEIGHT }}
>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<BarChart3Icon className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
</div>
<p className="text-muted-foreground text-sm">
<Trans>No documents created in this period</Trans>
</p>
</div>
) : (
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
<BarChart data={data.points} margin={{ top: 8, right: 0, bottom: 0, left: 0 }} barCategoryGap="20%">
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis
dataKey="date"
interval={tickInterval}
tickLine={false}
axisLine={false}
tickMargin={8}
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
tickFormatter={(value: string) => formatTickLabel(value, bucket, i18n.locale)}
/>
<YAxis
allowDecimals={false}
tickLine={false}
axisLine={false}
width={36}
tickCount={4}
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
/>
<Tooltip
content={<DocumentsOverTimeTooltip bucket={bucket} locale={i18n.locale} />}
cursor={{ fill: 'hsl(var(--muted-foreground) / 0.08)' }}
/>
<Bar
dataKey="count"
fill="hsl(var(--primary))"
radius={[4, 4, 0, 0]}
maxBarSize={28}
background={{ fill: 'hsl(var(--muted) / 0.5)', radius: 4 }}
isAnimationActive={false}
/>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
};
type DocumentsOverTimeTooltipProps = {
active?: boolean;
payload?: Array<{ payload: { date: string; count: number } }>;
bucket: Bucket;
locale: string;
};
const DocumentsOverTimeTooltip = ({ active, payload, bucket, locale }: DocumentsOverTimeTooltipProps) => {
const point = payload?.[0]?.payload;
if (!active || !point) {
return null;
}
const count = Number(point.count ?? 0);
return (
<div className="rounded-md border border-border bg-popover px-3 py-2 text-popover-foreground text-sm shadow-md">
<p className="text-muted-foreground text-xs">{formatTooltipLabel(point.date, bucket, locale)}</p>
<p className="mt-0.5 font-medium tabular-nums">
<Plural value={count} one="# document" other="# documents" />
</p>
</div>
);
};
const CHART_HEIGHT = 240;
const TARGET_DAILY_TICK_COUNT = 6;
/** Mirrors the backend resolver: custom windows longer than this are bucketed by month. */
const CUSTOM_RANGE_MONTH_BUCKET_THRESHOLD_DAYS = 92;
const guessBucket = (range: AnalyticsRangeValue): Bucket => {
if (range.range === '12m') {
return 'month';
}
if (range.range === 'custom') {
return getAnalyticsDateRangeDays(range.from, range.to) > CUSTOM_RANGE_MONTH_BUCKET_THRESHOLD_DAYS ? 'month' : 'day';
}
return 'day';
};
/**
* Month buckets label every month (12 fit at the lg width) and let recharts drop
* overlapping ones on narrow screens; daily buckets show roughly six evenly spaced labels.
*/
const getTickInterval = (pointCount: number, bucket: Bucket): number | 'preserveStartEnd' => {
if (bucket === 'month') {
return 'preserveStartEnd';
}
if (pointCount <= TARGET_DAILY_TICK_COUNT) {
return 0;
}
return Math.max(0, Math.round(pointCount / TARGET_DAILY_TICK_COUNT) - 1);
};
const formatTickLabel = (date: string, bucket: Bucket, locale: string) => {
const parsed = DateTime.fromISO(date).setLocale(locale);
if (bucket === 'month') {
return parsed.toLocaleString({ month: 'short' });
}
return parsed.toLocaleString({ month: 'short', day: 'numeric' });
};
const formatTooltipLabel = (date: string, bucket: Bucket, locale: string) => {
const parsed = DateTime.fromISO(date).setLocale(locale);
if (bucket === 'month') {
return parsed.toLocaleString({ month: 'long', year: 'numeric' });
}
return parsed.toLocaleString(DateTime.DATE_FULL);
};
@@ -0,0 +1,17 @@
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Trans } from '@lingui/react/macro';
/**
* Shown while the analytics route's `clientLoader` resolves the browser timezone
* during hydration.
*/
export const AnalyticsHydrateFallback = () => {
return (
<div role="status" aria-live="polite" data-testid="analytics-loading">
<SpinnerBox />
<span className="sr-only">
<Trans>Loading analytics</Trans>
</span>
</div>
);
};
@@ -0,0 +1,45 @@
import type { TTeamAnalyticsRange } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { InfoIcon } from 'lucide-react';
import { ANALYTICS_NO_ACTIVITY_LABELS } from '~/utils/analytics';
export type AnalyticsNoActivityAlertProps = {
range: TTeamAnalyticsRange;
/** Invoked when the user asks to widen the range to the last 12 months. */
onShowLastYear: () => void;
};
export const AnalyticsNoActivityAlert = ({ range, onShowLastYear }: AnalyticsNoActivityAlertProps) => {
const { _ } = useLingui();
const canWidenRange = range !== '12m';
return (
<Alert variant="neutral" padding="tight" className="mt-6" data-testid="analytics-no-activity">
<AlertDescription className="flex min-h-9 flex-wrap items-center justify-between gap-x-4 gap-y-2">
<span className="flex items-center gap-x-2">
<InfoIcon className="h-4 w-4 shrink-0" aria-hidden="true" />
<span>
{_(ANALYTICS_NO_ACTIVITY_LABELS[range])}
{canWidenRange && (
<>
{' '}
<Trans>Try a longer range.</Trans>
</>
)}
</span>
</span>
{canWidenRange && (
<Button variant="ghost" size="sm" className="-mr-2" onClick={onShowLastYear}>
<Trans>Show last 12 months</Trans>
</Button>
)}
</AlertDescription>
</Alert>
);
};
@@ -0,0 +1,214 @@
import type { TGetTeamAnalyticsOverviewResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { cn } from '@documenso/ui/lib/utils';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { LucideIcon } from 'lucide-react';
import { ArrowDownRightIcon, ArrowUpRightIcon, CircleCheckIcon, SendIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import type { AnalyticsQueryResult } from '~/utils/analytics';
import { AnalyticsStatCard } from './analytics-stat-card';
/** The part of the overview response shared by the team and organisation procedures. */
export type AnalyticsOverviewData = Pick<TGetTeamAnalyticsOverviewResponse, 'sent' | 'completionRate'>;
/**
* The third card counts the scope's "entities" (team members, organisation teams)
* and how many of them were active in the period.
*/
export type AnalyticsOverviewEntityCard<TData> = {
icon: LucideIcon;
title: ReactNode;
/** Applied to the value element, e.g. `analytics-members`. */
testId: string;
select: (data: TData) => { active: number; total: number };
};
export type AnalyticsOverviewCardsProps<TData extends AnalyticsOverviewData> = {
query: AnalyticsQueryResult<TData>;
entity: AnalyticsOverviewEntityCard<TData>;
};
export const AnalyticsOverviewCards = <TData extends AnalyticsOverviewData>({
query,
entity,
}: AnalyticsOverviewCardsProps<TData>) => {
const { i18n } = useLingui();
const { data, isLoading, isError, refetch } = query;
const entityCounts = data ? entity.select(data) : null;
const formatNumber = (value: number) => value.toLocaleString(i18n.locale);
const sharedProps = {
isLoading: isLoading || !data,
isError,
onRetry: refetch,
};
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<AnalyticsStatCard
{...sharedProps}
icon={SendIcon}
title={<Trans>Documents sent</Trans>}
value={data ? formatNumber(data.sent.current) : null}
badge={data ? <SentDeltaBadge current={data.sent.current} previous={data.sent.previous} /> : null}
description={<Trans>vs. previous period</Trans>}
testId="analytics-sent"
/>
<AnalyticsStatCard
{...sharedProps}
icon={CircleCheckIcon}
title={<Trans>Completion rate</Trans>}
value={data ? formatRate(data.completionRate.rate) : null}
badge={
data ? (
<CompletionRateDeltaBadge rate={data.completionRate.rate} previousRate={data.completionRate.previousRate} />
) : null
}
description={<Trans>of sent documents completed</Trans>}
testId="analytics-completion-rate"
/>
<AnalyticsStatCard
{...sharedProps}
icon={entity.icon}
title={entity.title}
value={entityCounts ? `${formatNumber(entityCounts.active)}/${formatNumber(entityCounts.total)}` : null}
description={
entityCounts ? (
<Trans>
{formatNumber(entityCounts.active)} active · {formatNumber(entityCounts.total - entityCounts.active)}{' '}
inactive
</Trans>
) : null
}
testId={entity.testId}
/>
</div>
);
};
type SentDeltaBadgeProps = {
current: number;
previous: number;
};
const SentDeltaBadge = ({ current, previous }: SentDeltaBadgeProps) => {
if (previous === 0 && current === 0) {
return null;
}
if (previous === 0) {
return (
<DeltaBadge tone="new" testId="analytics-sent-delta">
<Trans>New</Trans>
</DeltaBadge>
);
}
const delta = Math.round(((current - previous) / previous) * 100);
// Percentages off a tiny base (e.g. 1 → 165) are noise; cap the display.
const label =
delta > MAX_DISPLAYED_DELTA_PERCENT ? `>${MAX_DISPLAYED_DELTA_PERCENT}%` : `${formatSignedNumber(delta)}%`;
return (
<DeltaBadge tone={getDeltaTone(delta)} testId="analytics-sent-delta">
{label}
</DeltaBadge>
);
};
type CompletionRateDeltaBadgeProps = {
rate: number | null;
previousRate: number | null;
};
const CompletionRateDeltaBadge = ({ rate, previousRate }: CompletionRateDeltaBadgeProps) => {
if (rate === null || previousRate === null) {
return null;
}
// Compare the rounded values so the delta always agrees with the displayed rate.
const delta = Math.round(rate) - Math.round(previousRate);
return (
<DeltaBadge tone={getDeltaTone(delta)} testId="analytics-completion-rate-delta">
{formatSignedNumber(delta)}%
</DeltaBadge>
);
};
type DeltaTone = 'positive' | 'negative' | 'zero' | 'new';
type DeltaBadgeProps = {
tone: DeltaTone;
testId: string;
children: ReactNode;
};
const DeltaBadge = ({ tone, testId, children }: DeltaBadgeProps) => {
const DeltaIcon = DELTA_TONE_ICONS[tone];
return (
<span
className={cn(
'inline-flex items-center gap-x-0.5 rounded-full px-1.5 py-0.5 font-medium text-xs tabular-nums leading-none',
DELTA_TONE_CLASSES[tone],
)}
data-testid={testId}
>
{DeltaIcon && <DeltaIcon className="-ml-0.5 h-3 w-3" aria-hidden="true" />}
{children}
</span>
);
};
const DELTA_TONE_CLASSES: Record<DeltaTone, string> = {
positive: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
negative: 'bg-red-500/10 text-red-600 dark:text-red-400',
zero: 'bg-muted text-muted-foreground',
new: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
};
const MAX_DISPLAYED_DELTA_PERCENT = 999;
const DELTA_TONE_ICONS: Record<DeltaTone, typeof ArrowUpRightIcon | null> = {
positive: ArrowUpRightIcon,
negative: ArrowDownRightIcon,
zero: null,
new: null,
};
const formatRate = (rate: number | null) => {
if (rate === null) {
return '—';
}
return `${Math.round(rate)}%`;
};
const formatSignedNumber = (value: number) => {
if (value > 0) {
return `+${value}`;
}
return String(value);
};
const getDeltaTone = (delta: number): DeltaTone => {
if (delta > 0) {
return 'positive';
}
if (delta < 0) {
return 'negative';
}
return 'zero';
};
@@ -0,0 +1,67 @@
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { ReactNode } from 'react';
import type { AnalyticsRangeValue } from '~/utils/analytics';
import { ANALYTICS_RANGE_LABELS, formatAnalyticsDateRange } from '~/utils/analytics';
import { AnalyticsRangePicker } from './analytics-range-picker';
export type AnalyticsPageHeaderProps = {
avatarImageId: string | null;
/** The team or organisation name. */
name: string;
range: AnalyticsRangeValue;
onRangeChange: (range: AnalyticsRangeValue) => void;
/** Rendered before the range picker, e.g. a link to a related analytics page. */
actions?: ReactNode;
className?: string;
};
export const AnalyticsPageHeader = ({
avatarImageId,
name,
range,
onRangeChange,
actions,
className,
}: AnalyticsPageHeaderProps) => {
const { _, i18n } = useLingui();
const rangeLabel =
range.range === 'custom'
? formatAnalyticsDateRange(range.from, range.to, i18n.locale)
: _(ANALYTICS_RANGE_LABELS[range.range]);
return (
<div className={cn('flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between', className)}>
<div className="flex flex-row items-center">
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
{avatarImageId && <AvatarImage src={formatAvatarUrl(avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{name.slice(0, 1)}</AvatarFallback>
</Avatar>
<div>
<h2 className="font-semibold text-4xl">
<Trans>Analytics</Trans>
</h2>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>
Usage overview for {name} · {rangeLabel}
</Trans>
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{actions}
<AnalyticsRangePicker value={range} onValueChange={onRangeChange} />
</div>
</div>
);
};
@@ -0,0 +1,37 @@
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
export type AnalyticsQueryErrorProps = {
onRetry: () => Promise<unknown>;
className?: string;
};
export const AnalyticsQueryError = ({ onRetry, className }: AnalyticsQueryErrorProps) => {
const [isRetrying, setIsRetrying] = useState(false);
const handleRetry = async () => {
setIsRetrying(true);
try {
await onRetry();
} finally {
setIsRetrying(false);
}
};
return (
<Alert variant="neutral" padding="tight" className={className} data-testid="analytics-error">
<AlertDescription className="flex flex-wrap items-center justify-between gap-2">
<span>
<Trans>This data could not be loaded.</Trans>
</span>
<Button variant="outline" size="sm" onClick={() => void handleRetry()} loading={isRetrying}>
<Trans>Retry</Trans>
</Button>
</AlertDescription>
</Alert>
);
};
@@ -0,0 +1,264 @@
import { useWindowSize } from '@documenso/lib/client-only/hooks/use-window-size';
import { ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { Button } from '@documenso/ui/primitives/button';
import type { CalendarProps } from '@documenso/ui/primitives/calendar';
import { Calendar } from '@documenso/ui/primitives/calendar';
import { Popover, PopoverAnchor, PopoverContent } from '@documenso/ui/primitives/popover';
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { DateTime } from 'luxon';
import { useRef, useState } from 'react';
import type { AnalyticsRangeValue, TAnalyticsPresetRange } from '~/utils/analytics';
import {
ANALYTICS_PRESET_RANGES,
ANALYTICS_RANGE_LABELS,
formatAnalyticsDate,
formatAnalyticsDateRange,
getAnalyticsDateRangeDays,
} from '~/utils/analytics';
export type AnalyticsRangePickerProps = {
value: AnalyticsRangeValue;
onValueChange: (value: AnalyticsRangeValue) => void;
};
/** The calendar selection while the popover is open; `to` is unset until the second day is picked. */
type DraftRange = {
from: Date | undefined;
to: Date | undefined;
};
/** A single react-day-picker matcher, e.g. `{ after: Date }`. */
type DayMatcher = Exclude<CalendarProps['disabled'], undefined | unknown[]>;
/**
* A preset select with a "Custom range…" item that opens a two month range
* calendar anchored to the select. The custom window is only committed when
* "Apply" is pressed.
*/
export const AnalyticsRangePicker = ({ value, onValueChange }: AnalyticsRangePickerProps) => {
const { _, i18n } = useLingui();
const { width } = useWindowSize();
const triggerRef = useRef<HTMLButtonElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [isPickerOpen, setIsPickerOpen] = useState(false);
const [draft, setDraft] = useState<DraftRange | undefined>();
const numberOfMonths = width >= SM_BREAKPOINT ? 2 : 1;
const today = DateTime.local().startOf('day');
const openPicker = () => {
setDraft(
value.range === 'custom'
? { from: DateTime.fromISO(value.from).toJSDate(), to: DateTime.fromISO(value.to).toJSDate() }
: undefined,
);
setIsPickerOpen(true);
};
const closePicker = () => {
setIsPickerOpen(false);
setDraft(undefined);
};
const handleSelectValueChange = (nextValue: string) => {
if (nextValue === CUSTOM_RANGE_VALUE) {
openPicker();
return;
}
const preset = ANALYTICS_PRESET_RANGES.find((range) => range === nextValue);
if (!preset) {
return;
}
onValueChange({ range: preset });
};
/**
* Picking a day starts a new window unless one end is already pending, in which
* case it completes it. This replaces react-day-picker's default, which extends
* a completed window instead of starting over.
*/
const handleDaySelect = (_nextRange: unknown, day: Date) => {
if (draft?.from && !draft.to) {
setDraft(day < draft.from ? { from: day, to: draft.from } : { from: draft.from, to: day });
return;
}
setDraft({ from: day, to: undefined });
};
const handleApply = () => {
if (!draft?.from || !draft.to) {
return;
}
onValueChange({ range: 'custom', from: formatAnalyticsDate(draft.from), to: formatAnalyticsDate(draft.to) });
closePicker();
};
// Only the last year (plus a day) up to today is selectable.
const earliestDay = today.minus(ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK);
const disabledDays: DayMatcher[] = [{ before: earliestDay.toJSDate() }, { after: today.toJSDate() }];
// Open on the month of the pending window (or today), keeping the current month
// as the right-most one so no fully disabled future month is shown.
const anchorMonth = draft?.from ? DateTime.fromJSDate(draft.from).startOf('month') : today.startOf('month');
const lastVisibleMonth = today.startOf('month').minus({ months: numberOfMonths - 1 });
const defaultMonth = DateTime.min(anchorMonth, lastVisibleMonth).toJSDate();
const draftFrom = draft?.from ? formatAnalyticsDate(draft.from) : null;
const draftTo = draft?.to ? formatAnalyticsDate(draft.to) : null;
const draftDays = draftFrom && draftTo ? getAnalyticsDateRangeDays(draftFrom, draftTo) : 0;
const customLabel =
value.range === 'custom' ? formatAnalyticsDateRange(value.from, value.to, i18n.locale) : undefined;
return (
<Popover
open={isPickerOpen}
onOpenChange={(open) => {
if (!open) {
closePicker();
}
}}
>
{/*
* The select never holds "custom" as its value so choosing "Custom range…" always
* fires a change, letting an active custom window be adjusted. The trigger shows
* the formatted window through the placeholder instead.
*/}
<Select value={value.range === 'custom' ? '' : value.range} onValueChange={handleSelectValueChange}>
<PopoverAnchor asChild>
<SelectTrigger
ref={triggerRef}
className="w-full sm:w-auto sm:min-w-44"
aria-label={_(msg`Date range`)}
data-testid="analytics-range"
>
<SelectValue placeholder={customLabel} />
</SelectTrigger>
</PopoverAnchor>
<SelectContent position="popper">
{ANALYTICS_PRESET_OPTIONS.map(({ value: optionValue, label }) => (
<SelectItem key={optionValue} value={optionValue}>
{_(label)}
</SelectItem>
))}
<SelectSeparator />
<SelectItem value={CUSTOM_RANGE_VALUE} data-testid="analytics-range-custom">
<Trans>Custom range…</Trans>
</SelectItem>
</SelectContent>
</Select>
<PopoverContent
ref={contentRef}
align="end"
className="w-auto p-0"
// The select refocuses its trigger (asynchronously) as it closes, which
// would otherwise dismiss the popover that has just opened and strand
// keyboard focus outside it. Pointer interaction with the trigger still
// dismisses the popover so the select can be reopened.
onFocusOutside={(event) => {
if (!(event.target instanceof Node) || !triggerRef.current?.contains(event.target)) {
return;
}
event.preventDefault();
const content = contentRef.current;
const firstTabbable = content?.querySelector<HTMLElement>(TABBABLE_SELECTOR);
(firstTabbable ?? content)?.focus();
}}
// There is no popover trigger element, so hand focus back to the select.
onCloseAutoFocus={(event) => {
event.preventDefault();
triggerRef.current?.focus();
}}
>
<div data-testid="analytics-range-calendar">
<Calendar
mode="range"
selected={draft}
onSelect={handleDaySelect}
numberOfMonths={numberOfMonths}
// Adjacent months would otherwise show the same days twice.
showOutsideDays={false}
defaultMonth={defaultMonth}
fromDate={earliestDay.toJSDate()}
toDate={today.toJSDate()}
disabled={disabledDays}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-border border-t px-3 py-2">
<p className="text-muted-foreground text-sm" aria-live="polite">
{draftFrom && draftTo ? (
<>
{formatAnalyticsDateRange(draftFrom, draftTo, i18n.locale)} ·{' '}
<Plural value={draftDays} one="# day" other="# days" />
</>
) : draftFrom ? (
<Trans>Pick an end date</Trans>
) : (
<Trans>Pick a start date</Trans>
)}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="secondary" size="sm" onClick={closePicker}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
size="sm"
onClick={handleApply}
disabled={!draftFrom || !draftTo}
data-testid="analytics-range-apply"
>
<Trans>Apply</Trans>
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
};
const CUSTOM_RANGE_VALUE = 'custom';
/** Tailwind `sm` breakpoint; two months are shown from here up. */
const SM_BREAKPOINT = 640;
/** First element the popover should focus: the calendar's month navigation, then the days. */
const TABBABLE_SELECTOR = 'button:not([disabled]):not([tabindex="-1"]), [tabindex="0"]';
const ANALYTICS_PRESET_OPTIONS = ANALYTICS_PRESET_RANGES.map((value: TAnalyticsPresetRange) => ({
value,
label: ANALYTICS_RANGE_LABELS[value],
}));
@@ -0,0 +1,63 @@
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import type { LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { AnalyticsQueryError } from './analytics-query-error';
export type AnalyticsStatCardProps = {
icon: LucideIcon;
title: ReactNode;
value: ReactNode;
description: ReactNode;
badge?: ReactNode;
isLoading: boolean;
isError: boolean;
onRetry: () => Promise<unknown>;
testId: string;
};
export const AnalyticsStatCard = ({
icon: Icon,
title,
value,
description,
badge,
isLoading,
isError,
onRetry,
testId,
}: AnalyticsStatCardProps) => {
return (
<Card>
<CardContent className="flex flex-col p-5">
<div className="flex items-center justify-between gap-x-3">
<h3 className="font-medium text-muted-foreground text-sm">{title}</h3>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
</div>
{isError ? (
<AnalyticsQueryError onRetry={onRetry} className="mt-3" />
) : isLoading ? (
<div className="mt-3 flex flex-col gap-y-2">
<Skeleton className="h-9 w-24" />
<Skeleton className="h-3.5 w-32" />
</div>
) : (
<>
<div className="mt-3 flex flex-wrap items-baseline gap-x-2 gap-y-1">
<p className="font-semibold text-3xl text-foreground tabular-nums tracking-tight" data-testid={testId}>
{value}
</p>
{badge}
</div>
<p className="mt-1 text-muted-foreground text-xs">{description}</p>
</>
)}
</CardContent>
</Card>
);
};
@@ -0,0 +1,198 @@
import type { TGetTeamAnalyticsStatusBreakdownResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { AnalyticsQueryResult } from '~/utils/analytics';
import { AnalyticsQueryError } from './analytics-query-error';
export type AnalyticsStatusBreakdownCardProps = {
query: AnalyticsQueryResult<TGetTeamAnalyticsStatusBreakdownResponse>;
className?: string;
};
export const AnalyticsStatusBreakdownCard = ({ query, className }: AnalyticsStatusBreakdownCardProps) => {
const { _, i18n } = useLingui();
const { data, isLoading, isError, refetch } = query;
const rows = data ? allocatePercentages(STATUS_ROWS.map((row) => ({ ...row, count: data[row.key] }))) : [];
return (
<Card className={cn('flex flex-col', className)} data-testid="analytics-status-breakdown">
<CardHeader className="flex flex-row items-start justify-between gap-x-4 space-y-0">
<div className="space-y-1.5">
<CardTitle>
<Trans>Status breakdown</Trans>
</CardTitle>
<CardDescription>
<Trans>Documents created in this period</Trans>
</CardDescription>
</div>
{data && (
<p className="shrink-0 whitespace-nowrap">
<span className="font-semibold text-2xl text-foreground tabular-nums tracking-tight">
{data.total.toLocaleString(i18n.locale)}
</span>{' '}
<span className="text-muted-foreground text-sm">
<Trans>total</Trans>
</span>
</p>
)}
</CardHeader>
<CardContent className="flex flex-1 flex-col">
{isError ? (
<AnalyticsQueryError onRetry={refetch} />
) : isLoading || !data ? (
<div className="flex flex-col gap-y-4">
<Skeleton className="h-2.5 w-full rounded-full" />
<div className="flex flex-col gap-y-2">
{STATUS_ROWS.slice(0, 3).map((row) => (
<Skeleton key={row.key} className="h-5 w-full" />
))}
</div>
</div>
) : data.total === 0 ? (
<div className="flex flex-1 flex-col gap-y-4">
<StatusBar segments={[]} label={_(msg`No documents in this period`)} />
<p className="flex flex-1 items-center justify-center text-center text-muted-foreground text-sm">
<Trans>No documents in this period</Trans>
</p>
</div>
) : (
<div className="flex flex-col gap-y-2">
<StatusBar segments={rows} label={_(msg`Document status distribution`)} />
<ul className="flex flex-col divide-y divide-border">
{rows.map((row) => (
<li key={row.key} className="flex items-center justify-between gap-x-3 py-2.5 text-sm">
<div className="flex min-w-0 items-center gap-x-2">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: row.color }}
aria-hidden="true"
/>
<span className="truncate text-foreground">{_(row.label)}</span>
</div>
<div className="flex shrink-0 items-baseline gap-x-2 tabular-nums">
<span className="font-medium text-foreground" data-testid={`analytics-status-${row.key}`}>
{row.count.toLocaleString(i18n.locale)}
</span>
<span className="w-10 text-right text-muted-foreground">{row.percent}%</span>
</div>
</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
);
};
type StatusBarProps = {
segments: Array<{ key: string; percent: number; color: string }>;
label: string;
};
/**
* Stacked horizontal bar. Segment widths come from the largest-remainder
* percentages so they always add up to the full width; an empty list renders
* the muted track on its own.
*/
const StatusBar = ({ segments, label }: StatusBarProps) => {
return (
<div className="flex h-2.5 w-full gap-px overflow-hidden rounded-full bg-muted" role="img" aria-label={label}>
{segments.map((segment) => (
<div
key={segment.key}
className="h-full"
style={{ width: `${segment.percent}%`, backgroundColor: segment.color }}
/>
))}
</div>
);
};
type StatusKey = 'completed' | 'pending' | 'draft' | 'rejected' | 'cancelled';
type StatusRow = {
key: StatusKey;
label: MessageDescriptor;
color: string;
};
/**
* Single source of truth for status colours so the bar and the legend cannot drift.
*/
const STATUS_ROWS: StatusRow[] = [
{ key: 'completed', label: msg`Completed`, color: 'hsl(var(--primary))' },
{ key: 'pending', label: msg`Pending`, color: '#f59e0b' },
{ key: 'rejected', label: msg`Rejected`, color: '#ef4444' },
{ key: 'cancelled', label: msg`Cancelled`, color: '#f97316' },
{ key: 'draft', label: msg`Draft`, color: 'hsl(var(--muted-foreground) / 0.35)' },
];
/**
* Assign integer percentages to the non-zero rows using largest-remainder
* allocation so the values always sum to exactly 100, with every non-zero row
* shown as at least 1%.
*/
const allocatePercentages = <T extends { count: number }>(rows: T[]): Array<T & { percent: number }> => {
const visibleRows = rows.filter((row) => row.count > 0);
const total = visibleRows.reduce((sum, row) => sum + row.count, 0);
if (total === 0) {
return [];
}
const allocations = visibleRows.map((row, index) => {
const exact = (row.count / total) * 100;
const floored = Math.floor(exact);
return { index, percent: floored, remainder: exact - floored };
});
let remaining = 100 - allocations.reduce((sum, allocation) => sum + allocation.percent, 0);
const byRemainder = [...allocations].sort((a, b) => b.remainder - a.remainder || a.index - b.index);
for (const allocation of byRemainder) {
if (remaining <= 0) {
break;
}
allocation.percent += 1;
remaining -= 1;
}
// Every non-zero row must display at least 1%; take the difference from the largest rows.
const byPercentDesc = [...allocations].sort((a, b) => b.percent - a.percent || a.index - b.index);
for (const allocation of allocations) {
if (allocation.percent > 0) {
continue;
}
allocation.percent = 1;
const donor = byPercentDesc.find((candidate) => candidate !== allocation && candidate.percent > 1);
if (donor) {
donor.percent -= 1;
}
}
return visibleRows.map((row, index) => ({ ...row, percent: allocations[index].percent }));
};
@@ -0,0 +1,145 @@
import type { TGetTeamAnalyticsTemplateUsageResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { FileTextIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { Link } from 'react-router';
import type { AnalyticsQueryResult } from '~/utils/analytics';
import { formatRelativeDate } from '~/utils/analytics';
import { AnalyticsQueryError } from './analytics-query-error';
/** The template shape shared by the team and organisation procedures. */
export type AnalyticsTemplate = TGetTeamAnalyticsTemplateUsageResponse['templates'][number];
export type AnalyticsTemplateUsageCardProps<TTemplate extends AnalyticsTemplate> = {
query: AnalyticsQueryResult<{ templates: TTemplate[] }>;
/** Where the template title links to. Return null to render a plain title. */
getTemplateHref: (template: TTemplate) => string | null;
/** Extra meta shown before the "Updated ..." label, e.g. the owning team name. */
renderTemplateMeta?: (template: TTemplate) => ReactNode;
/** Link for the "View templates" button in the empty state. Omitted when there is no single templates page. */
templatesHref?: string;
className?: string;
};
export const AnalyticsTemplateUsageCard = <TTemplate extends AnalyticsTemplate>({
query,
getTemplateHref,
renderTemplateMeta,
templatesHref,
className,
}: AnalyticsTemplateUsageCardProps<TTemplate>) => {
const { i18n } = useLingui();
const { data, isLoading, isError, refetch } = query;
return (
<Card className={className} data-testid="analytics-template-usage">
<CardHeader>
<CardTitle>
<Trans>Template usage</Trans>
</CardTitle>
<CardDescription>
<Trans>Documents created from templates</Trans>
</CardDescription>
</CardHeader>
<CardContent>
{isError ? (
<AnalyticsQueryError onRetry={refetch} />
) : isLoading || !data ? (
<ul className="flex flex-col gap-y-3">
{Array.from({ length: 3 }, (_, index) => (
<li key={index} className="flex items-center gap-x-3">
<Skeleton className="h-4 w-5" />
<Skeleton className="h-9 w-9 shrink-0 rounded-md" />
<div className="flex flex-1 flex-col gap-y-1.5">
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-3 w-1/4" />
</div>
<Skeleton className="h-4 w-16" />
</li>
))}
</ul>
) : data.templates.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-y-3 py-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<FileTextIcon className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
</div>
<p className="max-w-sm text-muted-foreground text-sm">
<Trans>No documents were created from templates in this period</Trans>
</p>
{templatesHref && (
<Button variant="outline" size="sm" asChild>
<Link to={templatesHref}>
<Trans>View templates</Trans>
</Link>
</Button>
)}
</div>
) : (
<ol className="flex flex-col divide-y divide-border">
{data.templates.map((template, index) => {
const href = template.title === null ? null : getTemplateHref(template);
const meta = renderTemplateMeta?.(template);
return (
<li
key={template.id}
className="flex items-center gap-x-3 py-3 first:pt-0 last:pb-0"
data-testid="analytics-template-row"
>
<span className="w-5 shrink-0 text-muted-foreground text-xs tabular-nums" aria-hidden="true">
{index + 1}
</span>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted">
<FileTextIcon className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</div>
<div className="flex min-w-0 flex-1 flex-col">
{template.title === null ? (
<span className="truncate text-muted-foreground text-sm">
<Trans>Unavailable template</Trans>
</span>
) : href !== null ? (
<Link to={href} className="truncate font-medium text-foreground text-sm hover:underline">
{template.title}
</Link>
) : (
<span className="truncate font-medium text-foreground text-sm">{template.title}</span>
)}
{(meta || template.updatedAt !== null) && (
<span className="truncate text-muted-foreground text-xs">
{meta}
{meta && template.updatedAt !== null && ' · '}
{template.updatedAt !== null && (
<Trans>Updated {formatRelativeDate(template.updatedAt, i18n.locale)}</Trans>
)}
</span>
)}
</div>
<span className="shrink-0 rounded-md border bg-muted px-2 py-0.5 font-medium text-foreground text-xs tabular-nums">
<Plural value={template.count} one="# use" other="# uses" />
</span>
</li>
);
})}
</ol>
)}
</CardContent>
</Card>
);
};
@@ -599,18 +599,10 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => {
isVisibleCountCapped ? (
<Trans>{formatChipCount(totalVisibleCount, isVisibleCountCapped)} results</Trans>
) : (
<Plural
value={totalVisibleCount}
one="# result"
other="# results"
/>
<Plural value={totalVisibleCount} one="# result" other="# results" />
)
) : (
<Plural
value={totalVisibleCount}
one="# item"
other="# items"
/>
<Plural value={totalVisibleCount} one="# item" other="# items" />
)}
</span>
</div>
@@ -1,6 +1,9 @@
import LogoImage from '@documenso/assets/logo.png';
import { authClient } from '@documenso/auth/client';
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { canAccessOrganisationAnalytics, formatOrganisationAnalyticsPath } from '@documenso/lib/utils/organisations';
import { canExecuteTeamAction, formatAnalyticsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
@@ -22,6 +25,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
const { organisations } = useSession();
const currentTeam = useOptionalCurrentTeam();
const currentOrganisation = useOptionalCurrentOrganisation();
const { data: unreadCountData } = trpc.document.inbox.getCount.useQuery(
{
@@ -37,18 +41,19 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
};
const menuNavigationLinks = useMemo(() => {
let teamUrl = currentTeam?.url || null;
const navigationTeam =
currentTeam ??
(organisations.length === 1 && organisations[0].teams.length === 1 ? organisations[0].teams[0] : null);
if (!teamUrl && organisations.length === 1 && organisations[0].teams.length === 1) {
teamUrl = organisations[0].teams[0].url;
}
if (!teamUrl) {
if (!navigationTeam) {
return [
{
href: '/inbox',
text: t`Inbox`,
},
...(currentOrganisation && canAccessOrganisationAnalytics(currentOrganisation.currentOrganisationRole)
? [{ href: formatOrganisationAnalyticsPath(currentOrganisation.url), text: t`Analytics` }]
: []),
{
href: '/settings/profile',
text: t`Settings`,
@@ -56,6 +61,8 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
];
}
const teamUrl = navigationTeam.url;
return [
{
href: `/t/${teamUrl}/documents`,
@@ -69,12 +76,15 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
href: '/inbox',
text: t`Inbox`,
},
...(canExecuteTeamAction('MANAGE_TEAM', navigationTeam.currentTeamRole)
? [{ href: formatAnalyticsPath(teamUrl), text: t`Analytics` }]
: []),
{
href: '/settings/profile',
text: t`Settings`,
},
];
}, [currentTeam, organisations]);
}, [currentTeam, currentOrganisation, organisations, t]);
return (
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
@@ -1,66 +0,0 @@
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { DocumentStatus } from '@prisma/client';
import { StackAvatar } from './stack-avatar';
export type AvatarWithRecipientProps = {
recipient: TRecipientLite;
documentStatus: DocumentStatus;
};
export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) {
const [, copy] = useCopyToClipboard();
const { _ } = useLingui();
const { toast } = useToast();
const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null;
const onRecipientClick = () => {
if (!signingToken) {
return;
}
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => {
toast({
title: _(msg`Copied to clipboard`),
description: _(msg`The signing link has been copied to your clipboard.`),
});
});
};
return (
<div
className={cn('my-1 flex items-center gap-2', {
'cursor-pointer hover:underline': signingToken,
})}
role={signingToken ? 'button' : undefined}
title={signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined}
onClick={onRecipientClick}
>
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div
className="text-muted-foreground text-sm"
title={signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined}
>
<p>{recipient.email || recipient.name}</p>
<p className="text-muted-foreground/70 text-xs">{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
</div>
</div>
);
}
@@ -1,7 +1,18 @@
import { Trans } from '@lingui/react/macro';
import { AlertTriangleIcon } from 'lucide-react';
import { match } from 'ts-pattern';
export const DirectTemplateInvalidPageView = () => {
/**
* Why a direct link template cannot be used. Each reason would otherwise only
* surface once the signer submits, since the template renders normally.
*/
export type DirectTemplateInvalidReason = 'MISSING_SIGNATURE_FIELD' | 'MISSING_CONTENT_IMAGE';
export type DirectTemplateInvalidPageViewProps = {
reason: DirectTemplateInvalidReason;
};
export const DirectTemplateInvalidPageView = ({ reason }: DirectTemplateInvalidPageViewProps) => {
return (
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
<div>
@@ -12,10 +23,20 @@ export const DirectTemplateInvalidPageView = () => {
</h1>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
This direct link template cannot be used because one or more signers do not have a signature field assigned.
Please contact the sender to update the template.
</Trans>
{match(reason)
.with('MISSING_SIGNATURE_FIELD', () => (
<Trans>
This direct link template cannot be used because one or more signers do not have a signature field
assigned. Please contact the sender to update the template.
</Trans>
))
.with('MISSING_CONTENT_IMAGE', () => (
<Trans>
This direct link template cannot be used because one or more image contents have no image attached.
Please contact the sender to update the template.
</Trans>
))
.exhaustive()}
</p>
</div>
</div>
@@ -11,6 +11,7 @@ import {
import type { TTemplate } from '@documenso/lib/types/template';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { getNextDictatableRecipient } from '@documenso/lib/utils/recipient-groups';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
@@ -223,27 +224,10 @@ export const DirectTemplateSigningForm = ({
return undefined;
}
const sortedRecipients = template.recipients.sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) {
return a.id - b.id;
}
if (a.signingOrder === null) {
return 1;
}
if (b.signingOrder === null) {
return -1;
}
if (a.signingOrder === b.signingOrder) {
return a.id - b.id;
}
return a.signingOrder - b.signingOrder;
return getNextDictatableRecipient({
recipients: template.recipients,
currentRecipientId: directRecipient.id,
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === directRecipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [template.templateMeta?.signingOrder, template.recipients, directRecipient.id]);
return (
@@ -435,7 +419,7 @@ export const DirectTemplateSigningForm = ({
fields={localFields}
fieldsValidated={fieldsValidated}
recipient={directRecipient}
allowDictateNextSigner={nextRecipient && template.templateMeta?.allowDictateNextSigner}
allowDictateNextSigner={Boolean(nextRecipient && template.templateMeta?.allowDictateNextSigner)}
defaultNextSigner={nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined}
/>
</div>
@@ -102,8 +102,10 @@ export const DocumentSigningCompleteDialog = ({
const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {};
const canDictateNextSigner = allowDictateNextSigner && Boolean(defaultNextSigner);
const form = useForm<TNextSignerFormSchema>({
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
resolver: canDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
defaultValues: {
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
@@ -324,7 +326,7 @@ export const DocumentSigningCompleteDialog = ({
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
{allowDictateNextSigner && defaultNextSigner && (
{canDictateNextSigner && (
<div className="mb-4 flex flex-col gap-4">
<div className="flex flex-col gap-4 md:flex-row">
<FormField
@@ -39,7 +39,11 @@ export type DocumentSigningFormProps = {
}) => Promise<void>;
isSubmitting: boolean;
fieldsValidated: () => void;
nextRecipient?: RecipientWithFields;
/**
* The dictatable next recipient, decided server-side. Only their identity
* is needed — for the dictation flag and the prefilled inputs.
*/
nextRecipient?: Pick<Recipient, 'name' | 'email'>;
};
export const DocumentSigningForm = ({
@@ -84,6 +88,10 @@ export const DocumentSigningForm = ({
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
}, [fieldsRequiringValidation, recipient]);
const allowDictateNextSigner = Boolean(nextRecipient && document.documentMeta?.allowDictateNextSigner);
const defaultNextSigner = nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined;
const localFieldsValidated = () => {
setValidateUninsertedFields(true);
fieldsValidated();
@@ -151,10 +159,8 @@ export const DocumentSigningForm = ({
completeDocument({ nextSigner, accessAuthOptions })
}
recipient={recipient}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
allowDictateNextSigner={allowDictateNextSigner}
defaultNextSigner={defaultNextSigner}
/>
</div>
</div>
@@ -223,8 +229,8 @@ export const DocumentSigningForm = ({
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
onConfirm={handleAssistantConfirmDialogSubmit}
isSubmitting={isAssistantSubmitting}
allowDictateNextSigner={nextRecipient && document.documentMeta?.allowDictateNextSigner}
defaultNextSigner={nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined}
allowDictateNextSigner={allowDictateNextSigner}
defaultNextSigner={defaultNextSigner}
/>
</form>
) : (
@@ -291,10 +297,8 @@ export const DocumentSigningForm = ({
})
}
recipient={recipient}
allowDictateNextSigner={nextRecipient && document.documentMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
allowDictateNextSigner={allowDictateNextSigner}
defaultNextSigner={defaultNextSigner}
/>
</div>
</>
@@ -22,7 +22,7 @@ import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Trans } from '@lingui/react/macro';
import type { Field } from '@prisma/client';
import type { Field, Recipient } from '@prisma/client';
import { FieldType, RecipientRole } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useMemo, useState } from 'react';
@@ -60,6 +60,12 @@ export type DocumentSigningPageViewV1Props = {
completedFields: CompletedField[];
isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
/**
* The dictatable next recipient, computed server-side over the FULL
* recipient list — must not be re-derived from the role-scoped
* `allRecipients`.
*/
nextRecipient?: Pick<Recipient, 'name' | 'email'>;
branding: DocumentSigningBranding;
includeSenderDetails: boolean;
};
@@ -71,6 +77,7 @@ export const DocumentSigningPageViewV1 = ({
completedFields,
isRecipientsTurn,
allRecipients = [],
nextRecipient,
includeSenderDetails,
branding,
}: DocumentSigningPageViewV1Props) => {
@@ -133,34 +140,6 @@ export const DocumentSigningPageViewV1 = ({
const selectedSigner = allRecipients?.find((r) => r.id === selectedSignerId);
const targetSigner = recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null;
const nextRecipient = useMemo(() => {
if (!documentMeta?.signingOrder || documentMeta.signingOrder !== 'SEQUENTIAL') {
return undefined;
}
const sortedRecipients = [...allRecipients].sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) {
return a.id - b.id;
}
if (a.signingOrder === null) {
return 1;
}
if (b.signingOrder === null) {
return -1;
}
if (a.signingOrder === b.signingOrder) {
return a.id - b.id;
}
return a.signingOrder - b.signingOrder;
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
const hasPendingFields = pendingFields.length > 0;
@@ -235,7 +235,10 @@ export const DocumentSigningPageViewV2 = () => {
</div>
</div>
<div className="embed--DocumentContainer min-w-0 flex-1 overflow-y-auto" ref={scrollableContainerRef}>
<div
className="embed--DocumentContainer min-w-0 flex-1 overflow-x-auto overflow-y-auto"
ref={scrollableContainerRef}
>
<div className="flex flex-col">
{/* Horizontal envelope item selector */}
{envelopeItems.length > 1 && (
@@ -267,6 +270,9 @@ export const DocumentSigningPageViewV2 = () => {
customPageRenderer={EnvelopeSignerPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.signing}
toolbar={['zoom']}
// Todo: Content - Decide how to manage zooming on mobile.
toolbarClassName="hidden lg:flex"
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
@@ -6,6 +6,8 @@ import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/enve
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired, isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
import { getNextDictatableRecipient } from '@documenso/lib/utils/recipient-groups';
import { isRecipientBefore } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
import { EnvelopeType, type Field, FieldType, type Recipient, RecipientRole, SigningStatus } from '@prisma/client';
@@ -236,12 +238,16 @@ export const EnvelopeSigningProvider = ({
}, [envelopeData.recipient.fields]);
/**
* Assistant recipients are those that have a signing order after the assistant.
* Assistant recipients are those positioned strictly after the assistant —
* never their own group peers.
*/
const assistantRecipients =
recipient.role === RecipientRole.ASSISTANT
? envelope.recipients.filter((r) => (r.signingOrder ?? 0) > (recipient.signingOrder ?? 0))
: [];
const assistantRecipients = useMemo(() => {
if (recipient.role !== RecipientRole.ASSISTANT) {
return [];
}
return envelope.recipients.filter((r) => isRecipientBefore(recipient, r));
}, [envelope.recipients, recipient]);
/**
* Assistant fields are those fulfill all of the following:
@@ -249,12 +255,11 @@ export const EnvelopeSigningProvider = ({
* - After the assistant signing order
* - Are not signature fields
*/
const assistantFields =
recipient.role === RecipientRole.ASSISTANT
? assistantRecipients
.filter((r) => r.signingStatus !== SigningStatus.SIGNED)
.flatMap((r) => r.fields.filter((field) => field.type !== FieldType.SIGNATURE))
: [];
const assistantFields = useMemo(() => {
return assistantRecipients
.filter((r) => r.signingStatus !== SigningStatus.SIGNED)
.flatMap((r) => r.fields.filter((field) => field.type !== FieldType.SIGNATURE));
}, [assistantRecipients]);
/**
* The recipient that the assistant has currently selected to sign on behalf of.
@@ -269,7 +274,7 @@ export const EnvelopeSigningProvider = ({
const selectedAssistantRecipientFields = useMemo(() => {
return assistantFields.filter((field) => field.recipientId === selectedAssistantRecipient?.id);
}, [recipientFields, selectedAssistantRecipient]);
}, [assistantFields, selectedAssistantRecipient]);
/**
* Fields that have been completed by other recipients.
@@ -290,32 +295,14 @@ export const EnvelopeSigningProvider = ({
.filter((field) => field.inserted);
const nextRecipient = useMemo(() => {
if (!envelope.documentMeta.signingOrder || envelope.documentMeta.signingOrder !== 'SEQUENTIAL') {
if (envelope.documentMeta.signingOrder !== 'SEQUENTIAL') {
return null;
}
const sortedRecipients = [...envelope.recipients].sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) {
return a.id - b.id;
}
if (a.signingOrder === null) {
return 1;
}
if (b.signingOrder === null) {
return -1;
}
if (a.signingOrder === b.signingOrder) {
return a.id - b.id;
}
return a.signingOrder - b.signingOrder;
return getNextDictatableRecipient({
recipients: envelope.recipients,
currentRecipientId: recipient.id,
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: null;
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
const signField = async (
@@ -2,7 +2,7 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
import { Input } from '@documenso/ui/primitives/input';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useQueryState } from 'nuqs';
import { useQueryStates } from 'nuqs';
import { useEffect, useState } from 'react';
import { documentsSearchParams } from '~/utils/documents-search-params';
@@ -10,16 +10,26 @@ import { documentsSearchParams } from '~/utils/documents-search-params';
export const DocumentSearch = () => {
const { _ } = useLingui();
const [query, setQuery] = useQueryState('query', documentsSearchParams.query);
const [{ query }, setSearchParams] = useQueryStates(
{
query: documentsSearchParams.query,
page: documentsSearchParams.page,
},
{ history: 'push' },
);
const [searchTerm, setSearchTerm] = useState(query ?? '');
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
useEffect(() => {
if (debouncedSearchTerm !== (query ?? '')) {
void setQuery(debouncedSearchTerm || null);
// Reset pagination so a new search never lands on an empty page.
void setSearchParams({
query: debouncedSearchTerm || null,
page: null,
});
}
}, [debouncedSearchTerm, query, setQuery]);
}, [debouncedSearchTerm, query, setSearchParams]);
return (
<Input
@@ -0,0 +1,131 @@
import type Konva from 'konva';
import type { LucideIcon } from 'lucide-react';
/**
* The gap between a selection and its floating action bar.
*/
const ACTION_BAR_OFFSET_PX = 5;
/**
* Resolve the absolute CSS position for an action bar hanging below the
* bottom center of a set of nodes.
*
* Uses the union of the nodes' own client rects rather than the transformer's,
* since the transformer rect includes its handles (e.g. the rotate anchor and
* its stem above the selection) which would otherwise push the bar away from
* the selection.
*
* Client rects are in scaled stage coordinates, matching the CSS pixel space
* of the konva container.
*/
const getNodesActionBarPosition = (nodes: Konva.Node[]) => {
let minX = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const node of nodes) {
const rect = node.getClientRect({ skipStroke: true, skipShadow: true });
minX = Math.min(minX, rect.x);
maxX = Math.max(maxX, rect.x + rect.width);
maxY = Math.max(maxY, rect.y + rect.height);
}
if (!Number.isFinite(minX) || !Number.isFinite(maxY)) {
return null;
}
return {
top: `${maxY + ACTION_BAR_OFFSET_PX}px`,
left: `${minX + (maxX - minX) / 2}px`,
};
};
type EnvelopeCanvasActionBarProps = {
/**
* The Konva nodes the bar hangs below.
*/
nodes: Konva.Node[];
/**
* Hide the bar, e.g. while the nodes are being transformed.
*/
hidden?: boolean;
children: React.ReactNode;
};
/**
* A floating container positioned below the bottom center of a set of canvas
* nodes, for action bars and menus attached to a selection.
*
* Must be rendered within the page's relatively positioned container.
*/
export const EnvelopeCanvasActionBar = ({ nodes, hidden = false, children }: EnvelopeCanvasActionBarProps) => {
const position = nodes.length > 0 ? getNodesActionBarPosition(nodes) : null;
if (!position || hidden) {
return null;
}
return (
<div
data-testid="envelope-canvas-action-bar"
className="flex flex-col items-center"
style={{
position: 'absolute',
top: position.top,
left: position.left,
transform: 'translateX(-50%)',
gap: '8px',
pointerEvents: 'auto',
zIndex: 50,
}}
>
{children}
</div>
);
};
/**
* The floating pill which groups action buttons, themed like the viewer
* toolbar so the two float over the page consistently.
*
* The ring and shadow are stronger than the toolbar's since the bar sits
* directly on the page, which is usually white, rather than on the viewer
* background.
*/
export const EnvelopeCanvasActionButtonGroup = ({ children }: { children: React.ReactNode }) => {
return (
<div className="flex w-fit items-center gap-x-0.5 rounded-lg bg-popover p-1 text-popover-foreground shadow-black/25 shadow-lg ring-1 ring-black/15 dark:shadow-black/60 dark:ring-white/20">
{children}
</div>
);
};
/**
* A thin vertical separator between groups of actions within the pill.
*/
export const EnvelopeCanvasActionDivider = () => {
return <div className="mx-0.5 h-4 w-px bg-border" />;
};
type EnvelopeCanvasActionButtonProps = {
title: string;
icon: LucideIcon;
onClick: () => void;
};
export const EnvelopeCanvasActionButton = ({ title, icon: Icon, onClick }: EnvelopeCanvasActionButtonProps) => {
return (
<button
type="button"
title={title}
className="rounded-md p-1.5 text-foreground/70 transition-colors hover:bg-muted hover:text-foreground"
onClick={onClick}
onTouchEnd={onClick}
>
<Icon className="h-3.5 w-3.5" />
</button>
);
};
@@ -0,0 +1,423 @@
import type { TLocalContent } from '@documenso/lib/client-only/hooks/use-editor-contents';
import {
CONTENT_MAX_FONT_SIZE,
CONTENT_MAX_STROKE_WIDTH,
CONTENT_MIN_FONT_SIZE,
CONTENT_MIN_STROKE_WIDTH,
DEFAULT_CONTENT_FONT_SIZE,
DEFAULT_CONTENT_HIGHLIGHT_COLOR,
DEFAULT_CONTENT_STROKE_COLOR,
DEFAULT_CONTENT_STROKE_WIDTH,
DEFAULT_CONTENT_TEXT_ALIGN,
DEFAULT_CONTENT_TEXT_COLOR,
DEFAULT_CONTENT_VERTICAL_ALIGN,
EnvelopeContentType,
} from '@documenso/lib/types/envelope-content-meta';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import { FormField } from '@documenso/ui/primitives/form/form';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { Slider } from '@documenso/ui/primitives/slider';
import { Trans, useLingui } from '@lingui/react/macro';
import type { LucideIcon } from 'lucide-react';
import {
ALargeSmallIcon,
AlignCenterIcon,
AlignLeftIcon,
AlignRightIcon,
AlignVerticalJustifyCenterIcon,
AlignVerticalJustifyEndIcon,
AlignVerticalJustifyStartIcon,
ImageUpIcon,
} from 'lucide-react';
import { match } from 'ts-pattern';
import { DEFAULT_ENABLED_FILL_COLOR } from '~/components/forms/editor/editor-content-shape-form';
import { ContentImageUploadDialog } from '~/components/general/envelope-editor/content-image-upload-dialog';
import {
type TContentHighlightFormSchema,
type TContentLineFormSchema,
type TContentShapeFormSchema,
type TContentTextFormSchema,
useContentSettingsForm,
} from '~/components/general/envelope-editor/content-settings-form-provider';
import { EnvelopeCanvasActionButton, EnvelopeCanvasActionDivider } from './envelope-canvas-action-bar';
type EnvelopeCanvasContentActionsProps = {
/**
* The single selected content.
*/
content: TLocalContent;
};
/**
* The type specific quick actions for a single selected content, shown at the
* start of the content action bar: the frequently adjusted styles (colors,
* stroke width) and the image upload for image contents.
*
* The style actions are fields of the selected content's settings form, the
* same form the sidebar edits, so the two never disagree. The full set of
* settings remains in the sidebar. Renders nothing for types without quick
* actions.
*/
export const EnvelopeCanvasContentActions = ({ content }: EnvelopeCanvasContentActionsProps) => {
const { t } = useLingui();
const { content: formContent, isReady } = useContentSettingsForm();
// The canvas selection and the settings form follow each other, but guard
// against a render in between so a stale selection never edits another
// content's form, and wait for the form to hold this content's values.
if (formContent?.formId !== content.formId || !isReady) {
return null;
}
const actions = match(content.contentMeta)
.with({ type: EnvelopeContentType.TEXT }, () => <TextContentActions />)
.with({ type: EnvelopeContentType.LINE }, () => <LineContentActions />)
.with({ type: EnvelopeContentType.SHAPE }, () => <ShapeContentActions />)
.with({ type: EnvelopeContentType.HIGHLIGHT }, () => <HighlightContentActions />)
.with({ type: EnvelopeContentType.IMAGE }, () => (
<EnvelopeCanvasActionButton
title={content.dataContentId ? t`Replace image` : t`Upload image`}
icon={ImageUpIcon}
onClick={() => void ContentImageUploadDialog.call({ formId: content.formId })}
/>
))
.exhaustive();
return (
<>
{actions}
<EnvelopeCanvasActionDivider />
</>
);
};
const TextContentActions = () => {
const { t } = useLingui();
const { form } = useContentSettingsForm<TContentTextFormSchema>();
return (
<>
<FormField
control={form.control}
name="color"
render={({ field }) => (
<ColorAction
title={t`Text color`}
value={field.value ?? DEFAULT_CONTENT_TEXT_COLOR}
onChange={field.onChange}
/>
)}
/>
<FormField
control={form.control}
name="fontSize"
render={({ field }) => (
<FontSizeAction value={field.value ?? DEFAULT_CONTENT_FONT_SIZE} onChange={field.onChange} />
)}
/>
<FormField
control={form.control}
name="textAlign"
render={({ field }) => (
<ChoiceAction
title={t`Text align`}
value={field.value ?? DEFAULT_CONTENT_TEXT_ALIGN}
options={[
{ value: 'left', icon: AlignLeftIcon, label: t`Left` },
{ value: 'center', icon: AlignCenterIcon, label: t`Center` },
{ value: 'right', icon: AlignRightIcon, label: t`Right` },
]}
onChange={field.onChange}
/>
)}
/>
<FormField
control={form.control}
name="verticalAlign"
render={({ field }) => (
<ChoiceAction
title={t`Vertical align`}
value={field.value ?? DEFAULT_CONTENT_VERTICAL_ALIGN}
options={[
{ value: 'top', icon: AlignVerticalJustifyStartIcon, label: t`Top` },
{ value: 'middle', icon: AlignVerticalJustifyCenterIcon, label: t`Middle` },
{ value: 'bottom', icon: AlignVerticalJustifyEndIcon, label: t`Bottom` },
]}
onChange={field.onChange}
/>
)}
/>
</>
);
};
const LineContentActions = () => {
const { t } = useLingui();
const { form } = useContentSettingsForm<TContentLineFormSchema>();
return (
<>
<FormField
control={form.control}
name="strokeColor"
render={({ field }) => (
<ColorAction
title={t`Line color`}
value={field.value ?? DEFAULT_CONTENT_STROKE_COLOR}
onChange={field.onChange}
/>
)}
/>
<FormField
control={form.control}
name="strokeWidth"
render={({ field }) => (
<StrokeWidthAction value={field.value ?? DEFAULT_CONTENT_STROKE_WIDTH} onChange={field.onChange} />
)}
/>
</>
);
};
const ShapeContentActions = () => {
const { t } = useLingui();
const { form } = useContentSettingsForm<TContentShapeFormSchema>();
return (
<>
<FormField
control={form.control}
name="strokeColor"
render={({ field }) => (
<ColorAction
title={t`Border color`}
value={field.value ?? DEFAULT_CONTENT_STROKE_COLOR}
onChange={field.onChange}
/>
)}
/>
<FormField
control={form.control}
name="fillColor"
render={({ field }) => (
<ColorAction
title={field.value ? t`Fill color` : t`Add fill`}
value={field.value}
defaultValue={DEFAULT_ENABLED_FILL_COLOR}
onChange={field.onChange}
/>
)}
/>
<FormField
control={form.control}
name="strokeWidth"
render={({ field }) => (
<StrokeWidthAction value={field.value ?? DEFAULT_CONTENT_STROKE_WIDTH} onChange={field.onChange} />
)}
/>
</>
);
};
const HighlightContentActions = () => {
const { t } = useLingui();
const { form } = useContentSettingsForm<TContentHighlightFormSchema>();
return (
<FormField
control={form.control}
name="color"
render={({ field }) => (
<ColorAction
title={t`Highlight color`}
value={field.value ?? DEFAULT_CONTENT_HIGHLIGHT_COLOR}
onChange={field.onChange}
/>
)}
/>
);
};
type ColorActionProps = {
title: string;
/**
* The current color, or null when none is set (e.g. no fill).
*/
value: string | null;
/**
* The color the picker starts from when none is set.
*/
defaultValue?: string;
onChange: (color: string) => void;
};
/**
* A swatch button which opens a color picker. An unset color shows a struck
* through swatch.
*/
const ColorAction = ({ title, value, defaultValue, onChange }: ColorActionProps) => {
return (
<ColorPicker
value={value ?? ''}
defaultValue={defaultValue}
onChange={onChange}
trigger={
<button type="button" title={title} className="rounded-md p-1.5 transition-colors hover:bg-muted">
<span
className="relative block h-3.5 w-3.5 overflow-hidden rounded-full ring-1 ring-black/20 ring-inset dark:ring-white/30"
style={{ backgroundColor: value ?? 'transparent' }}
>
{!value && <span className="absolute -inset-x-1 top-1/2 h-px -translate-y-1/2 rotate-45 bg-destructive" />}
</span>
</button>
}
/>
);
};
type FontSizeActionProps = {
value: number;
onChange: (fontSize: number) => void;
};
/**
* A button showing the current font size which opens a slider.
*/
const FontSizeAction = ({ value, onChange }: FontSizeActionProps) => {
const { t } = useLingui();
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
title={t`Font size`}
className="rounded-md p-1.5 text-foreground/70 transition-colors hover:bg-muted hover:text-foreground"
>
<ALargeSmallIcon className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-3" onOpenAutoFocus={(event) => event.preventDefault()}>
<div className="mb-2 flex items-center justify-between text-xs">
<span className="text-muted-foreground">
<Trans>Font size</Trans>
</span>
<span className="tabular-nums">{value}</span>
</div>
<Slider
value={[value]}
min={CONTENT_MIN_FONT_SIZE}
max={CONTENT_MAX_FONT_SIZE}
step={1}
onValueChange={([fontSize]) => onChange(fontSize)}
/>
</PopoverContent>
</Popover>
);
};
type ChoiceActionProps<T extends string> = {
title: string;
value: T;
options: { value: T; icon: LucideIcon; label: string }[];
onChange: (value: T) => void;
};
/**
* A button showing the icon of the current choice which opens the other
* choices, e.g. text alignment.
*/
const ChoiceAction = <T extends string>({ title, value, options, onChange }: ChoiceActionProps<T>) => {
const current = options.find((option) => option.value === value) ?? options[0];
const CurrentIcon = current.icon;
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
title={title}
className="rounded-md p-1.5 text-foreground/70 transition-colors hover:bg-muted hover:text-foreground"
>
<CurrentIcon className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="flex w-auto gap-0.5 p-1" onOpenAutoFocus={(event) => event.preventDefault()}>
{options.map((option) => {
const OptionIcon = option.icon;
return (
<button
key={option.value}
type="button"
title={option.label}
aria-pressed={option.value === value}
onClick={() => onChange(option.value)}
className="rounded-md p-1.5 text-foreground/70 transition-colors hover:bg-muted hover:text-foreground aria-pressed:bg-muted aria-pressed:text-foreground"
>
<OptionIcon className="h-4 w-4" />
</button>
);
})}
</PopoverContent>
</Popover>
);
};
type StrokeWidthActionProps = {
value: number;
onChange: (width: number) => void;
};
/**
* A button showing the current stroke width which opens a slider.
*/
const StrokeWidthAction = ({ value, onChange }: StrokeWidthActionProps) => {
const { t } = useLingui();
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
title={t`Thickness`}
className="flex h-[26px] w-[26px] items-center justify-center rounded-md text-foreground/70 transition-colors hover:bg-muted hover:text-foreground"
>
{/* A bar whose thickness follows the value, so the current width is still glanceable. */}
<span className="block w-3.5 rounded-full bg-current" style={{ height: Math.max(1.5, Math.min(value, 6)) }} />
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-3" onOpenAutoFocus={(event) => event.preventDefault()}>
<div className="mb-2 flex items-center justify-between text-xs">
<span className="text-muted-foreground">
<Trans>Thickness</Trans>
</span>
<span className="tabular-nums">{value}</span>
</div>
<Slider
value={[value]}
min={CONTENT_MIN_STROKE_WIDTH}
max={CONTENT_MAX_STROKE_WIDTH}
step={0.5}
onValueChange={([width]) => onChange(width)}
/>
</PopoverContent>
</Popover>
);
};
@@ -0,0 +1,169 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@documenso/ui/primitives/command';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { useLingui } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client';
import { CopyPlusIcon, ShapesIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { fieldButtonList } from '../envelope-editor-fields-drag-drop';
import { EnvelopeRecipientSelectorCommand } from '../envelope-recipient-selector';
import { EnvelopeCanvasActionButton, EnvelopeCanvasActionButtonGroup } from './envelope-canvas-action-bar';
type EnvelopeCanvasFieldActionButtonsProps = {
selectedFieldFormIds: string[];
onDuplicate: () => void;
onDuplicateOnAllPages: () => void;
onDelete: () => void;
onChangeRecipient: (recipientId: number) => void;
onChangeFieldType: (type: FieldType) => void;
};
/**
* The floating actions for the selected fields.
*/
export const EnvelopeCanvasFieldActionButtons = ({
selectedFieldFormIds,
onDuplicate,
onDuplicateOnAllPages,
onDelete,
onChangeRecipient,
onChangeFieldType,
}: EnvelopeCanvasFieldActionButtonsProps) => {
const { t } = useLingui();
const [showRecipientSelector, setShowRecipientSelector] = useState(false);
const [showFieldTypeSelector, setShowFieldTypeSelector] = useState(false);
const { editorFields, envelope } = useCurrentEnvelopeEditor();
const selectedFields = useMemo(
() => editorFields.localFields.filter((field) => selectedFieldFormIds.includes(field.formId)),
[editorFields.localFields, selectedFieldFormIds],
);
/**
* Decide the preselected field type in the command input.
*
* If all fields share the same type, use that as the default selection.
* Otherwise show no preselection.
*/
const preselectedFieldType = useMemo(() => {
if (selectedFields.length === 0) {
return null;
}
const firstType = selectedFields[0].type;
const isTypesSame = selectedFields.every((field) => field.type === firstType);
return isTypesSame ? firstType : null;
}, [selectedFields]);
/**
* Decide the preselected recipient in the command input.
*
* If all fields belong to the same recipient then use that recipient as the default.
*
* Otherwise show the placeholder.
*/
const preselectedRecipient = useMemo(() => {
if (selectedFields.length === 0) {
return null;
}
const recipient = envelope.recipients.find((recipient) => recipient.id === selectedFields[0].recipientId);
if (!recipient) {
return null;
}
const isRecipientsSame = selectedFields.every((field) => field.recipientId === recipient.id);
return isRecipientsSame ? recipient : null;
}, [selectedFields, envelope.recipients]);
return (
<>
<EnvelopeCanvasActionButtonGroup>
<EnvelopeCanvasActionButton
title={t`Change Recipient`}
icon={UserCircleIcon}
onClick={() => setShowRecipientSelector(true)}
/>
<EnvelopeCanvasActionButton
title={t`Change Field Type`}
icon={ShapesIcon}
onClick={() => setShowFieldTypeSelector(true)}
/>
<EnvelopeCanvasActionButton title={t`Duplicate`} icon={CopyPlusIcon} onClick={onDuplicate} />
<EnvelopeCanvasActionButton
title={t`Duplicate on all pages`}
icon={SquareStackIcon}
onClick={onDuplicateOnAllPages}
/>
<EnvelopeCanvasActionButton title={t`Remove`} icon={TrashIcon} onClick={onDelete} />
</EnvelopeCanvasActionButtonGroup>
<CommandDialog position="start" open={showRecipientSelector} onOpenChange={setShowRecipientSelector}>
<EnvelopeRecipientSelectorCommand
placeholder={t`Select a recipient`}
selectedRecipient={preselectedRecipient}
onSelectedRecipientChange={(recipient) => {
editorFields.setSelectedRecipient(recipient.id);
onChangeRecipient(recipient.id);
setShowRecipientSelector(false);
}}
recipients={envelope.recipients}
fields={envelope.fields}
/>
</CommandDialog>
<CommandDialog position="start" open={showFieldTypeSelector} onOpenChange={setShowFieldTypeSelector}>
<Command defaultValue={preselectedFieldType ? t(FRIENDLY_FIELD_TYPE[preselectedFieldType]) : undefined}>
<CommandInput placeholder={t`Select a field type`} />
<CommandList>
<CommandEmpty>
<span className="inline-block px-4 text-muted-foreground">
{t`No field type matching this description was found.`}
</span>
</CommandEmpty>
<CommandGroup>
{fieldButtonList.map((field) => {
const FieldIcon = field.icon;
const label = t(FRIENDLY_FIELD_TYPE[field.type]);
return (
<CommandItem
key={field.type}
className="px-2"
onSelect={() => {
onChangeFieldType(field.type);
setShowFieldTypeSelector(false);
}}
>
<FieldIcon className="mr-2 h-4 w-4" />
<span className="truncate">{label}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
</>
);
};
@@ -0,0 +1,40 @@
import { useLingui } from '@lingui/react/macro';
import type { ContentDragDropItem } from '../envelope-editor-content-drag-drop';
type EnvelopeCanvasPendingContentMenuProps = {
/**
* The palette items on offer, e.g. without images once the organisation's
* image allowance is used up.
*/
items: ContentDragDropItem[];
onSelectItem: (item: ContentDragDropItem) => void;
};
/**
* The content type picker shown after drawing a marquee on an empty area of
* the page while editing contents, to create a content of that size.
*/
export const EnvelopeCanvasPendingContentMenu = ({ items, onSelectItem }: EnvelopeCanvasPendingContentMenuProps) => {
const { t } = useLingui();
return (
<div
// Don't use darkmode for this component, it should look the same for both light/dark modes.
className="flex w-max items-center gap-x-1 rounded-md border border-gray-300 bg-white p-1 text-gray-500 shadow-sm"
>
{items.map((item) => (
<button
key={item.key}
type="button"
onClick={() => onSelectItem(item)}
className="flex flex-shrink-0 items-center gap-x-1.5 rounded-sm px-2 py-1 text-xs hover:bg-gray-100 hover:text-gray-600"
>
<item.icon className="h-3.5 w-3.5" />
{t(item.name)}
</button>
))}
</div>
);
};
@@ -0,0 +1,34 @@
import { useLingui } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client';
import { fieldButtonList } from '../envelope-editor-fields-drag-drop';
type EnvelopeCanvasPendingFieldMenuProps = {
onSelectType: (type: FieldType) => void;
};
/**
* The field type picker shown after drawing a marquee on an empty area of the
* page, to create a field of that size.
*/
export const EnvelopeCanvasPendingFieldMenu = ({ onSelectType }: EnvelopeCanvasPendingFieldMenuProps) => {
const { t } = useLingui();
return (
<div
// Don't use darkmode for this component, it should look the same for both light/dark modes.
className="grid w-max grid-cols-5 gap-x-1 gap-y-0.5 rounded-md border border-gray-300 bg-white p-1 text-gray-500 shadow-sm"
>
{fieldButtonList.map((field) => (
<button
key={field.type}
type="button"
onClick={() => onSelectType(field.type)}
className="col-span-1 w-full flex-shrink-0 rounded-sm px-2 py-1 text-xs hover:bg-gray-100 hover:text-gray-600"
>
{t(field.name)}
</button>
))}
</div>
);
};
@@ -0,0 +1,59 @@
import { CONTENT_GROUP_NODE_NAME } from '@documenso/lib/universal/content-renderer/content-renderer';
import type Konva from 'konva';
import type { RefObject } from 'react';
/**
* The shared canvas handles and page metrics passed to the envelope canvas
* hooks, sourced from `usePageRenderer` and the page render data.
*/
export type EnvelopeCanvas = {
stage: RefObject<Konva.Stage | null>;
pageLayer: RefObject<Konva.Layer | null>;
/**
* The scale of the stage relative to the unscaled page.
*/
scale: number;
pageNumber: number;
/**
* The raw page size in pixels.
*/
unscaledViewport: { width: number; height: number };
/**
* The page size in pixels as rendered on the stage.
*/
scaledViewport: { width: number; height: number };
};
export type EnvelopeCanvasSelectionKind = 'field' | 'content';
/**
* The current canvas selection. Fields and contents share one transformer, so
* only one kind can be selected at a time.
*/
export type EnvelopeCanvasSelection = {
kind: EnvelopeCanvasSelectionKind;
groups: Konva.Group[];
} | null;
/**
* The Konva group names for each selectable kind.
*/
export const ENVELOPE_CANVAS_GROUP_NAMES: Record<EnvelopeCanvasSelectionKind, string> = {
// Defined by the field renderer in `field-generic-items.ts`.
field: 'field-group',
content: CONTENT_GROUP_NODE_NAME,
};
/**
* A box in scaled stage coordinates, e.g. from a client rect.
*/
export type EnvelopeCanvasBox = {
x: number;
y: number;
width: number;
height: number;
};
@@ -0,0 +1,112 @@
import Konva from 'konva';
import type { EnvelopeCanvasSelectionKind } from './envelope-canvas-types';
type ReconcileEnvelopeCanvasGroupsOptions<T> = {
layer: Konva.Layer;
/**
* The Konva group name of the items being reconciled.
*/
groupName: string;
items: T[];
getRenderId: (item: T) => string;
render: (item: T) => void;
};
/**
* Sync a layer's groups of a given name with a list of items.
*
* Groups whose ID no longer matches an item are destroyed, then every item is
* (re)rendered so existing groups pick up any changes.
*/
export const reconcileEnvelopeCanvasGroups = <T>({
layer,
groupName,
items,
getRenderId,
render,
}: ReconcileEnvelopeCanvasGroupsOptions<T>) => {
const renderIds = new Set(items.map(getRenderId));
layer.find('Group').forEach((group) => {
if (group.name() === groupName && !renderIds.has(group.id())) {
group.destroy();
}
});
for (const item of items) {
render(item);
}
};
/**
* Filter selected groups down to those which are still attached to the stage
* and still correspond to an item on the page, e.g. after items are deleted
* or the page is resynced.
*/
export const getLiveEnvelopeCanvasGroups = (groups: Konva.Group[], isOnPage: (renderId: string) => boolean) => {
return groups.filter((group) => Boolean(group.getStage()) && Boolean(group.getParent()) && isOnPage(group.id()));
};
type SyncEditorSelectionToCanvasOptions = {
layer: Konva.Layer;
kind: EnvelopeCanvasSelectionKind;
/**
* The form ID of the item selected within the editor, if any.
*/
editorFormId: string | null;
isOnPage: (renderId: string) => boolean;
/**
* The currently selected groups of this kind.
*/
selectedGroups: Konva.Group[];
select: (kind: EnvelopeCanvasSelectionKind, nodes: Konva.Node[]) => void;
clear: () => void;
};
/**
* Sync the editor's single selected item onto the canvas selection.
*
* Creating an item marks it as selected within the editor, so this makes a
* newly placed item show its transformer immediately without a second click.
* It also clears the canvas selection when the editor selection is cleared,
* so a stale transformer can't linger.
*
* Must run after the groups have been rendered so the item's group exists.
*/
export const syncEditorSelectionToCanvas = ({
layer,
kind,
editorFormId,
isOnPage,
selectedGroups,
select,
clear,
}: SyncEditorSelectionToCanvasOptions) => {
const isSingleSelection = selectedGroups.length === 1;
if (editorFormId && isOnPage(editorFormId)) {
const isAlreadySelected = isSingleSelection && selectedGroups[0].id() === editorFormId;
if (isAlreadySelected) {
return;
}
const groupToSelect = layer.findOne(`#${editorFormId}`);
if (groupToSelect instanceof Konva.Group) {
select(kind, [groupToSelect]);
}
return;
}
if (editorFormId === null && isSingleSelection) {
clear();
}
};
@@ -0,0 +1,448 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import type { TLocalContent } from '@documenso/lib/client-only/hooks/use-editor-contents';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { TEnvelopeContentMeta } from '@documenso/lib/types/envelope-content-meta';
import {
CONTENT_META_DEFAULT_VALUES,
CONTENT_SHAPE_META_DEFAULT_VALUES_BY_SHAPE,
EnvelopeContentShapeType,
EnvelopeContentType,
} from '@documenso/lib/types/envelope-content-meta';
import {
readContentGroupTransform,
resolveContentMetaFromTransform,
} from '@documenso/lib/universal/content-renderer/content-geometry';
import {
MIN_CONTENT_HEIGHT_PX,
MIN_CONTENT_WIDTH_PX,
} from '@documenso/lib/universal/content-renderer/content-renderer';
import { renderContent } from '@documenso/lib/universal/content-renderer/render-content';
import { getLocalContentOrderId, sortContentsForRender } from '@documenso/lib/utils/envelope-content';
import { getClientSideContentTranslations } from '@documenso/lib/utils/envelope-content-translations';
import type { PercentageBox } from '@documenso/lib/utils/geometry';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import { useLingui } from '@lingui/react/macro';
import Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import { useEffect, useMemo } from 'react';
import type { ContentDragDropItem } from '../envelope-editor-content-drag-drop';
import type { EnvelopeCanvas, EnvelopeCanvasBox } from './envelope-canvas-types';
import { ENVELOPE_CANVAS_GROUP_NAMES } from './envelope-canvas-types';
import {
getLiveEnvelopeCanvasGroups,
reconcileEnvelopeCanvasGroups,
syncEditorSelectionToCanvas,
} from './reconcile-envelope-canvas-groups';
import { findEnvelopeCanvasGroupsInBox } from './use-envelope-canvas-marquee';
import { useEnvelopeCanvasPendingCreation } from './use-envelope-canvas-pending-creation';
import type { EnvelopeCanvasSelectionApi } from './use-envelope-canvas-selection';
const PENDING_CONTENT_NODE_NAME = 'pending-content-creation';
type UseEnvelopeCanvasContentsLayerOptions = {
canvas: EnvelopeCanvas;
selection: EnvelopeCanvasSelectionApi;
/**
* Whether contents can currently be edited on the canvas.
*/
isEditable: boolean;
/**
* Whether the envelope has used up the organisation's content allowance,
* in which case no further contents can be created from the canvas.
*/
isContentLimitReached: boolean;
applyPageItemsVisibility: () => void;
};
/**
* Renders and manages the contents of the current page on the canvas.
*
* Owns content rendering, z-ordering relative to fields, geometry write back,
* the marquee-to-create flow and the selection actions for contents.
*/
export const useEnvelopeCanvasContentsLayer = ({
canvas,
selection,
isEditable,
isContentLimitReached,
applyPageItemsVisibility,
}: UseEnvelopeCanvasContentsLayerOptions) => {
const { i18n } = useLingui();
const analytics = useAnalytics();
const { envelope, editorContents, selectedEditorTab } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, contentImages, setRenderError } = useCurrentEnvelopeRender();
const { stage, pageLayer, scale, pageNumber, unscaledViewport } = canvas;
const { contentGroups: selectedGroups } = selection;
/**
* The rectangle drawn via the marquee which is pending a content type choice.
*/
const pending = useEnvelopeCanvasPendingCreation({ canvas, nodeName: PENDING_CONTENT_NODE_NAME });
/**
* The page's contents in stacking order, so rendering them in sequence
* puts the last one on top. Local contents are ordered by their form ID as
* the tiebreak, the same way persisted contents are ordered by their ID.
*/
const localPageContents = useMemo(
() =>
sortContentsForRender(
editorContents.localContents
.filter(
(content) => content.contentMeta.page === pageNumber && content.envelopeItemId === currentEnvelopeItem?.id,
)
.map((content) => ({ ...content, id: getLocalContentOrderId(content) })),
(content) => content.contentMeta.zIndex,
),
[editorContents.localContents, pageNumber, currentEnvelopeItem?.id],
);
const isOnPage = (formId: string) => localPageContents.some((content) => content.formId === formId);
/**
* Write the new geometry of a content back into its content meta after a drag
* or resize/rotate gesture.
*/
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
const isDragEvent = event.type === 'dragend';
const contentGroup = event.target as Konva.Group;
const contentFormId = contentGroup.id();
const content = editorContents.getContentByFormId(contentFormId);
if (!content) {
return;
}
editorContents.updateContentByFormId(contentFormId, {
contentMeta: resolveContentMetaFromTransform(
content.contentMeta,
readContentGroupTransform(contentGroup),
isDragEvent ? 'drag' : 'transform',
unscaledViewport.width,
unscaledViewport.height,
),
});
// Select the content if it is not already selected.
if (isDragEvent && !selection.isSelected(contentGroup)) {
selection.select('content', [contentGroup]);
}
pageLayer.current?.batchDraw();
};
const unsafeRenderContent = (content: TLocalContent) => {
if (!pageLayer.current) {
return;
}
const { contentGroup } = renderContent(
{
renderId: content.formId,
contentMeta: content.contentMeta,
dataContentId: content.dataContentId,
},
{
pageLayer: pageLayer.current,
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
scale,
mode: 'edit',
editable: isEditable,
translations: getClientSideContentTranslations(i18n),
hoverOutlineColor: getRecipientColorStyles('green').baseRing,
images: contentImages.images,
},
);
// Contents are inert while the fields tab is active so they behave as part
// of the document. Their stacking relative to the fields is applied to
// all of them together afterwards, see `applyContentsStacking`.
contentGroup.listening(isEditable);
if (!isEditable) {
return;
}
contentGroup.off('click');
contentGroup.off('transformend');
contentGroup.off('dragend');
// A plain click selects just this content, shift + click toggles it
// in/out of the current selection.
contentGroup.on('click', (event) => {
if (event.evt.shiftKey) {
selection.toggle('content', contentGroup);
} else {
selection.select('content', [contentGroup]);
}
pageLayer.current?.batchDraw();
});
contentGroup.on('transformend', handleResizeOrMove);
contentGroup.on('dragend', handleResizeOrMove);
};
const renderContentOnLayer = (content: TLocalContent) => {
try {
unsafeRenderContent(content);
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'editor',
location: 'envelope_page_render',
envelopeId: envelope.id,
});
setRenderError(true);
}
};
/**
* Place the contents as a band relative to the fields, keeping their
* stacking order intact: above the muted fields while the contents tab is
* active so they are fully visible and clickable, beneath the fields
* otherwise as part of the document.
*
* Konva stacks by child order, so the band is moved one group at a time.
* Sending to the bottom iterates in reverse so the last content stays on
* top, as when sending to the top in order.
*/
const applyContentsStacking = () => {
const layer = pageLayer.current;
if (!layer) {
return;
}
const groups = localPageContents.flatMap((content) => {
const group = layer.findOne(`#${content.formId}`);
return group instanceof Konva.Group ? [group] : [];
});
if (selectedEditorTab === 'contents') {
for (const group of groups) {
group.moveToTop();
}
} else {
for (const group of [...groups].reverse()) {
group.moveToBottom();
}
}
};
const renderAll = () => {
for (const content of localPageContents) {
renderContentOnLayer(content);
}
applyContentsStacking();
};
/**
* Resolve a marquee selection box into a content selection, or into a
* pending content creation when nothing was selected and the box is large
* enough.
*/
const selectInBox = (box: EnvelopeCanvasBox) => {
const currentStage = stage.current;
if (!currentStage || !isEditable) {
return;
}
const groupsInBox = findEnvelopeCanvasGroupsInBox(currentStage, ENVELOPE_CANVAS_GROUP_NAMES.content, box);
selection.select('content', groupsInBox);
const unscaledBoxWidth = box.width / scale;
const unscaledBoxHeight = box.height / scale;
// Create a content if no items are selected or the size is too small.
if (
groupsInBox.length === 0 &&
!isContentLimitReached &&
unscaledBoxWidth > MIN_CONTENT_WIDTH_PX &&
unscaledBoxHeight > MIN_CONTENT_HEIGHT_PX
) {
pending.setPendingFromBox(box);
}
};
/**
* Create a content of the given palette item from the pending creation
* rectangle.
*
* Unlike a palette drop, which places a content at its default size, the
* content is fitted to the box the author drew.
*/
const createFromPending = (item: ContentDragDropItem) => {
const box = pending.getPendingBox();
pending.clearPending();
if (!box || !currentEnvelopeItem) {
return;
}
editorContents.addContent({
envelopeItemId: currentEnvelopeItem.id,
contentMeta: buildContentMetaFromBox(item, pageNumber, box),
});
};
/**
* Render contents when they are added, removed or updated, and when an
* image arrives after the page was created (a failed image simply keeps
* its placeholder so the author can re-upload).
*/
useEffect(() => {
const layer = pageLayer.current;
if (!layer || !stage.current) {
return;
}
reconcileEnvelopeCanvasGroups({
layer,
groupName: ENVELOPE_CANVAS_GROUP_NAMES.content,
items: localPageContents,
getRenderId: (content) => content.formId,
render: renderContentOnLayer,
});
applyContentsStacking();
// Reconcile selection state with live content nodes after flush/sync updates.
const liveSelectedGroups = getLiveEnvelopeCanvasGroups(selectedGroups, isOnPage);
if (liveSelectedGroups.length !== selectedGroups.length) {
selection.select('content', liveSelectedGroups);
}
// Only sync while editable, otherwise a transformer would be attached
// to an inert group.
if (isEditable) {
syncEditorSelectionToCanvas({
layer,
kind: 'content',
editorFormId: editorContents.selectedContent?.formId ?? null,
isOnPage,
selectedGroups,
select: selection.select,
clear: selection.clear,
});
}
applyPageItemsVisibility();
selection.refreshTransformer();
layer.batchDraw();
}, [
localPageContents,
selectedGroups,
selectedEditorTab,
isEditable,
editorContents.selectedContent?.formId,
contentImages.images,
]);
/**
* Selecting a single content brings it to the front of its page, so the
* content being worked on is never hidden behind another.
*/
useEffect(() => {
if (isEditable && selectedGroups.length === 1) {
editorContents.bringContentToFront(selectedGroups[0].id());
}
}, [isEditable, selectedGroups]);
/**
* Clear any active content selection and pending content creation when
* contents are no longer editable, e.g. when switching back to the fields
* tab or hiding contents via the viewer toolbar.
*/
useEffect(() => {
if (!isEditable && (selectedGroups.length > 0 || pending.pendingCreation)) {
pending.clearPending();
selection.clear();
pageLayer.current?.batchDraw();
}
}, [isEditable, selectedGroups, pending.pendingCreation]);
const getSelectedContents = () =>
selectedGroups
.map((group) => editorContents.getContentByFormId(group.id()))
.filter((content) => content !== undefined);
const deleteSelected = () => {
editorContents.removeContentsByFormId(selectedGroups.map((group) => group.id()));
selection.clear();
};
const duplicateSelected = () => {
for (const content of getSelectedContents()) {
editorContents.duplicateContent(content);
}
};
return {
localPageContents,
renderAll,
selectInBox,
pendingCreation: pending.pendingCreation,
createFromPending,
clearPending: pending.clearPending,
deleteSelected,
duplicateSelected,
};
};
/**
* Build the meta of a content created from a box drawn on the page, merging
* the box into the default values of the item.
*
* Box contents take the box as their bounds. Lines have no box of their own,
* so they run horizontally along its top edge, from the top left to the top
* right corner.
*/
const buildContentMetaFromBox = (item: ContentDragDropItem, page: number, box: PercentageBox): TEnvelopeContentMeta => {
// Shapes share a content type and are told apart by `shape`.
const defaultMeta =
item.type === EnvelopeContentType.SHAPE
? CONTENT_SHAPE_META_DEFAULT_VALUES_BY_SHAPE[item.shape ?? EnvelopeContentShapeType.RECTANGLE]
: CONTENT_META_DEFAULT_VALUES[item.type];
const meta = { ...structuredClone(defaultMeta), page };
if (meta.type === EnvelopeContentType.LINE) {
return {
...meta,
x1: box.positionX,
y1: box.positionY,
x2: box.positionX + box.width,
y2: box.positionY,
};
}
return {
...meta,
positionX: box.positionX,
positionY: box.positionY,
width: box.width,
height: box.height,
};
};
@@ -0,0 +1,472 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { EnvelopePageItemsVisibility } from '@documenso/lib/types/envelope-page-items-visibility';
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import { MIN_FIELD_HEIGHT_PX, MIN_FIELD_WIDTH_PX } from '@documenso/lib/universal/field-renderer/field-renderer';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { useLingui } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client';
import Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import { useEffect, useMemo } from 'react';
import type { EnvelopeCanvas, EnvelopeCanvasBox } from './envelope-canvas-types';
import { ENVELOPE_CANVAS_GROUP_NAMES } from './envelope-canvas-types';
import {
getLiveEnvelopeCanvasGroups,
reconcileEnvelopeCanvasGroups,
syncEditorSelectionToCanvas,
} from './reconcile-envelope-canvas-groups';
import { findEnvelopeCanvasGroupsInBox } from './use-envelope-canvas-marquee';
import { useEnvelopeCanvasPendingCreation } from './use-envelope-canvas-pending-creation';
import type { EnvelopeCanvasSelectionApi } from './use-envelope-canvas-selection';
const PENDING_FIELD_NODE_NAME = 'pending-field-creation';
type UseEnvelopeCanvasFieldsLayerOptions = {
canvas: EnvelopeCanvas;
selection: EnvelopeCanvasSelectionApi;
fieldsVisibility: EnvelopePageItemsVisibility;
applyPageItemsVisibility: () => void;
};
/**
* Renders and manages the fields of the current page on the canvas.
*
* Owns field rendering, overlap highlighting, geometry write back, the
* marquee-to-create flow and the selection actions for fields.
*/
export const useEnvelopeCanvasFieldsLayer = ({
canvas,
selection,
fieldsVisibility,
applyPageItemsVisibility,
}: UseEnvelopeCanvasFieldsLayerOptions) => {
const { i18n } = useLingui();
const analytics = useAnalytics();
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
const { stage, pageLayer, scale, pageNumber, unscaledViewport, scaledViewport } = canvas;
const { fieldGroups: selectedGroups, isTransforming } = selection;
/**
* The rectangle drawn via the marquee which is pending a field type choice.
*/
const pending = useEnvelopeCanvasPendingCreation({ canvas, nodeName: PENDING_FIELD_NODE_NAME });
const localPageFields = useMemo(
() =>
editorFields.localFields.filter(
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
),
[editorFields.localFields, pageNumber, currentEnvelopeItem?.id],
);
const isOnPage = (formId: string) => localPageFields.some((field) => field.formId === formId);
/**
* Debounce the fields used for overlap highlighting so we don't recompute on every
* small drag/resize tick. Overlaps only occur within the same page and envelope
* item, so computing from this page's fields alone is sufficient.
*/
const debouncedPageFields = useDebouncedValue(localPageFields, 300);
const overlappingFieldFormIds = useMemo(() => {
const formIds = new Set<string>();
const pairs = getOverlappingFieldPairs(
debouncedPageFields.map((field) => ({
id: field.formId,
envelopeItemId: field.envelopeItemId,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
})),
);
for (const pair of pairs) {
formIds.add(pair.fieldA.id);
formIds.add(pair.fieldB.id);
}
return formIds;
}, [debouncedPageFields]);
/**
* Write the new geometry of a field back after a drag or resize gesture.
*/
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
const isDragEvent = event.type === 'dragend';
const fieldGroup = event.target as Konva.Group;
const fieldFormId = fieldGroup.id();
// Note: This values are scaled.
const {
width: fieldPixelWidth,
height: fieldPixelHeight,
x: fieldX,
y: fieldY,
} = fieldGroup.getClientRect({
skipStroke: true,
skipShadow: true,
});
const pageHeight = scaledViewport.height;
const pageWidth = scaledViewport.width;
// Calculate x and y as a percentage of the page width and height
const positionPercentX = (fieldX / pageWidth) * 100;
const positionPercentY = (fieldY / pageHeight) * 100;
// Get the bounds as a percentage of the page width and height
const fieldPageWidth = (fieldPixelWidth / pageWidth) * 100;
const fieldPageHeight = (fieldPixelHeight / pageHeight) * 100;
const fieldUpdates: Partial<TLocalField> = {
positionX: positionPercentX,
positionY: positionPercentY,
};
// Do not update the width/height unless the field has actually been resized.
// This is because our calculations will shift the width/height slightly
// due to the way we convert between pixel and percentage.
if (!isDragEvent) {
fieldUpdates.width = fieldPageWidth;
fieldUpdates.height = fieldPageHeight;
}
editorFields.updateFieldByFormId(fieldFormId, fieldUpdates);
// Select the field if it is not already selected.
if (isDragEvent && !selection.isSelected(fieldGroup)) {
selection.select('field', [fieldGroup]);
}
pageLayer.current?.batchDraw();
};
/**
* Draws (or removes) a dashed warning outline over a field that significantly
* overlaps another field. The highlight is a child of the field group so it moves
* and resizes with the field, and sits on top of the field's own rect (which is
* re-styled on every render and would otherwise clobber a direct stroke change).
*/
const syncOverlapHighlight = (fieldGroup: Konva.Group, isOverlapping: boolean) => {
const existingHighlight = fieldGroup.findOne('.field-overlap-highlight');
// Skip while a field is actively being dragged/resized. The highlight is driven
// by debounced field data, so it would lag behind and distort during the gesture.
// It is repainted once the gesture settles (the effect re-runs on isTransforming).
if (isTransforming || !isOverlapping) {
existingHighlight?.destroy();
return;
}
const fieldRect = fieldGroup.findOne('.field-rect');
if (!fieldRect) {
return;
}
const highlightAttrs = {
x: 0,
y: 0,
width: fieldRect.width(),
height: fieldRect.height(),
stroke: '#f59e0b',
strokeWidth: 2,
dash: [6, 4],
cornerRadius: 2,
strokeScaleEnabled: false,
listening: false,
} satisfies Partial<Konva.RectConfig>;
if (existingHighlight instanceof Konva.Rect) {
existingHighlight.setAttrs(highlightAttrs);
existingHighlight.moveToTop();
return;
}
const highlight = new Konva.Rect({
name: 'field-overlap-highlight',
...highlightAttrs,
});
fieldGroup.add(highlight);
highlight.moveToTop();
};
const unsafeRenderField = (field: TLocalField) => {
if (!pageLayer.current) {
return;
}
// Muted fields are rendered with the read-only styling and cannot be
// edited, mirroring how other recipients' fields look during signing.
const isMuted = fieldsVisibility === 'muted';
const recipient = envelope.recipients.find((r) => r.id === field.recipientId);
const isFieldEditable =
!isMuted && recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
const { fieldGroup } = renderField({
scale,
pageLayer: pageLayer.current,
field: {
renderId: field.formId,
...field,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
},
translations: getClientSideFieldTranslations(i18n),
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
color: isMuted ? 'readOnly' : getRecipientColorKey(field.recipientId),
editable: isFieldEditable,
mode: 'edit',
});
syncOverlapHighlight(fieldGroup, overlappingFieldFormIds.has(field.formId));
if (!isFieldEditable) {
return;
}
fieldGroup.off('click');
fieldGroup.off('transformend');
fieldGroup.off('dragend');
// A plain click selects just this field, shift + click toggles it in/out
// of the current selection.
fieldGroup.on('click', (event) => {
pending.clearPending();
if (event.evt.shiftKey) {
selection.toggle('field', fieldGroup);
} else {
selection.select('field', [fieldGroup]);
}
pageLayer.current?.batchDraw();
});
fieldGroup.on('transformend', handleResizeOrMove);
fieldGroup.on('dragend', handleResizeOrMove);
};
const renderFieldOnLayer = (field: TLocalField) => {
try {
unsafeRenderField(field);
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'editor',
location: 'envelope_page_render',
envelopeId: envelope.id,
});
setRenderError(true);
}
};
/**
* Render every field on the page. Called when the page canvas is created.
*/
const renderAll = () => {
for (const field of localPageFields) {
renderFieldOnLayer(field);
}
};
/**
* Resolve a marquee selection box into a field selection, or into a pending
* field creation when nothing was selected and the box is large enough.
*/
const selectInBox = (box: EnvelopeCanvasBox) => {
const currentStage = stage.current;
if (!currentStage) {
return;
}
// While fields are hidden they cannot be selected or created.
if (fieldsVisibility !== 'visible') {
return;
}
const groupsInBox = findEnvelopeCanvasGroupsInBox(currentStage, ENVELOPE_CANVAS_GROUP_NAMES.field, box);
selection.select('field', groupsInBox);
const unscaledBoxWidth = box.width / scale;
const unscaledBoxHeight = box.height / scale;
// Create a field if no items are selected or the size is too small.
if (
groupsInBox.length === 0 &&
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
editorFields.selectedRecipient &&
canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields)
) {
pending.setPendingFromBox(box);
}
};
/**
* Create a field of the given type from the pending creation rectangle.
*/
const createFromPending = (type: FieldType) => {
const box = pending.getPendingBox();
pending.clearPending();
if (!box || !currentEnvelopeItem || !editorFields.selectedRecipient) {
return;
}
editorFields.addField({
envelopeItemId: currentEnvelopeItem.id,
page: pageNumber,
type,
positionX: box.positionX,
positionY: box.positionY,
width: box.width,
height: box.height,
recipientId: editorFields.selectedRecipient.id,
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[type]),
});
};
/**
* Render fields when they are added, removed or updated.
*/
useEffect(() => {
const layer = pageLayer.current;
if (!layer || !stage.current) {
return;
}
reconcileEnvelopeCanvasGroups({
layer,
groupName: ENVELOPE_CANVAS_GROUP_NAMES.field,
items: localPageFields,
getRenderId: (field) => field.formId,
render: renderFieldOnLayer,
});
// Reconcile selection state with live field nodes after flush/sync updates.
const liveSelectedGroups = getLiveEnvelopeCanvasGroups(selectedGroups, isOnPage);
if (liveSelectedGroups.length !== selectedGroups.length) {
selection.select('field', liveSelectedGroups);
}
syncEditorSelectionToCanvas({
layer,
kind: 'field',
editorFormId: editorFields.selectedField?.formId ?? null,
isOnPage,
selectedGroups,
select: selection.select,
clear: selection.clear,
});
applyPageItemsVisibility();
selection.refreshTransformer();
layer.batchDraw();
}, [
localPageFields,
selectedGroups,
overlappingFieldFormIds,
isTransforming,
editorFields.selectedField?.formId,
fieldsVisibility,
]);
/**
* Clear any active selection and pending field creation when fields are no
* longer visible, since the transformer and floating toolbars would
* otherwise remain anchored to hidden fields.
*/
useEffect(() => {
if (fieldsVisibility !== 'visible' && (selectedGroups.length > 0 || pending.pendingCreation)) {
pending.clearPending();
selection.clear();
pageLayer.current?.batchDraw();
}
}, [fieldsVisibility, selectedGroups, pending.pendingCreation]);
const getSelectedFields = () =>
selectedGroups.map((group) => editorFields.getFieldByFormId(group.id())).filter((field) => field !== undefined);
const deleteSelected = () => {
editorFields.removeFieldsByFormId(selectedGroups.map((group) => group.id()));
selection.clear();
};
const duplicateSelected = () => {
for (const field of getSelectedFields()) {
editorFields.duplicateField(field);
}
};
const duplicateSelectedOnAllPages = () => {
for (const field of getSelectedFields()) {
editorFields.duplicateFieldToAllPages(field);
}
selection.clear();
};
const changeSelectedRecipient = (recipientId: number) => {
for (const field of getSelectedFields()) {
if (field.recipientId !== recipientId) {
editorFields.updateFieldByFormId(field.formId, { recipientId, id: undefined });
}
}
};
const changeSelectedType = (type: FieldType) => {
for (const field of getSelectedFields()) {
if (field.type !== type) {
editorFields.updateFieldByFormId(field.formId, {
type,
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[type]),
id: undefined,
});
}
}
};
return {
localPageFields,
renderAll,
selectInBox,
pendingCreation: pending.pendingCreation,
createFromPending,
clearPending: pending.clearPending,
deleteSelected,
duplicateSelected,
duplicateSelectedOnAllPages,
changeSelectedRecipient,
changeSelectedType,
};
};
@@ -0,0 +1,195 @@
import type { TLocalContent } from '@documenso/lib/client-only/hooks/use-editor-contents';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { EnvelopeContentType } from '@documenso/lib/types/envelope-content-meta';
import { resolveLineMetaFromPoints } from '@documenso/lib/universal/content-renderer/content-geometry';
import {
CONTENT_LINE_NODE_NAME,
calculateContentLineGeometry,
} from '@documenso/lib/universal/content-renderer/content-renderer';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import Konva from 'konva';
import { useEffect, useMemo, useState } from 'react';
import type { EnvelopeCanvas } from './envelope-canvas-types';
import type { EnvelopeCanvasSelectionApi } from './use-envelope-canvas-selection';
const LINE_ANCHOR_NODE_NAME = 'content-line-anchor';
/**
* The screen size radius of the line endpoint anchors.
*/
const LINE_ANCHOR_RADIUS = 5;
type UseEnvelopeCanvasLineAnchorsOptions = {
canvas: EnvelopeCanvas;
selection: EnvelopeCanvasSelectionApi;
isEditable: boolean;
localPageContents: TLocalContent[];
};
/**
* Draggable endpoint anchors for a single selected line content.
*
* Lines are move-only within the shared transformer, so the endpoints are
* edited through these custom anchors instead. Anchors are hidden while the
* line body itself is being dragged, and recreated afterwards.
*/
export const useEnvelopeCanvasLineAnchors = ({
canvas,
selection,
isEditable,
localPageContents,
}: UseEnvelopeCanvasLineAnchorsOptions) => {
const { editorContents } = useCurrentEnvelopeEditor();
const { pageLayer, scale, unscaledViewport, scaledViewport } = canvas;
const { contentGroups: selectedGroups, isTransforming } = selection;
/**
* Whether an anchor is being dragged.
*
* Tracked separately from the selection's `isTransforming` since that would
* destroy the anchors mid drag (this effect depends on it), whereas this only
* needs to hide the floating action bar.
*/
const [isDragging, setIsDragging] = useState(false);
/**
* The selected line content, if exactly one line content is selected.
*/
const selectedLineContent = useMemo((): TLocalContent | null => {
if (selectedGroups.length !== 1) {
return null;
}
const content = editorContents.getContentByFormId(selectedGroups[0].id());
return content?.contentMeta.type === EnvelopeContentType.LINE ? content : null;
// Depends on the contents themselves rather than the getter, which is
// stable, so the anchors follow the line as its coordinates change.
}, [selectedGroups, localPageContents, editorContents.getContentByFormId]);
useEffect(() => {
const layer = pageLayer.current;
if (!layer) {
return;
}
const destroyAnchors = () => {
for (const node of layer.find(`.${LINE_ANCHOR_NODE_NAME}`)) {
node.destroy();
}
};
destroyAnchors();
const contentMeta = selectedLineContent?.contentMeta;
if (!selectedLineContent || contentMeta?.type !== EnvelopeContentType.LINE || !isEditable || isTransforming) {
layer.batchDraw();
return;
}
const geometry = calculateContentLineGeometry(contentMeta, unscaledViewport.width, unscaledViewport.height);
const findLineNodes = () => {
const lineGroup = layer.findOne(`#${selectedLineContent.formId}`);
if (!(lineGroup instanceof Konva.Group)) {
return null;
}
const contentLine = lineGroup.findOne(`.${CONTENT_LINE_NODE_NAME}`);
if (!(contentLine instanceof Konva.Line)) {
return null;
}
return { lineGroup, contentLine };
};
const endpoints = [
{ pointIndex: 0, x: geometry.x + geometry.points[0], y: geometry.y + geometry.points[1] },
{ pointIndex: 2, x: geometry.x + geometry.points[2], y: geometry.y + geometry.points[3] },
];
for (const endpoint of endpoints) {
const anchor = new Konva.Circle({
name: LINE_ANCHOR_NODE_NAME,
x: endpoint.x,
y: endpoint.y,
// Compensate for the stage scale so the anchors keep a constant
// screen size, mirroring the transformer anchors.
radius: LINE_ANCHOR_RADIUS / scale,
fill: '#ffffff',
// Matches the transformer anchors, themed to the brand green.
stroke: getRecipientColorStyles('green').baseRing,
strokeWidth: 1.5,
strokeScaleEnabled: false,
draggable: true,
// Keep the anchor within the page bounds. Positions are in scaled
// stage coordinates.
dragBoundFunc: (pos) => ({
x: Math.max(0, Math.min(scaledViewport.width, pos.x)),
y: Math.max(0, Math.min(scaledViewport.height, pos.y)),
}),
});
anchor.on('dragstart', () => setIsDragging(true));
// Live preview while dragging the anchor.
anchor.on('dragmove', () => {
const lineNodes = findLineNodes();
if (!lineNodes) {
return;
}
const points = [...lineNodes.contentLine.points()];
points[endpoint.pointIndex] = anchor.x() - lineNodes.lineGroup.x();
points[endpoint.pointIndex + 1] = anchor.y() - lineNodes.lineGroup.y();
lineNodes.contentLine.points(points);
layer.batchDraw();
});
// Write the new endpoint positions back into the content meta. The
// reconcile effect re-renders the normalized geometry and recreates
// the anchors.
anchor.on('dragend', () => {
setIsDragging(false);
const lineNodes = findLineNodes();
if (!lineNodes) {
return;
}
editorContents.updateContentByFormId(selectedLineContent.formId, {
contentMeta: resolveLineMetaFromPoints(
contentMeta,
lineNodes.lineGroup.x(),
lineNodes.lineGroup.y(),
lineNodes.contentLine.points(),
unscaledViewport.width,
unscaledViewport.height,
),
});
});
layer.add(anchor);
anchor.moveToTop();
}
layer.batchDraw();
return () => {
destroyAnchors();
};
}, [selectedLineContent, isEditable, isTransforming, localPageContents, scale, scaledViewport]);
return { isDragging };
};
@@ -0,0 +1,201 @@
import { useLatestRef } from '@documenso/lib/client-only/hooks/use-latest-ref';
import { KONVA_SELECTION_FILL_COLOR } from '@documenso/lib/universal/konva/constants';
import Konva from 'konva';
import { useCallback } from 'react';
import { clamp } from 'remeda';
import type { EnvelopeCanvasBox } from './envelope-canvas-types';
type UseEnvelopeCanvasMarqueeOptions = {
/**
* Called when a marquee drag ends, with the selection box in scaled stage
* coordinates. The caller decides what the box selects.
*/
onSelect: (box: EnvelopeCanvasBox) => void;
/**
* Called when an empty area of the stage is clicked without dragging.
*/
onEmptyClick: () => void;
};
/**
* Drag-to-select marquee on an empty area of the stage.
*
* The callbacks are read through refs, so the once-bound stage handlers always
* delegate to the latest render's logic.
*/
export const useEnvelopeCanvasMarquee = ({ onSelect, onEmptyClick }: UseEnvelopeCanvasMarqueeOptions) => {
const onSelectRef = useLatestRef(onSelect);
const onEmptyClickRef = useLatestRef(onEmptyClick);
/**
* Bind the marquee to a stage. Called once when the page canvas is created.
*/
const bind = useCallback((stage: Konva.Stage, layer: Konva.Layer) => {
const selectionRectangle = new Konva.Rect({
name: 'marquee-selection',
fill: KONVA_SELECTION_FILL_COLOR,
visible: false,
});
layer.add(selectionRectangle);
let x1 = 0;
let y1 = 0;
/**
* The pointer in layer coordinates, pinned to the page.
*
* Pointer positions are in scaled stage coordinates, while the rectangle
* lives on the scaled layer, so positions are divided by the stage scale.
*
* The pointer is clamped to the page so the rectangle stops at the page
* edge while the mouse itself is free to leave it, matching how a
* transformer resize behaves.
*/
const getPointer = () => {
const pointerPosition = stage.getPointerPosition();
if (!pointerPosition) {
return null;
}
return {
x: clamp(pointerPosition.x / stage.scaleX(), { min: 0, max: stage.width() / stage.scaleX() }),
y: clamp(pointerPosition.y / stage.scaleY(), { min: 0, max: stage.height() / stage.scaleY() }),
};
};
// The stage only receives pointer events while the pointer is over its
// container, so a drag which leaves the page would freeze the rectangle
// and never finish. Like Konva's own transformer, the move and up events
// are tracked on the window for the duration of a drag instead.
const onWindowPointerMove = (evt: MouseEvent | TouchEvent) => {
// The stage is rebuilt on zoom, which throws away this rectangle.
if (!selectionRectangle.getStage()) {
stopTrackingWindow();
return;
}
// The stage cannot see the pointer once it is outside the container, so
// register its position from the window event.
stage.setPointersPositions(evt);
const pointer = getPointer();
if (!pointer) {
return;
}
selectionRectangle.moveToTop();
selectionRectangle.setAttrs({
x: Math.min(x1, pointer.x),
y: Math.min(y1, pointer.y),
width: Math.abs(pointer.x - x1),
height: Math.abs(pointer.y - y1),
});
};
/**
* Whether the pointer was actually dragged, as opposed to a plain click
* which leaves the rectangle without an area.
*/
const isMarqueeDrawn = () => {
return selectionRectangle.visible() && selectionRectangle.width() > 0 && selectionRectangle.height() > 0;
};
const onWindowPointerUp = () => {
stopTrackingWindow();
if (!selectionRectangle.getStage()) {
return;
}
// Hide in a timeout so the click handler below can still detect that a
// marquee drag just finished.
setTimeout(() => {
selectionRectangle.visible(false);
});
// A plain click is not a selection, the click handler below deals with
// it. Selecting here would also match anything whose bounding box merely
// contains the point (e.g. a diagonal line), and since this runs after
// other window listeners it would override e.g. a content being placed.
if (!isMarqueeDrawn()) {
return;
}
onSelectRef.current(selectionRectangle.getClientRect());
};
const startTrackingWindow = () => {
window.addEventListener('mousemove', onWindowPointerMove);
window.addEventListener('touchmove', onWindowPointerMove);
window.addEventListener('mouseup', onWindowPointerUp);
window.addEventListener('touchend', onWindowPointerUp);
window.addEventListener('touchcancel', onWindowPointerUp);
};
const stopTrackingWindow = () => {
window.removeEventListener('mousemove', onWindowPointerMove);
window.removeEventListener('touchmove', onWindowPointerMove);
window.removeEventListener('mouseup', onWindowPointerUp);
window.removeEventListener('touchend', onWindowPointerUp);
window.removeEventListener('touchcancel', onWindowPointerUp);
};
stage.on('mousedown.marquee touchstart.marquee', (e) => {
// Do nothing if the pointer is down on a shape.
if (e.target !== stage) {
return;
}
const pointer = getPointer();
if (!pointer) {
return;
}
x1 = pointer.x;
y1 = pointer.y;
selectionRectangle.setAttrs({
x: x1,
y: y1,
width: 0,
height: 0,
visible: true,
});
startTrackingWindow();
});
stage.on('click.marquee tap.marquee', (e) => {
// A marquee drag just finished, the selection was handled on mouse up.
if (isMarqueeDrawn()) {
return;
}
if (e.target === stage) {
onEmptyClickRef.current();
}
});
}, []);
return { bind };
};
/**
* Find the groups of a given name which intersect a box, excluding groups
* which are not draggable (i.e. not currently editable).
*/
export const findEnvelopeCanvasGroupsInBox = (stage: Konva.Stage, groupName: string, box: EnvelopeCanvasBox) => {
return stage
.find(`.${groupName}`)
.filter(
(node): node is Konva.Group =>
node instanceof Konva.Group && node.draggable() && Konva.Util.haveIntersection(box, node.getClientRect()),
);
};
@@ -0,0 +1,88 @@
import { KONVA_SELECTION_FILL_COLOR } from '@documenso/lib/universal/konva/constants';
import type { PercentageBox } from '@documenso/lib/utils/geometry';
import { toPercentageBox } from '@documenso/lib/utils/geometry';
import Konva from 'konva';
import { useState } from 'react';
import type { EnvelopeCanvas, EnvelopeCanvasBox } from './envelope-canvas-types';
type UseEnvelopeCanvasPendingCreationOptions = {
canvas: EnvelopeCanvas;
/**
* The Konva name given to the pending rectangle, so it can be found and
* removed from the layer again.
*/
nodeName: string;
};
/**
* The rectangle left on the page after a marquee is drawn over an empty area,
* which is pending a choice of what to create within it.
*
* Shared by the fields and contents layers, which each decide when a marquee
* becomes a pending creation and what gets created from it.
*/
export const useEnvelopeCanvasPendingCreation = ({ canvas, nodeName }: UseEnvelopeCanvasPendingCreationOptions) => {
const { pageLayer, scale, unscaledViewport } = canvas;
const [pendingCreation, setPendingCreation] = useState<Konva.Rect | null>(null);
/**
* Remove any pending creation rectangle from the canvas.
*/
const clearPending = () => {
setPendingCreation(null);
for (const node of pageLayer.current?.find(`.${nodeName}`) ?? []) {
node.destroy();
}
};
/**
* Draw the pending rectangle for a marquee box, given in scaled stage
* coordinates.
*/
const setPendingFromBox = (box: EnvelopeCanvasBox) => {
const layer = pageLayer.current;
if (!layer) {
return;
}
// The rectangle lives on the scaled layer, so the box is unscaled first.
const pendingRect = new Konva.Rect({
name: nodeName,
x: box.x / scale,
y: box.y / scale,
width: box.width / scale,
height: box.height / scale,
fill: KONVA_SELECTION_FILL_COLOR,
});
layer.add(pendingRect);
setPendingCreation(pendingRect);
};
/**
* The pending rectangle as a percentage box of the page, or null when
* nothing is pending.
*/
const getPendingBox = (): PercentageBox | null => {
if (!pendingCreation) {
return null;
}
return toPercentageBox(
{
x: pendingCreation.x(),
y: pendingCreation.y(),
width: pendingCreation.width(),
height: pendingCreation.height(),
},
unscaledViewport,
);
};
return { pendingCreation, clearPending, setPendingFromBox, getPendingBox };
};
@@ -0,0 +1,258 @@
import { useLatestRef } from '@documenso/lib/client-only/hooks/use-latest-ref';
import type { TransformerSelectionConfig } from '@documenso/lib/universal/konva/transformer';
import {
boundTransformerBoxToPage,
DEFAULT_TRANSFORMER_SELECTION_CONFIG,
} from '@documenso/lib/universal/konva/transformer';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import Konva from 'konva';
import type { Transformer } from 'konva/lib/shapes/Transformer';
import { useCallback, useMemo, useRef, useState } from 'react';
import type { EnvelopeCanvasSelection, EnvelopeCanvasSelectionKind } from './envelope-canvas-types';
import { ENVELOPE_CANVAS_GROUP_NAMES } from './envelope-canvas-types';
/**
* A stable empty array so consumers of a kind which is not selected keep a
* stable dependency and don't re-render on every selection change.
*/
const EMPTY_GROUPS: Konva.Group[] = [];
/** How far past a resize handle you can still grab it, in screen pixels. */
const TRANSFORMER_ANCHOR_HIT_STROKE_PX = 24;
type UseEnvelopeCanvasSelectionOptions = {
/**
* Resolve the transformer configuration for a selection.
*/
getTransformerConfig: (kind: EnvelopeCanvasSelectionKind, groups: Konva.Group[]) => TransformerSelectionConfig;
/**
* Called synchronously whenever the selection changes, so the editor
* selection can be kept in sync without a frame of drift.
*/
onChange: (selection: EnvelopeCanvasSelection) => void;
};
/**
* The single canvas selection shared by fields and contents, along with the
* Konva transformer which acts upon it.
*
* The exposed functions are stable and read their options through refs, so
* they are safe to call from Konva handlers bound once at stage creation.
*/
export const useEnvelopeCanvasSelection = ({ getTransformerConfig, onChange }: UseEnvelopeCanvasSelectionOptions) => {
const transformerRef = useRef<Transformer | null>(null);
const [selection, setSelectionState] = useState<EnvelopeCanvasSelection>(null);
/**
* Whether a selected item is being dragged, resized or rotated.
*/
const [isTransforming, setIsTransforming] = useState(false);
const getTransformerConfigRef = useLatestRef(getTransformerConfig);
const onChangeRef = useLatestRef(onChange);
const applyTransformerConfig = useCallback((transformer: Transformer, currentSelection: EnvelopeCanvasSelection) => {
const config = currentSelection
? getTransformerConfigRef.current(currentSelection.kind, currentSelection.groups)
: DEFAULT_TRANSFORMER_SELECTION_CONFIG;
transformer.enabledAnchors(config.enabledAnchors);
transformer.rotateEnabled(config.rotateEnabled);
transformer.keepRatio(config.keepRatio);
transformer.borderEnabled(config.borderEnabled);
}, []);
const selectionRef = useLatestRef(selection);
/**
* Re-sync the transformer after the items change without the selection
* changing: re-resolve its configuration (e.g. an image attached to the
* selected content locks its ratio) and raise it back above the items.
*
* The handles straddle the selected item's edges, so any item stacked above
* the transformer hides part of them and takes their clicks. Rendering
* appends new items on top and the contents stacking raises every content,
* so each reconcile calls this afterwards.
*/
const refreshTransformer = useCallback(() => {
const transformer = transformerRef.current;
if (!transformer) {
return;
}
applyTransformerConfig(transformer, selectionRef.current);
transformer.moveToTop();
transformer.forceUpdate();
}, [applyTransformerConfig]);
const applySelection = useCallback((nextSelection: EnvelopeCanvasSelection) => {
const transformer = transformerRef.current;
if (transformer) {
// Configure the transformer before assigning nodes, since assigning
// nodes triggers an update using the current configuration.
applyTransformerConfig(transformer, nextSelection);
transformer.nodes(nextSelection?.groups ?? []);
}
if (nextSelection?.groups.length === 1) {
nextSelection.groups[0].moveToTop();
}
// Above the item just raised, so its handles stay visible and clickable.
transformer?.moveToTop();
setSelectionState(nextSelection);
onChangeRef.current(nextSelection);
}, []);
const select = useCallback(
(kind: EnvelopeCanvasSelectionKind, nodes: Konva.Node[]) => {
const groupName = ENVELOPE_CANVAS_GROUP_NAMES[kind];
const groups = nodes.filter(
(node): node is Konva.Group =>
node instanceof Konva.Group &&
node.hasName(groupName) &&
Boolean(node.getStage()) &&
Boolean(node.getParent()),
);
applySelection(groups.length > 0 ? { kind, groups } : null);
},
[applySelection],
);
/**
* Toggle a node in or out of the current selection (shift-click semantics).
*
* Nodes of a different kind than the toggled node are dropped, since only
* one kind can be selected at a time.
*/
const toggle = useCallback(
(kind: EnvelopeCanvasSelectionKind, node: Konva.Node) => {
const currentNodes = transformerRef.current?.nodes() ?? [];
const isAlreadySelected = currentNodes.includes(node);
select(kind, isAlreadySelected ? currentNodes.filter((current) => current !== node) : [...currentNodes, node]);
},
[select],
);
const clear = useCallback(() => {
applySelection(null);
}, [applySelection]);
const isSelected = useCallback((node: Konva.Node) => {
return (transformerRef.current?.nodes() ?? []).includes(node);
}, []);
/**
* Re-resolve the selection against the groups currently on the layer.
*
* The stage is destroyed and rebuilt whenever the page is rescaled (e.g.
* zooming), which leaves the selection holding detached nodes: the handles
* disappear, the action bar positions itself from an empty rect, and the
* next reconcile drops the selection altogether. Groups keep their render
* ID across the rebuild, so the equivalent new groups are selected instead.
*/
const reattachSelection = useCallback(
(layer: Konva.Layer) => {
const currentSelection = selectionRef.current;
if (!currentSelection) {
return;
}
const selectedIds = new Set(currentSelection.groups.map((group) => group.id()));
const groups = layer
.find(`.${ENVELOPE_CANVAS_GROUP_NAMES[currentSelection.kind]}`)
.filter((group) => selectedIds.has(group.id()));
select(currentSelection.kind, groups);
},
[select],
);
/**
* Create the transformer on a layer. Called once when the page canvas is
* created, and again whenever the stage is recreated.
*/
const attach = useCallback((layer: Konva.Layer) => {
// Match the brand green used for the first recipient's fields rather than
// Konva's default blue.
const selectionColor = getRecipientColorStyles('green').baseRing;
const transformer = new Konva.Transformer({
...DEFAULT_TRANSFORMER_SELECTION_CONFIG,
rotationSnaps: [0, 45, 90, 135, 180, 225, 270, 315],
rotationSnapTolerance: 5,
keepRatio: false,
borderStroke: selectionColor,
anchorStroke: selectionColor,
ignoreStroke: true,
flipEnabled: false,
anchorStyleFunc: (anchor) => {
// The stage is scaled to the page, so the hit area is divided by that
// scale to stay a constant size on screen.
const stageScale = layer.getStage()?.scaleX() ?? 1;
anchor.hitStrokeWidth(TRANSFORMER_ANCHOR_HIT_STROKE_PX / stageScale);
},
boundBoxFunc: (oldBox, newBox) => {
// Boxes are in absolute stage coordinates and the stage is the page,
// so pin the resize to the stage's own size.
const stage = layer.getStage();
const bounded = stage
? boundTransformerBoxToPage(newBox, { width: stage.width(), height: stage.height() })
: newBox;
// Enforce minimum size
if (bounded.width < 30 || bounded.height < 20) {
return oldBox;
}
return bounded;
},
});
layer.add(transformer);
// Konva fires transform events (resize and rotate) directly on the
// transformer and its nodes without bubbling, so they cannot be observed
// on the stage.
transformer.on('transformstart', () => setIsTransforming(true));
transformer.on('transformend', () => setIsTransforming(false));
transformerRef.current = transformer;
return transformer;
}, []);
const fieldGroups = useMemo(() => (selection?.kind === 'field' ? selection.groups : EMPTY_GROUPS), [selection]);
const contentGroups = useMemo(() => (selection?.kind === 'content' ? selection.groups : EMPTY_GROUPS), [selection]);
return {
selection,
fieldGroups,
contentGroups,
isTransforming,
setIsTransforming,
attach,
reattachSelection,
select,
toggle,
clear,
isSelected,
refreshTransformer,
};
};
export type EnvelopeCanvasSelectionApi = ReturnType<typeof useEnvelopeCanvasSelection>;
@@ -0,0 +1,323 @@
import { decodeLocalContentImage } from '@documenso/lib/client-only/load-content-image';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import {
APP_CONTENT_IMAGE_MIME_TYPES,
APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT,
} from '@documenso/lib/constants/envelope-content';
import { AppError } from '@documenso/lib/errors/app-error';
import { EnvelopeContentType } from '@documenso/lib/types/envelope-content-meta';
import { resolveAttachedImageBox } from '@documenso/lib/universal/content-renderer/content-image-box';
import { nanoid } from '@documenso/lib/universal/id';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { PRESIGNED_DATA_CONTENT_ID_PREFIX } from '@documenso/lib/utils/embed-config';
import type { Size } from '@documenso/lib/utils/geometry';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter } from '@documenso/ui/primitives/dialog';
import { Trans, useLingui } from '@lingui/react/macro';
import { Loader2Icon } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { createCallable } from 'react-call';
import { match } from 'ts-pattern';
/**
* The DOM id of the dialog's file input, so the e2e suite can find it.
*/
export const getContentImageInputId = (formId: string) => `content-image-input-${formId}`;
type ContentImageUploadDialogProps = {
formId: string;
/**
* A file to upload straight away, e.g. one dropped onto the settings
* panel. Without it the file picker is opened first.
*/
file?: File;
};
type UploadStatus = 'picking' | 'uploading' | 'invalid-image' | 'too-large' | 'failed';
/**
* Why the server would reject the file, if it would, so the user finds out
* before it is uploaded. Applies to dropped and picked files alike.
*/
const getRejectionReason = (file: File): 'too-large' | 'invalid-image' | null => {
if (file.size > megabytesToBytes(APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT)) {
return 'too-large';
}
if (!APP_CONTENT_IMAGE_MIME_TYPES.some((accepted) => accepted === file.type)) {
return 'invalid-image';
}
return null;
};
/**
* Attach an image to an image content.
*
* Blocks the editor from the moment it is opened until the upload settles,
* so there is only ever one upload in flight. Pending autosaves are flushed
* before uploading, so the content always has a server id by then.
*
* Invoke with `ContentImageUploadDialog.call({ formId })`; `Root` must be
* mounted inside the envelope editor and render providers.
*/
export const ContentImageUploadDialog = createCallable<ContentImageUploadDialogProps, void>(
({ call, formId, file: droppedFile }) => {
const { t } = useLingui();
const { editorContents, flushAutosave, envelope, isEmbedded } = useCurrentEnvelopeEditor();
const { contentImages, pageSizes } = useCurrentEnvelopeRender();
const { mutateAsync: uploadImage } = trpc.envelope.content.uploadImage.useMutation();
const inputRef = useRef<HTMLInputElement>(null);
const [status, setStatus] = useState<UploadStatus>(() => {
if (!droppedFile) {
return 'picking';
}
return getRejectionReason(droppedFile) ?? 'uploading';
});
/**
* Point the content at its new image, fitting its box to the image.
*
* `data` is the file of an image which has not been uploaded yet.
*/
const attachImage = (dataContentId: string, image: Size, data?: File) => {
// Re-read in case the content moved while the image was on its way.
const latest = editorContents.getContentByFormId(formId);
if (!latest || latest.contentMeta.type !== EnvelopeContentType.IMAGE) {
return;
}
const { contentMeta } = latest;
const page = pageSizes.get(latest.envelopeItemId, contentMeta.page);
const box = page
? resolveAttachedImageBox({
image,
page,
box: {
positionX: contentMeta.positionX,
positionY: contentMeta.positionY,
width: contentMeta.width,
height: contentMeta.height,
},
})
: null;
editorContents.updateContentByFormId(formId, {
dataContentId,
data,
contentMeta: box ? { ...contentMeta, ...box } : contentMeta,
});
};
const uploadToServer = async (file: File) => {
// The content must exist on the server before an image can be
// attached to it.
await flushAutosave();
const content = editorContents.getContentByFormId(formId);
if (!content?.id || content.contentMeta.type !== EnvelopeContentType.IMAGE) {
throw new AppError('CONTENT_NOT_PERSISTED');
}
const formData = new FormData();
formData.append('payload', JSON.stringify({ envelopeId: envelope.id, envelopeContentId: content.id }));
formData.append('file', file);
const { dataContent } = await uploadImage(formData);
// Decode the local file at the size the server normalized it to, so
// the preview matches what the route would serve.
const image = await createImageBitmap(file, {
resizeWidth: dataContent.metadata.width,
resizeHeight: dataContent.metadata.height,
resizeQuality: 'high',
}).catch(() => null);
if (image) {
contentImages.setImage(dataContent.id, image, {
fileSize: dataContent.metadata.fileSize,
});
}
attachImage(dataContent.id, dataContent.metadata);
};
const upload = async (file: File) => {
setStatus('uploading');
try {
if (isEmbedded) {
// Decoded the way the server will store it, which also rejects what
// the server would, since it only sees the image once the envelope is
// saved.
const { image, details } = await decodeLocalContentImage(file);
const dataContentId = `${PRESIGNED_DATA_CONTENT_ID_PREFIX}${nanoid()}`;
contentImages.setImage(dataContentId, image, details);
attachImage(dataContentId, image, file);
} else {
await uploadToServer(file);
}
call.end();
} catch (err) {
const error = AppError.parseError(err);
setStatus(
match(error.code)
.with('INVALID_IMAGE_FILE', (): UploadStatus => 'invalid-image')
.with('FILE_TOO_LARGE', (): UploadStatus => 'too-large')
.otherwise((): UploadStatus => 'failed'),
);
}
};
// Dismissing the picker without choosing closes the dialog too. Browsers
// fire `cancel` on the input for this, but React does not expose it as a
// prop, so it is attached natively.
useEffect(() => {
const input = inputRef.current;
if (!input) {
return;
}
const onCancel = () => call.end();
input.addEventListener('cancel', onCancel);
return () => input.removeEventListener('cancel', onCancel);
}, [call]);
// Start immediately: upload the dropped file, or open the picker so the
// user does not have to click twice. A dropped file the server would
// reject opens on the reason instead.
const hasStartedRef = useRef(false);
useEffect(() => {
if (hasStartedRef.current) {
return;
}
hasStartedRef.current = true;
if (!droppedFile) {
inputRef.current?.click();
return;
}
if (!getRejectionReason(droppedFile)) {
void upload(droppedFile);
}
}, []);
const onFilePicked = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
// Allow the same file to be picked again after a failure.
event.target.value = '';
if (!file) {
return;
}
const rejectionReason = getRejectionReason(file);
if (rejectionReason) {
setStatus(rejectionReason);
return;
}
void upload(file);
};
// Clicked directly rather than via state, since the status may already
// be `picking` and a state change alone would not reopen the picker.
const reopenPicker = () => {
setStatus('picking');
inputRef.current?.click();
};
const isBusy = status === 'uploading';
return (
<>
<input
ref={inputRef}
id={getContentImageInputId(formId)}
type="file"
accept={APP_CONTENT_IMAGE_MIME_TYPES.join(',')}
className="hidden"
onChange={onFilePicked}
/>
<Dialog open={true} onOpenChange={(open) => !open && !isBusy && call.end()}>
<DialogContent
className={cn('max-w-xs', {
hidden: status === 'picking',
})}
hideClose
onPointerDownOutside={(event) => isBusy && event.preventDefault()}
onEscapeKeyDown={(event) => isBusy && event.preventDefault()}
>
{match(status)
.with('picking', () => null) // Only show the overlay since the actual browser uploader should be shown.
.with('uploading', () => (
<div
className="flex flex-col items-center justify-center gap-2 py-4"
data-testid="content-image-uploading"
>
<Loader2Icon className="h-5 w-5 animate-spin text-muted-foreground" />
<DialogDescription>
<Trans>Uploading Image</Trans>
</DialogDescription>
</div>
))
.with('invalid-image', () => (
<DialogDescription className="text-destructive">
<Trans>This image could not be read. Use a PNG, JPEG or WebP file.</Trans>
</DialogDescription>
))
.with('too-large', () => (
<DialogDescription className="text-destructive">
<Trans>This image is larger than {APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT} MB.</Trans>
</DialogDescription>
))
.with('failed', () => (
<DialogDescription className="text-destructive">
<Trans>The image could not be uploaded. Please try again.</Trans>
</DialogDescription>
))
.exhaustive()}
{!isBusy && (
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => call.end()}>
<Trans>Close</Trans>
</Button>
<Button type="button" onClick={reopenPicker}>
{status === 'picking' ? t`Choose image` : t`Try again`}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
</>
);
},
);
@@ -0,0 +1,246 @@
import type { TLocalContent } from '@documenso/lib/client-only/hooks/use-editor-contents';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import {
CONTENT_HIGHLIGHT_META_DEFAULT_VALUES,
CONTENT_LINE_META_DEFAULT_VALUES,
CONTENT_SHAPE_META_DEFAULT_VALUES_BY_SHAPE,
CONTENT_TEXT_META_DEFAULT_VALUES,
EnvelopeContentType,
type TEnvelopeContentMeta,
ZContentHighlightMetaSchema,
ZContentLineMetaSchema,
ZContentShapeMetaSchema,
ZContentTextMetaSchema,
} from '@documenso/lib/types/envelope-content-meta';
import { Form } from '@documenso/ui/primitives/form/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { createContext, useContext, useLayoutEffect, useState } from 'react';
import { type FieldValues, useForm, useFormContext } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
type ContentSettingsFormProviderProps = {
/**
* The content whose settings the form edits, or null when none is selected.
*/
content: TLocalContent | null;
children: React.ReactNode;
};
/**
* Hosts the single settings form for the selected content.
*
* Every surface which edits a content's presentational meta (the sidebar
* settings panel, the canvas action bar) binds to this one form via
* `useContentSettingsForm`, so there is only ever one copy of the values and
* nothing to keep in sync. The form is the sole writer of those keys onto the
* store; geometry is owned by the canvas and is never part of the form.
*
* The form's values are written to the store whenever they change while
* valid, merged onto the content's latest meta.
*
* The provider wraps the whole editor surface (including the PDF viewer), so
* it must not remount when the selection changes. Instead the one form is
* reset to the newly selected content's values.
*/
export const ContentSettingsFormProvider = ({ content, children }: ContentSettingsFormProviderProps) => {
const { editorContents } = useCurrentEnvelopeEditor();
const config = getContentSettingsFormConfig(content?.contentMeta ?? null);
// The resolver is read on every render, so the schema follows the selected
// content's type. The default values are only used for the initial mount;
// later selections are applied via `reset` below.
const form = useForm<FieldValues>({
resolver: zodResolver(config.schema),
mode: 'onChange',
defaultValues: config.defaultValues,
});
const formId = content?.formId ?? null;
/**
* The content the form currently holds the values of.
*
* The reset below runs after the render which changed the selection, so
* for that one render the form still holds the previous content's values.
* Inputs must not mount during it: a Radix select which mounts with one
* value and is immediately given another pushes an empty string back into
* the form (its hidden native select has no options yet), which surfaces
* as a spurious validation error.
*/
const [readyFormId, setReadyFormId] = useState<string | null>(null);
// Reset then subscribe, in that order, so the reset is not observed as an
// edit and selecting a content never writes anything by itself. A layout
// effect so the fields beneath never paint the previous content's values.
useLayoutEffect(() => {
form.reset(config.defaultValues);
setReadyFormId(formId);
if (!formId) {
return;
}
const subscription = form.watch((values) => {
const parsed = config.schema.safeParse(values);
if (!parsed.success) {
return;
}
editorContents.patchContentMeta(formId, parsed.data);
});
return () => subscription.unsubscribe();
// Only the selected content matters; its config is derived from it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form, formId, editorContents.patchContentMeta]);
const isReady = formId !== null && formId === readyFormId;
return (
<ContentSettingsFormContext.Provider value={{ content, isReady }}>
<Form {...form}>{children}</Form>
</ContentSettingsFormContext.Provider>
);
};
/**
* The settings form of the selected content, for the surfaces which edit it.
*
* Typed by the caller since the form's shape depends on the content type,
* which the caller has already narrowed on.
*/
export const useContentSettingsForm = <TForm extends FieldValues>() => {
const context = useContext(ContentSettingsFormContext);
if (!context) {
throw new Error('useContentSettingsForm must be used within a ContentSettingsFormProvider');
}
const form = useFormContext<TForm>();
return {
form,
/**
* The content the form is bound to, so a consumer holding a different
* content (e.g. a stale canvas selection) can tell it is not this one.
*/
content: context.content,
/**
* Whether the form holds the selected content's values yet. Inputs must
* only be mounted once it does, see the provider.
*/
isReady: context.isReady,
};
};
type ContentSettingsFormContextValue = {
content: TLocalContent | null;
isReady: boolean;
};
const ContentSettingsFormContext = createContext<ContentSettingsFormContextValue | null>(null);
type ContentSettingsFormConfig = {
schema: z.ZodType<Partial<TEnvelopeContentMeta>>;
defaultValues: FieldValues;
};
/**
* Only the presentational settings are editable in the form. Geometry (page,
* position, size and endpoints) is managed on the canvas.
*/
export const ZContentTextFormSchema = ZContentTextMetaSchema.pick({
text: true,
fontSize: true,
textAlign: true,
verticalAlign: true,
lineHeight: true,
letterSpacing: true,
color: true,
});
export type TContentTextFormSchema = z.infer<typeof ZContentTextFormSchema>;
export const ZContentLineFormSchema = ZContentLineMetaSchema.pick({
strokeWidth: true,
strokeColor: true,
strokeStyle: true,
});
export type TContentLineFormSchema = z.infer<typeof ZContentLineFormSchema>;
export const ZContentShapeFormSchema = ZContentShapeMetaSchema.pick({
strokeWidth: true,
strokeColor: true,
strokeStyle: true,
fillColor: true,
fillOpacity: true,
});
export type TContentShapeFormSchema = z.infer<typeof ZContentShapeFormSchema>;
export const ZContentHighlightFormSchema = ZContentHighlightMetaSchema.pick({
color: true,
fillOpacity: true,
});
export type TContentHighlightFormSchema = z.infer<typeof ZContentHighlightFormSchema>;
/**
* Contents without settings (images, or no selection) get an empty form so
* the form context always exists for whatever is mounted beneath it.
*/
const ZEmptyFormSchema = z.object({});
/**
* The schema and initial values of the settings form for a content, by type.
*/
const getContentSettingsFormConfig = (meta: TEnvelopeContentMeta | null): ContentSettingsFormConfig => {
if (!meta) {
return { schema: ZEmptyFormSchema, defaultValues: {} };
}
return match(meta)
.with({ type: EnvelopeContentType.TEXT }, (value) =>
createFormConfig(ZContentTextFormSchema, CONTENT_TEXT_META_DEFAULT_VALUES, value),
)
.with({ type: EnvelopeContentType.LINE }, (value) =>
createFormConfig(ZContentLineFormSchema, CONTENT_LINE_META_DEFAULT_VALUES, value),
)
.with({ type: EnvelopeContentType.SHAPE }, (value) =>
createFormConfig(ZContentShapeFormSchema, CONTENT_SHAPE_META_DEFAULT_VALUES_BY_SHAPE[value.shape], value),
)
.with({ type: EnvelopeContentType.HIGHLIGHT }, (value) =>
createFormConfig(ZContentHighlightFormSchema, CONTENT_HIGHLIGHT_META_DEFAULT_VALUES, value),
)
.with({ type: EnvelopeContentType.IMAGE }, () => ({ schema: ZEmptyFormSchema, defaultValues: {} }))
.exhaustive();
};
/**
* The form's initial values are the content's meta laid over the type's
* default meta, narrowed to the form's keys by the schema (which strips the
* rest, e.g. geometry).
*/
const createFormConfig = <TSchema extends z.ZodType<Partial<TEnvelopeContentMeta>>>(
schema: TSchema,
defaults: TEnvelopeContentMeta,
meta: TEnvelopeContentMeta,
): ContentSettingsFormConfig => {
const merged = { ...defaults, ...meta };
const parsed = schema.safeParse(merged);
return {
schema,
// The stored meta was validated on load, so this only falls back if the
// defaults themselves are ever inconsistent with the form schema.
defaultValues: parsed.success ? parsed.data : schema.parse(defaults),
};
};
@@ -0,0 +1,411 @@
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { useLatestRef } from '@documenso/lib/client-only/hooks/use-latest-ref';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import {
CONTENT_HIGHLIGHT_META_DEFAULT_VALUES,
CONTENT_IMAGE_META_DEFAULT_VALUES,
CONTENT_LINE_META_DEFAULT_VALUES,
CONTENT_SHAPE_META_DEFAULT_VALUES_BY_SHAPE,
CONTENT_TEXT_META_DEFAULT_VALUES,
CONTENT_TYPE_DATA_CONTENT_TYPE,
EnvelopeContentShapeType,
EnvelopeContentType,
type TEnvelopeContentMeta,
} from '@documenso/lib/types/envelope-content-meta';
import { CONTENT_IMAGE_DEFAULT_SIZE } from '@documenso/lib/universal/content-renderer/content-image-box';
import { canContentBeChanged } from '@documenso/lib/utils/envelope';
import { resolveEnvelopeContentLimits } from '@documenso/lib/utils/envelope-content';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import type { LucideIcon } from 'lucide-react';
import { HighlighterIcon, ImageIcon, ScanLineIcon, SquareIcon, TextIcon } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { match } from 'ts-pattern';
const MIN_HEIGHT_PX = 12;
const MIN_WIDTH_PX = 36;
const DEFAULT_HEIGHT_PX = MIN_HEIGHT_PX * 2.5;
const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
export type ContentDragDropItem = {
key: string;
type: EnvelopeContentType;
shape?: EnvelopeContentShapeType;
icon: LucideIcon;
name: MessageDescriptor;
};
export const contentButtonList: ContentDragDropItem[] = [
{
key: EnvelopeContentType.TEXT,
type: EnvelopeContentType.TEXT,
icon: TextIcon,
name: msg`Text`,
},
{
key: EnvelopeContentType.LINE,
type: EnvelopeContentType.LINE,
icon: ScanLineIcon,
name: msg`Line`,
},
{
key: `${EnvelopeContentType.SHAPE}:${EnvelopeContentShapeType.RECTANGLE}`,
type: EnvelopeContentType.SHAPE,
shape: EnvelopeContentShapeType.RECTANGLE,
icon: SquareIcon,
name: msg`Rectangle`,
},
{
key: EnvelopeContentType.HIGHLIGHT,
type: EnvelopeContentType.HIGHLIGHT,
icon: HighlighterIcon,
name: msg`Highlight`,
},
{
key: EnvelopeContentType.IMAGE,
type: EnvelopeContentType.IMAGE,
icon: ImageIcon,
name: msg`Image`,
},
];
type EnvelopeEditorContentDragDropProps = {
selectedEnvelopeItemId: string | null;
};
export const EnvelopeEditorContentDragDrop = ({ selectedEnvelopeItemId }: EnvelopeEditorContentDragDropProps) => {
const { envelope, editorContents, setIsPlacingItem } = useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation();
const { t } = useLingui();
const contentLimits = resolveEnvelopeContentLimits(
editorContents.localContents.map((content) => content.contentMeta.type),
organisation.organisationClaim,
);
const [selectedContent, setSelectedContent] = useState<ContentDragDropItem | null>(null);
// Let the canvas know a content is being placed, so it can get its selection
// out of the way of the placement click.
useEffect(() => {
setIsPlacingItem(selectedContent !== null);
return () => {
setIsPlacingItem(false);
};
}, [selectedContent, setIsPlacingItem]);
const { isWithinPageBounds, getPage } = useDocumentElement();
const [isContentWithinBounds, setIsContentWithinBounds] = useState(false);
const [coords, setCoords] = useState({
x: 0,
y: 0,
});
const contentBounds = useRef({
height: 0,
width: 0,
});
const onMouseMove = useCallback(
(event: MouseEvent) => {
setIsContentWithinBounds(
isWithinPageBounds(event, PDF_VIEWER_PAGE_SELECTOR, contentBounds.current.width, contentBounds.current.height),
);
setCoords({
x: event.clientX - contentBounds.current.width / 2,
y: event.clientY - contentBounds.current.height / 2,
});
},
[isWithinPageBounds],
);
const onMouseClick = useCallback(
(event: MouseEvent) => {
if (!selectedContent || !selectedEnvelopeItemId) {
return;
}
const $page = getPage(event, PDF_VIEWER_PAGE_SELECTOR);
if (
!$page ||
!isWithinPageBounds(event, PDF_VIEWER_PAGE_SELECTOR, contentBounds.current.width, contentBounds.current.height)
) {
setSelectedContent(null);
return;
}
const { top, left, height, width } = getBoundingClientRect($page);
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
// Calculate x and y as a percentage of the page width and height
let pageX = ((event.pageX - left) / width) * 100;
let pageY = ((event.pageY - top) / height) * 100;
// Get the bounds as a percentage of the page width and height
const contentPageWidth = (contentBounds.current.width / width) * 100;
const contentPageHeight = (contentBounds.current.height / height) * 100;
// And center it based on the bounds
pageX -= contentPageWidth / 2;
pageY -= contentPageHeight / 2;
editorContents.addContent({
envelopeItemId: selectedEnvelopeItemId,
contentMeta: buildContentMeta({
item: selectedContent,
page: pageNumber,
positionX: pageX,
positionY: pageY,
width: contentPageWidth,
height: contentPageHeight,
}),
});
setIsContentWithinBounds(false);
setSelectedContent(null);
},
[isWithinPageBounds, selectedContent, selectedEnvelopeItemId, getPage, editorContents],
);
const selectedContentRef = useLatestRef(selectedContent);
useEffect(() => {
const observer = new MutationObserver((_mutations) => {
const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
if (!$page) {
return;
}
contentBounds.current = resolveDragBounds(selectedContentRef.current, $page);
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
};
}, []);
useEffect(() => {
if (selectedContent) {
const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
if ($page) {
contentBounds.current = resolveDragBounds(selectedContent, $page);
}
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseClick);
}
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseClick);
};
}, [onMouseClick, onMouseMove, selectedContent]);
if (!canContentBeChanged(envelope)) {
return (
<Alert variant="neutral" className="rounded-lg border border-border">
<AlertDescription className="text-sm">
{match(envelope.status)
.with(DocumentStatus.COMPLETED, () => (
<Trans>This document has been completed, so its content can no longer be changed.</Trans>
))
.with(DocumentStatus.REJECTED, () => (
<Trans>This document has been rejected, so its content can no longer be changed.</Trans>
))
.otherwise(() => (
<Trans>Content cannot be changed because the document has already been sent.</Trans>
))}
</AlertDescription>
</Alert>
);
}
// The organisation's plan caps how much content an envelope may hold. Only
// adding is blocked: existing contents stay editable and removable so an
// envelope which went over its limit can be brought back down.
if (contentLimits.isContentLimitReached || contentLimits.isImageLimitReached) {
return (
<Alert variant="neutral" className="rounded-lg border border-border" data-testid="content-limit-reached-alert">
<AlertDescription className="text-sm">
{contentLimits.isContentLimitReached ? (
<Plural
value={contentLimits.contentLimit}
one="This envelope cannot have more than # content. Remove some, or contact support if you need more."
other="This envelope cannot have more than # contents. Remove some, or contact support if you need more."
/>
) : (
<Plural
value={contentLimits.imageLimit}
one="This envelope cannot have more than # image content. Remove some, or contact support if you need more."
other="This envelope cannot have more than # image contents. Remove some, or contact support if you need more."
/>
)}
</AlertDescription>
</Alert>
);
}
return (
<>
<div className="grid grid-cols-2 gap-x-2 gap-y-2.5">
{contentButtonList.map((content) => (
<button
key={content.key}
type="button"
onClick={() => setSelectedContent(content)}
onMouseDown={() => setSelectedContent(content)}
data-selected={selectedContent?.key === content.key ? true : undefined}
className="group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-border px-4 transition-colors"
>
<p className="flex items-center justify-center gap-x-1.5 font-normal font-noto text-muted-foreground text-sm group-data-[selected]:text-foreground">
{<content.icon className="h-4 w-4" />}
{t(content.name)}
</p>
</button>
))}
</div>
{selectedContent && (
<div
className={cn(
'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] dark:text-muted',
// Match the brand green used for the first recipient's fields and
// the rest of the content editor's selection affordances.
getRecipientColorStyles('green').base,
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isContentWithinBounds,
'dark:text-black/60': isContentWithinBounds,
},
)}
style={{
top: coords.y,
left: coords.x,
height: contentBounds.current.height,
width: contentBounds.current.width,
}}
>
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{t(selectedContent.name)}</span>
</div>
)}
</>
);
};
type BuildContentMetaOptions = {
item: ContentDragDropItem;
page: number;
positionX: number;
positionY: number;
width: number;
height: number;
};
/**
* Build the content meta for a newly dropped content, merging the drop
* geometry into the default values for the given content type.
*
* Geometry is handled per content type since it differs, e.g. lines use
* start/end coordinates instead of a position and size.
*/
/**
* The screen size of the drag preview for a content type.
*
* Image contents preview at their page relative default size so the preview
* matches the box which gets created, the rest use a fixed screen size.
*/
const resolveDragBounds = (item: ContentDragDropItem | null, $page: Element) => {
if (item !== null && CONTENT_TYPE_DATA_CONTENT_TYPE[item.type] !== undefined) {
const { width, height } = getBoundingClientRect($page);
return {
width: (width * CONTENT_IMAGE_DEFAULT_SIZE.width) / 100,
height: (height * CONTENT_IMAGE_DEFAULT_SIZE.height) / 100,
};
}
return {
width: DEFAULT_WIDTH_PX,
height: DEFAULT_HEIGHT_PX,
};
};
const buildContentMeta = ({
item,
page,
positionX,
positionY,
width,
height,
}: BuildContentMetaOptions): TEnvelopeContentMeta => {
const { type } = item;
return match(type)
.with(EnvelopeContentType.LINE, () => ({
...structuredClone(CONTENT_LINE_META_DEFAULT_VALUES),
page,
// Lines are placed horizontally across the drop area, centered vertically.
x1: positionX,
x2: positionX + width,
y1: positionY + height / 2,
y2: positionY + height / 2,
}))
.with(EnvelopeContentType.IMAGE, () => ({
...structuredClone(CONTENT_IMAGE_META_DEFAULT_VALUES),
page,
// Image contents use a page relative default size, so whether the
// author has resized the box can be told when an image is attached.
// Centered on the drop point like the other contents.
positionX: positionX + width / 2 - CONTENT_IMAGE_DEFAULT_SIZE.width / 2,
positionY: positionY + height / 2 - CONTENT_IMAGE_DEFAULT_SIZE.height / 2,
...CONTENT_IMAGE_DEFAULT_SIZE,
}))
.with(EnvelopeContentType.SHAPE, () => ({
...structuredClone(CONTENT_SHAPE_META_DEFAULT_VALUES_BY_SHAPE[item.shape ?? EnvelopeContentShapeType.RECTANGLE]),
page,
positionX,
positionY,
width,
height,
}))
.with(EnvelopeContentType.TEXT, () => ({
...structuredClone(CONTENT_TEXT_META_DEFAULT_VALUES),
text: '',
page,
positionX,
positionY,
width,
height,
}))
.with(EnvelopeContentType.HIGHLIGHT, () => ({
...structuredClone(CONTENT_HIGHLIGHT_META_DEFAULT_VALUES),
page,
positionX,
positionY,
width,
height,
}))
.exhaustive();
};
@@ -94,12 +94,22 @@ export const EnvelopeEditorFieldDragDrop = ({
selectedRecipientId,
selectedEnvelopeItemId,
}: EnvelopeEditorFieldDragDropProps) => {
const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { envelope, editorFields, isTemplate, getRecipientColorKey, setIsPlacingItem } = useCurrentEnvelopeEditor();
const { t } = useLingui();
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
// Let the canvas know a field is being placed, so it can get its selection
// out of the way of the placement click.
useEffect(() => {
setIsPlacingItem(selectedField !== null);
return () => {
setIsPlacingItem(false);
};
}, [selectedField, setIsPlacingItem]);
const { isWithinPageBounds, getPage } = useDocumentElement();
const isFieldsDisabled = useMemo(() => {
@@ -1,8 +1,10 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import type { EnvelopeEditorTab } from '@documenso/lib/client-only/providers/envelope-editor-provider';
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 { EnvelopeContentType } from '@documenso/lib/types/envelope-content-meta';
import {
FIELD_META_DEFAULT_VALUES,
type TCheckboxFieldMeta,
@@ -25,20 +27,33 @@ import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { Tabs, TabsContent } from '@documenso/ui/primitives/tabs';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { TabsList, TabsTrigger } from '@radix-ui/react-tabs';
import {
AlertTriangleIcon,
FileTextIcon,
LayoutGridIcon,
MousePointerIcon,
PencilIcon,
SparklesIcon,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
import { AiFieldDetectionDialog } from '~/components/dialogs/ai-field-detection-dialog';
import { EnvelopeItemEditDialog } from '~/components/dialogs/envelope-item-edit-dialog';
import { EditorContentHighlightForm } from '~/components/forms/editor/editor-content-highlight-form';
import { EditorContentImageSettings } from '~/components/forms/editor/editor-content-image-settings';
import { EditorContentLineForm } from '~/components/forms/editor/editor-content-line-form';
import { EditorContentShapeForm } from '~/components/forms/editor/editor-content-shape-form';
import { EditorContentTextForm } from '~/components/forms/editor/editor-content-text-form';
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
import { EditorFieldDropdownForm } from '~/components/forms/editor/editor-field-dropdown-form';
@@ -51,7 +66,9 @@ import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field
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 { ContentImageUploadDialog } from './content-image-upload-dialog';
import { ContentSettingsFormProvider } from './content-settings-form-provider';
import { EnvelopeEditorContentDragDrop } from './envelope-editor-content-drag-drop';
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
import { EnvelopeEditorFieldsPageRenderer } from './envelope-editor-fields-page-renderer';
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
@@ -72,6 +89,14 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
[FieldType.DROPDOWN]: msg`Dropdown Settings`,
};
const ContentSettingsTypeTranslations: Record<EnvelopeContentType, MessageDescriptor> = {
[EnvelopeContentType.TEXT]: msg`Text Settings`,
[EnvelopeContentType.LINE]: msg`Line Settings`,
[EnvelopeContentType.SHAPE]: msg`Shape Settings`,
[EnvelopeContentType.HIGHLIGHT]: msg`Highlight Settings`,
[EnvelopeContentType.IMAGE]: msg`Image Settings`,
};
export const EnvelopeEditorFieldsPage = () => {
const [searchParams] = useSearchParams();
@@ -79,12 +104,33 @@ export const EnvelopeEditorFieldsPage = () => {
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
const {
envelope,
editorFields,
editorContents,
navigateToStep,
editorConfig,
selectedEditorTab,
setSelectedEditorTab,
} = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const { currentEnvelopeItem, setCurrentEnvelopeItem, viewerControls } = useCurrentEnvelopeRender();
const { _ } = useLingui();
/**
* Switching tabs resets which items are shown to the default for that tab:
* both on the fields tab, and only contents on the contents tab so the page
* reads as the document itself. This drives the same toggles as the viewer
* toolbar, so the user can still override them for the current tab, but any
* override is discarded on the next switch.
*/
const onEditorTabChange = (tab: EnvelopeEditorTab) => {
setSelectedEditorTab(tab);
viewerControls.setFieldsVisibility(tab === 'contents' ? 'hidden' : 'visible');
viewerControls.setContentsVisibility('visible');
};
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
const { revalidate } = useRevalidator();
@@ -96,6 +142,11 @@ export const EnvelopeEditorFieldsPage = () => {
const selectedField = useMemo(() => structuredClone(editorFields.selectedField), [editorFields.selectedField]);
const selectedContent = useMemo(
() => structuredClone(editorContents.selectedContent),
[editorContents.selectedContent],
);
/**
* Debounce the fields used for overlap detection so we don't recompute on every
* small drag/resize movement, which is expensive on large field counts and can
@@ -186,6 +237,21 @@ export const EnvelopeEditorFieldsPage = () => {
editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null);
}, []);
/**
* Deselect a field or content which is not on the current envelope item
* when the item changes, otherwise its settings stay open in the sidebar for
* something which is no longer on screen.
*/
useEffect(() => {
if (editorFields.selectedField && editorFields.selectedField.envelopeItemId !== currentEnvelopeItem?.id) {
editorFields.setSelectedField(null);
}
if (editorContents.selectedContent && editorContents.selectedContent.envelopeItemId !== currentEnvelopeItem?.id) {
editorContents.setSelectedContent(null);
}
}, [currentEnvelopeItem?.id]);
const onDetectClick = () => {
if (!team.preferences.aiFeaturesEnabled) {
setIsAiEnableDialogOpen(true);
@@ -202,319 +268,405 @@ export const EnvelopeEditorFieldsPage = () => {
});
};
if (!editorConfig.general?.allowAddFieldsStep && !editorConfig.general?.allowAddContentsStep) {
return null;
}
return (
<div className="relative flex h-full">
<div className="flex h-full w-full flex-col overflow-y-auto px-2" ref={scrollableContainerRef}>
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector
className="px-0"
fields={editorFields.localFields}
renderItemAction={
editorConfig.envelopeItems !== null &&
editorConfig.envelopeItems.allowReplace &&
envelopeItemPermissions.canFileBeChanged
? (item) => (
<div className="relative flex h-5 w-5 flex-shrink-0 items-center justify-center">
<div
className={cn('h-2 w-2 rounded-full transition-opacity duration-150 group-hover:opacity-0', {
'bg-green-500': currentEnvelopeItem?.id === item.id,
})}
/>
<EnvelopeItemEditDialog
envelopeItem={item}
allowConfigureTitle={editorConfig.envelopeItems?.allowConfigureTitle ?? false}
trigger={
<span
className="absolute inset-0 flex cursor-pointer items-center justify-center opacity-0 transition-opacity duration-150 group-hover:opacity-100"
onClick={(e) => e.stopPropagation()}
data-testid={`envelope-item-edit-button-${item.id}`}
>
<PencilIcon className="h-3.5 w-3.5" />
</span>
}
/>
</div>
)
: undefined
}
/>
<EnvelopeEditorInvalidDirectTemplateAlert />
{/* Document View */}
<div className="mt-4 flex h-full flex-col items-center justify-center">
{envelope.recipients.length === 0 && (
<Alert
variant="neutral"
className="mb-4 flex max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm border border-border bg-background"
>
<div className="flex flex-col gap-1">
<AlertTitle>
<Trans>Missing Recipients</Trans>
</AlertTitle>
<AlertDescription>
<Trans>You need at least one recipient to add fields</Trans>
</AlertDescription>
</div>
<Button variant="outline" onClick={() => void navigateToStep('upload')}>
<Trans>Add Recipients</Trans>
</Button>
</Alert>
)}
{overlappingFieldPairs.length > 0 && (
<Alert
variant="warning"
className="mt-20 mb-4 flex w-full max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm"
>
<div className="flex flex-row items-start gap-3">
<AlertTriangleIcon className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="flex flex-col gap-1">
<AlertTitle>
<Trans>Overlapping fields detected</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Some fields are placed on top of each other. This may complicate the signing process or cause
fields to not work as expected.
</Trans>
</AlertDescription>
</div>
</div>
</Alert>
)}
{currentEnvelopeItem !== null ? (
<EnvelopePdfViewer
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
<>
{/*
The settings form of the selected content is shared by the sidebar and
the canvas action bar, so it wraps both. Never keyed: remounting it
would remount the PDF viewer beneath it.
*/}
<ContentSettingsFormProvider content={selectedContent ?? null}>
<div className="relative flex h-full">
<div
className="flex h-full w-full flex-col overflow-x-auto overflow-y-auto px-2"
ref={scrollableContainerRef}
>
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector
className="px-0"
fields={editorFields.localFields}
renderItemAction={
editorConfig.envelopeItems !== null &&
editorConfig.envelopeItems.allowReplace &&
envelopeItemPermissions.canFileBeChanged
? (item) => (
<div className="relative flex h-5 w-5 flex-shrink-0 items-center justify-center">
<div
className={cn('h-2 w-2 rounded-full transition-opacity duration-150 group-hover:opacity-0', {
'bg-green-500': currentEnvelopeItem?.id === item.id,
})}
/>
<EnvelopeItemEditDialog
envelopeItem={item}
allowConfigureTitle={editorConfig.envelopeItems?.allowConfigureTitle ?? false}
trigger={
<span
className="absolute inset-0 flex cursor-pointer items-center justify-center opacity-0 transition-opacity duration-150 group-hover:opacity-100"
onClick={(e) => e.stopPropagation()}
data-testid={`envelope-item-edit-button-${item.id}`}
>
<PencilIcon className="h-3.5 w-3.5" />
</span>
}
/>
</div>
)
: undefined
}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="h-10 w-10 text-muted-foreground" />
<p className="mt-1 text-foreground text-sm">
<Trans>No documents found</Trans>
</p>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Please upload a document to continue</Trans>
</p>
<EnvelopeEditorInvalidDirectTemplateAlert />
{/* Document View */}
<div className="mt-4 flex h-full flex-col items-center justify-center">
{envelope.recipients.length === 0 && (
<Alert
variant="neutral"
className="mb-4 flex max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm border border-border bg-background"
>
<div className="flex flex-col gap-1">
<AlertTitle>
<Trans>Missing Recipients</Trans>
</AlertTitle>
<AlertDescription>
<Trans>You need at least one recipient to add fields</Trans>
</AlertDescription>
</div>
<Button variant="outline" onClick={() => void navigateToStep('upload')}>
<Trans>Add Recipients</Trans>
</Button>
</Alert>
)}
{overlappingFieldPairs.length > 0 && (
<Alert
variant="warning"
className="mt-20 mb-4 flex w-full max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm"
>
<div className="flex flex-row items-start gap-3">
<AlertTriangleIcon className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="flex flex-col gap-1">
<AlertTitle>
<Trans>Overlapping fields detected</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Some fields are placed on top of each other. This may complicate the signing process or cause
fields to not work as expected.
</Trans>
</AlertDescription>
</div>
</div>
</Alert>
)}
{currentEnvelopeItem !== null ? (
<EnvelopePdfViewer
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
toolbar={['zoom', 'fields', 'contents']}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="h-10 w-10 text-muted-foreground" />
<p className="mt-1 text-foreground text-sm">
<Trans>No documents found</Trans>
</p>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Please upload a document to continue</Trans>
</p>
</div>
)}
</div>
</div>
{/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && envelope.recipients.length > 0 && (
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-border border-l bg-background py-2">
<Tabs value={selectedEditorTab} onValueChange={(value) => onEditorTabChange(value as EnvelopeEditorTab)}>
{editorConfig.general?.allowAddFieldsStep && editorConfig.general?.allowAddContentsStep && (
<TabsList className="-mt-4 flex w-full flex-row border-b pt-2 text-muted-foreground text-sm">
<TabsTrigger
className="group flex min-h-12 w-1/2 items-center justify-center px-2 text-center hover:text-muted-foreground/80 data-[state=active]:shadow-[inset_0_-2px_0_0_hsl(var(--primary))]"
value="fields"
>
<MousePointerIcon className="mr-2 -ml-1 h-3.5 w-3.5 group-data-[state=active]:text-primary" />
<Trans>Fields</Trans>
</TabsTrigger>
<TabsTrigger
className="group flex min-h-12 w-1/2 items-center justify-center px-2 text-center hover:text-muted-foreground/80 data-[state=active]:shadow-[inset_0_-2px_0_0_hsl(var(--primary))]"
value="contents"
>
<LayoutGridIcon className="mr-2 -ml-1 h-3.5 w-3.5 group-data-[state=active]:text-primary" />
<Trans>Contents</Trans>
</TabsTrigger>
</TabsList>
)}
{/* Fields tab */}
<TabsContent value="fields">
{/* Recipient selector section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Selected Recipient</Trans>
</h3>
<EnvelopeRecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) => editorFields.setSelectedRecipient(recipient.id)}
recipients={envelope.recipients}
fields={envelope.fields}
className="w-full"
align="end"
/>
{editorFields.selectedRecipient &&
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
<Alert className="mt-4" variant="warning">
<AlertDescription>
<Trans>
This recipient can no longer be modified as they have signed a field, or completed the
document.
</Trans>
</AlertDescription>
</Alert>
)}
</section>
<Separator className="my-4" />
{/* Add fields section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Add Fields</Trans>
</h3>
<EnvelopeEditorFieldDragDrop
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
{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="mr-2 -ml-1 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</>
)}
</section>
{/* Field details section. */}
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
{selectedField && (
<section>
<Separator className="my-4" />
{searchParams.get('devmode') && (
<>
<div className="px-4">
<h3 className="mb-3 font-semibold text-foreground text-sm">
<Trans>Developer Mode</Trans>
</h3>
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-foreground text-sm">
{selectedField.id && (
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Field ID:</Trans>
</span>{' '}
{selectedField.id}
</p>
)}
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Recipient ID:</Trans>
</span>{' '}
{selectedField.recipientId}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos X:</Trans>
</span>{' '}
{selectedField.positionX.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos Y:</Trans>
</span>{' '}
{selectedField.positionY.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Width:</Trans>
</span>{' '}
{selectedField.width.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Height:</Trans>
</span>{' '}
{selectedField.height.toFixed(2)}
</p>
</div>
</div>
<Separator className="my-4" />
</>
)}
<div className="px-4 [&_label]:text-foreground/70 [&_label]:text-xs">
<h3 className="font-semibold text-sm">
{_(FieldSettingsTypeTranslations[selectedField.type])}
</h3>
{match(selectedField.type)
.with(FieldType.SIGNATURE, () => (
<EditorFieldSignatureForm
value={selectedField?.fieldMeta as TSignatureFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.CHECKBOX, () => (
<EditorFieldCheckboxForm
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DATE, () => (
<EditorFieldDateForm
value={selectedField?.fieldMeta as TDateFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DROPDOWN, () => (
<EditorFieldDropdownForm
value={selectedField?.fieldMeta as TDropdownFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.EMAIL, () => (
<EditorFieldEmailForm
value={selectedField?.fieldMeta as TEmailFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.INITIALS, () => (
<EditorFieldInitialsForm
value={selectedField?.fieldMeta as TInitialsFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NAME, () => (
<EditorFieldNameForm
value={selectedField?.fieldMeta as TNameFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NUMBER, () => (
<EditorFieldNumberForm
value={selectedField?.fieldMeta as TNumberFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.RADIO, () => (
<EditorFieldRadioForm
value={selectedField?.fieldMeta as TRadioFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.TEXT, () => (
<EditorFieldTextForm
value={selectedField?.fieldMeta as TTextFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.otherwise(() => null)}
</div>
</section>
)}
</AnimateGenericFadeInOut>
</TabsContent>
{/* Contents tab */}
<TabsContent value="contents">
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Add Content</Trans>
</h3>
<EnvelopeEditorContentDragDrop selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null} />
</section>
{/* Content details section. */}
<AnimateGenericFadeInOut key={editorContents.selectedContent?.formId}>
{selectedContent && (
<section>
<Separator className="my-4" />
<div className="px-4 [&_label]:text-foreground/70 [&_label]:text-xs">
<h3 className="font-semibold text-sm">
{_(ContentSettingsTypeTranslations[selectedContent.contentMeta.type])}
</h3>
{match(selectedContent.contentMeta.type)
.with(EnvelopeContentType.TEXT, () => <EditorContentTextForm />)
.with(EnvelopeContentType.LINE, () => <EditorContentLineForm />)
.with(EnvelopeContentType.SHAPE, () => <EditorContentShapeForm />)
.with(EnvelopeContentType.HIGHLIGHT, () => <EditorContentHighlightForm />)
.with(EnvelopeContentType.IMAGE, () => (
<EditorContentImageSettings
formId={selectedContent.formId}
dataContentId={selectedContent.dataContentId ?? null}
/>
))
.exhaustive()}
</div>
</section>
)}
</AnimateGenericFadeInOut>
</TabsContent>
</Tabs>
</div>
)}
</div>
</div>
</ContentSettingsFormProvider>
{/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && envelope.recipients.length > 0 && (
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-border border-l bg-background py-4">
{/* Recipient selector section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Selected Recipient</Trans>
</h3>
<EnvelopeRecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) => editorFields.setSelectedRecipient(recipient.id)}
recipients={envelope.recipients}
fields={envelope.fields}
className="w-full"
align="end"
/>
{editorFields.selectedRecipient &&
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
<Alert className="mt-4" variant="warning">
<AlertDescription>
<Trans>
This recipient can no longer be modified as they have signed a field, or completed the document.
</Trans>
</AlertDescription>
</Alert>
)}
</section>
<Separator className="my-4" />
{/* Add fields section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Add Fields</Trans>
</h3>
<EnvelopeEditorFieldDragDrop
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
{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="mr-2 -ml-1 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</>
)}
</section>
{/* Field details section. */}
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
{selectedField && (
<section>
<Separator className="my-4" />
{searchParams.get('devmode') && (
<>
<div className="px-4">
<h3 className="mb-3 font-semibold text-foreground text-sm">
<Trans>Developer Mode</Trans>
</h3>
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-foreground text-sm">
{selectedField.id && (
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Field ID:</Trans>
</span>{' '}
{selectedField.id}
</p>
)}
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Recipient ID:</Trans>
</span>{' '}
{selectedField.recipientId}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos X:</Trans>
</span>{' '}
{selectedField.positionX.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos Y:</Trans>
</span>{' '}
{selectedField.positionY.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Width:</Trans>
</span>{' '}
{selectedField.width.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Height:</Trans>
</span>{' '}
{selectedField.height.toFixed(2)}
</p>
</div>
</div>
<Separator className="my-4" />
</>
)}
<div className="px-4 [&_label]:text-foreground/70 [&_label]:text-xs">
<h3 className="font-semibold text-sm">{_(FieldSettingsTypeTranslations[selectedField.type])}</h3>
{match(selectedField.type)
.with(FieldType.SIGNATURE, () => (
<EditorFieldSignatureForm
value={selectedField?.fieldMeta as TSignatureFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.CHECKBOX, () => (
<EditorFieldCheckboxForm
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DATE, () => (
<EditorFieldDateForm
value={selectedField?.fieldMeta as TDateFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DROPDOWN, () => (
<EditorFieldDropdownForm
value={selectedField?.fieldMeta as TDropdownFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.EMAIL, () => (
<EditorFieldEmailForm
value={selectedField?.fieldMeta as TEmailFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.INITIALS, () => (
<EditorFieldInitialsForm
value={selectedField?.fieldMeta as TInitialsFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NAME, () => (
<EditorFieldNameForm
value={selectedField?.fieldMeta as TNameFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NUMBER, () => (
<EditorFieldNumberForm
value={selectedField?.fieldMeta as TNumberFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.RADIO, () => (
<EditorFieldRadioForm
value={selectedField?.fieldMeta as TRadioFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.TEXT, () => (
<EditorFieldTextForm
value={selectedField?.fieldMeta as TTextFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.otherwise(() => null)}
</div>
</section>
)}
</AnimateGenericFadeInOut>
</div>
)}
</div>
<ContentImageUploadDialog.Root />
</>
);
};
@@ -1,5 +1,5 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { getEnvelopeItemPermissions, mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
@@ -222,7 +222,6 @@ export default function EnvelopeEditorHeader() {
.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}
@@ -215,6 +215,7 @@ export const EnvelopeEditorPreviewPage = () => {
envelopeItems={envelope.envelopeItems}
token={undefined}
fields={fieldsWithPlaceholders}
contents={envelope.contents}
recipients={envelope.recipients.map((recipient) => ({
...recipient,
signingStatus: SigningStatus.SIGNED,
@@ -225,7 +226,7 @@ export const EnvelopeEditorPreviewPage = () => {
}}
>
<div className="relative flex h-full">
<div className="flex h-full w-full flex-col overflow-y-auto px-2" ref={scrollableContainerRef}>
<div className="flex h-full w-full flex-col overflow-x-auto overflow-y-auto px-2" ref={scrollableContainerRef}>
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector className="px-0" fields={editorFields.localFields} />
@@ -247,6 +248,7 @@ export const EnvelopeEditorPreviewPage = () => {
customPageRenderer={EnvelopeGenericPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
toolbar={['zoom']}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
@@ -1,42 +1,30 @@
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import {
updateEditorSigners,
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 { 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 {
isAssistantLastSigner,
isCcRecipient,
normalizeRecipientSigningOrders,
canRecipientBeModified as utilCanRecipientBeModified,
} from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
import {
RecipientAutoCompleteInput,
type RecipientAutoCompleteOption,
} from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { normalizeGroupedSigningOrders } from '@documenso/lib/utils/recipient-groups';
import { canEditorRecipientBeModified } from '@documenso/lib/utils/recipients';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { SigningOrderConfirmation } from '@documenso/ui/primitives/document-flow/signing-order-confirmation';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DragDropContext, Draggable, Droppable, type DropResult, type SensorAPI } from '@hello-pangea/dnd';
import { plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react';
import { Trans } from '@lingui/react/macro';
import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client';
import { HelpCircleIcon, PlusIcon, SparklesIcon } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFieldArray, useWatch } from 'react-hook-form';
import { useRevalidator, useSearchParams } from 'react-router';
@@ -45,7 +33,8 @@ import { isDeepEqual } from 'remeda';
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
import { useCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
import { RecipientStepList } from './recipient-step-list';
export const EnvelopeEditorRecipientForm = () => {
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients, isEmbedded, editorConfig } =
@@ -53,9 +42,7 @@ export const EnvelopeEditorRecipientForm = () => {
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
const cspNonce = useCspNonce();
const { t } = useLingui();
const { toast } = useToast();
const { remaining } = useLimits();
const { sessionData } = useOptionalSession();
@@ -63,7 +50,6 @@ export const EnvelopeEditorRecipientForm = () => {
const user = sessionData?.user;
const [searchParams, setSearchParams] = useSearchParams();
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
// AI recipient detection dialog state
@@ -109,23 +95,8 @@ export const EnvelopeEditorRecipientForm = () => {
});
};
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
const $sensorApi = useRef<SensorAPI | null>(null);
const isFirstRender = useRef(true);
const { recipients, fields } = envelope;
const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery(
{
query: debouncedRecipientSearchQuery,
},
{
enabled: debouncedRecipientSearchQuery.length > 1 && !isEmbedded,
retry: false,
},
);
const recipientSuggestions = recipientSuggestionsData?.results || [];
const { recipients } = envelope;
const { form } = editorRecipients;
@@ -163,15 +134,16 @@ export const EnvelopeEditorRecipientForm = () => {
}, [watchedSigners]);
const normalizeSigningOrders = (signers: typeof watchedSigners) => {
return normalizeRecipientSigningOrders(signers, (signer) => canRecipientBeModified(signer.id));
return normalizeGroupedSigningOrders(signers, (signer) => canRecipientBeModified(signer.id));
};
const activeRecipientCount = watchedSigners.filter((signer) => !isCcRecipient(signer)).length;
const { fields: signers, remove: removeSigner } = useFieldArray({
// Keep a mounted field array for `signers` so react-hook-form reconciles
// whole-array `setValue` calls atomically. Without it, reordering the array
// leaves stale partial entries in watched values (missing email/name/role),
// which breaks validation and the autosave sync.
useFieldArray({
control,
name: 'signers',
keyName: 'nativeId',
});
const emptySignerIndex = watchedSigners.findIndex(
@@ -185,39 +157,22 @@ export const EnvelopeEditorRecipientForm = () => {
const hasCurrentEditorInfo = Boolean(currentEditorEmail || currentEditorName);
// Note: Watched signer entries can be transiently partial while react-hook-form
// re-registers reordered array fields, so guard optional access here.
const isUserAlreadyARecipient = watchedSigners.some(
(signer) => signer.email.toLowerCase() === currentEditorEmail?.toLowerCase(),
(signer) => Boolean(currentEditorEmail) && signer.email?.toLowerCase() === currentEditorEmail?.toLowerCase(),
);
const hasDocumentBeenSent = recipients.some(
(recipient) => recipient.role !== RecipientRole.CC && recipient.sendStatus === SendStatus.SENT,
);
const canRecipientBeModified = (recipientId?: number) => {
if (envelope.type === EnvelopeType.TEMPLATE) {
return true;
}
if (recipientId === undefined) {
return true;
}
const recipient = recipients.find((recipient) => recipient.id === recipientId);
if (!recipient) {
return false;
}
return utilCanRecipientBeModified(recipient, fields);
};
const canRecipientBeModified = (recipientId?: number) => canEditorRecipientBeModified(envelope, recipientId);
const appendNormalizedSigner = (signer: (typeof watchedSigners)[number], shouldFocus = false) => {
const updatedSigners = normalizeSigningOrders([...form.getValues('signers'), signer]);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
updateEditorSigners(form, updatedSigners);
if (shouldFocus) {
const signerIndex = updatedSigners.findIndex((updatedSigner) => updatedSigner.formId === signer.formId);
@@ -235,20 +190,17 @@ export const EnvelopeEditorRecipientForm = () => {
email: '',
role: RecipientRole.SIGNER,
actionAuth: [],
signingOrder: activeRecipientCount + 1,
signingOrder: undefined,
});
};
const onAiDetectionComplete = (detectedRecipients: TDetectedRecipientSchema[]) => {
const currentSigners = form.getValues('signers');
let nextSigningOrder =
currentSigners.length > 0 ? Math.max(...currentSigners.map((s) => s.signingOrder ?? 0)) + 1 : 1;
// If the only signer is the default empty signer lets just replace it with the detected recipients
if (currentSigners.length === 1 && !currentSigners[0].name && !currentSigners[0].email) {
form.setValue(
'signers',
updateEditorSigners(
form,
detectedRecipients.map((recipient, index) => ({
formId: nanoid(12),
name: recipient.name,
@@ -257,10 +209,6 @@ export const EnvelopeEditorRecipientForm = () => {
actionAuth: [],
signingOrder: index + 1,
})),
{
shouldValidate: true,
shouldDirty: true,
},
);
return;
@@ -281,16 +229,11 @@ export const EnvelopeEditorRecipientForm = () => {
email: recipient.email,
role: recipient.role,
actionAuth: [],
signingOrder: nextSigningOrder,
signingOrder: undefined,
});
nextSigningOrder += 1;
}
form.setValue('signers', normalizeSigningOrders(currentSigners), {
shouldValidate: true,
shouldDirty: true,
});
updateEditorSigners(form, normalizeSigningOrders(currentSigners));
toast({
title: plural(detectedRecipients.length, {
@@ -304,32 +247,6 @@ export const EnvelopeEditorRecipientForm = () => {
});
};
const onRemoveSigner = (index: number) => {
const signer = signers[index];
if (!canRecipientBeModified(signer.id)) {
toast({
title: t`Cannot remove signer`,
description: t`This signer has already signed the document.`,
variant: 'destructive',
});
return;
}
const formStateIndex = form.getValues('signers').findIndex((s) => s.formId === signer.formId);
if (formStateIndex !== -1) {
removeSigner(formStateIndex);
const updatedSigners = form.getValues('signers').filter((s) => s.formId !== signer.formId);
form.setValue('signers', normalizeSigningOrders(updatedSigners), {
shouldValidate: true,
shouldDirty: true,
});
}
};
const onAddSelfSigner = () => {
if (emptySignerIndex !== -1) {
setValue(`signers.${emptySignerIndex}.name`, currentEditorName ?? '', {
@@ -350,7 +267,7 @@ export const EnvelopeEditorRecipientForm = () => {
email: currentEditorEmail ?? '',
role: RecipientRole.SIGNER,
actionAuth: [],
signingOrder: activeRecipientCount + 1,
signingOrder: undefined,
},
true,
);
@@ -359,142 +276,6 @@ export const EnvelopeEditorRecipientForm = () => {
}
};
const handleRecipientAutoCompleteSelect = (index: number, suggestion: RecipientAutoCompleteOption) => {
setValue(`signers.${index}.email`, suggestion.email, {
shouldValidate: true,
shouldDirty: true,
});
setValue(`signers.${index}.name`, suggestion.name || '', {
shouldValidate: true,
shouldDirty: true,
});
};
const onDragEnd = useCallback(
async (result: DropResult) => {
if (!result.destination) {
return;
}
const items = Array.from(watchedSigners);
const [reorderedSigner] = items.splice(result.source.index, 1);
// Find next valid position
let insertIndex = result.destination.index;
while (insertIndex < items.length && !canRecipientBeModified(items[insertIndex].id)) {
insertIndex++;
}
items.splice(insertIndex, 0, reorderedSigner);
const updatedSigners = normalizeSigningOrders(items);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (isAssistantLastSigner(updatedSigners)) {
toast({
title: t`Warning: Assistant as last signer`,
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
});
}
await form.trigger('signers');
},
[form, canRecipientBeModified, watchedSigners, toast],
);
const handleRoleChange = useCallback(
(index: number, role: RecipientRole) => {
const currentSigners = form.getValues('signers');
const signingOrder = form.getValues('signingOrder');
// Handle parallel to sequential conversion for assistants
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, {
shouldValidate: true,
shouldDirty: true,
});
toast({
title: t`Signing order is enabled.`,
description: t`You cannot add assistants when signing order is disabled.`,
variant: 'destructive',
});
return;
}
const updatedSigners = normalizeSigningOrders(
currentSigners.map((signer, idx) => ({
...signer,
role: idx === index ? role : signer.role,
})),
);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) {
toast({
title: t`Warning: Assistant as last signer`,
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
});
}
},
[form, toast, canRecipientBeModified],
);
const handleSigningOrderChange = useCallback(
(index: number, newOrderString: string) => {
const trimmedOrderString = newOrderString.trim();
if (!trimmedOrderString) {
return;
}
const newOrder = Number(trimmedOrderString);
if (!Number.isInteger(newOrder) || newOrder < 1) {
return;
}
const currentSigners = form.getValues('signers');
const signer = currentSigners[index];
if (isCcRecipient(signer)) {
return;
}
const nonCcSigners = currentSigners.filter((s) => !isCcRecipient(s));
const ccSigners = currentSigners.filter((s) => isCcRecipient(s));
const currentSigningOrderIndex = nonCcSigners.findIndex((s) => s.formId === signer.formId);
if (currentSigningOrderIndex === -1) {
return;
}
const [reorderedSigner] = nonCcSigners.splice(currentSigningOrderIndex, 1);
const newPosition = Math.min(Math.max(0, newOrder - 1), nonCcSigners.length);
nonCcSigners.splice(newPosition, 0, reorderedSigner);
const updatedSigners = normalizeSigningOrders([...nonCcSigners, ...ccSigners]);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (signer.role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) {
toast({
title: t`Warning: Assistant as last signer`,
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
});
}
},
[form, canRecipientBeModified, toast],
);
const handleSigningOrderDisable = useCallback(() => {
setShowSigningOrderConfirmation(false);
@@ -506,10 +287,8 @@ export const EnvelopeEditorRecipientForm = () => {
})),
);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
updateEditorSigners(form, updatedSigners);
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, {
shouldValidate: true,
shouldDirty: true,
@@ -537,14 +316,17 @@ export const EnvelopeEditorRecipientForm = () => {
const { data } = validatedFormValues;
// Weird edge case where the whole envelope is created via API
// with no signing order. If they come to this page it will show an error
// since they aren't equal and the recipient is no longer editable.
// Locked recipients hold persisted values the server refuses to rewrite,
// e.g. an envelope created via API with no signing order where a recipient
// has already signed. Restore their PERSISTED order so form normalization
// drift never submits a "changed" locked recipient the server rejects.
const envelopeRecipients = data.signers.map((recipient) => {
if (!canRecipientBeModified(recipient.id)) {
const persistedRecipient = recipients.find((envelopeRecipient) => envelopeRecipient.id === recipient.id);
return {
...recipient,
signingOrder: recipient.signingOrder,
signingOrder: persistedRecipient?.signingOrder ?? undefined,
};
}
return recipient;
@@ -570,7 +352,7 @@ export const EnvelopeEditorRecipientForm = () => {
signer.email !== recipient.email ||
signer.name !== recipient.name ||
signer.role !== recipient.role ||
signer.signingOrder !== recipient.signingOrder ||
(signer.signingOrder ?? null) !== (recipient.signingOrder ?? null) ||
!isDeepEqual(signerActionAuth, recipientActionAuth)
);
});
@@ -590,7 +372,7 @@ export const EnvelopeEditorRecipientForm = () => {
}, [formValues]);
const recipientCountLimit = organisation.organisationClaim.recipientCount;
const isOverRecipientLimit = recipientCountLimit > 0 && signers.length > recipientCountLimit;
const isOverRecipientLimit = recipientCountLimit > 0 && watchedSigners.length > recipientCountLimit;
return (
<Card backdropBlur={false} className="border">
@@ -646,7 +428,7 @@ export const EnvelopeEditorRecipientForm = () => {
type="button"
className="flex-1"
size="sm"
disabled={isSubmitting || signers.length >= remaining.recipients}
disabled={isSubmitting || watchedSigners.length >= remaining.recipients}
onClick={() => onAddSigner()}
>
<PlusIcon className="mr-1 -ml-1 h-5 w-5" />
@@ -796,288 +578,7 @@ export const EnvelopeEditorRecipientForm = () => {
)}
</div>
<DragDropContext
nonce={cspNonce}
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 ||
isCcRecipient(signer) ||
!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,
})}
>
<div className="flex flex-row items-center gap-x-2">
{isSigningOrderSequential && isCcRecipient(signer) && (
<div className="mt-auto h-10 w-[4.25rem] flex-shrink-0" />
)}
{isSigningOrderSequential && !isCcRecipient(signer) && (
<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={activeRecipientCount}
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);
}}
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}.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>
<RecipientStepList showAdvancedSettings={showAdvancedSettings} />
<FormErrorMessage
className="mt-2"
@@ -18,6 +18,7 @@ export const EnvelopeEditorRenderProviderWrapper = ({
envelope={envelope}
envelopeItems={envelope.envelopeItems}
fields={envelope.fields}
contents={envelope.contents}
recipients={envelope.recipients}
token={token}
presignToken={presignedToken}
@@ -16,7 +16,7 @@ import {
ZDocumentMetaTimezoneSchema,
} from '@documenso/lib/types/document-meta';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import { isHttpUrl } from '@documenso/lib/utils/is-http-url';
import { canAccessTeamDocument, DocumentSignatureType, extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { zEmail } from '@documenso/lib/utils/zod';
import { trpc } from '@documenso/trpc/react';
@@ -97,7 +97,7 @@ export const ZAddSettingsFormSchema = z.object({
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || isValidRedirectUrl(value), {
.refine((value) => value === undefined || value === '' || isHttpUrl(value), {
message: 'Please enter a valid URL, make sure you include http:// or https:// part of the url.',
}),
language: z
@@ -54,6 +54,7 @@ export const EnvelopeEditorUploadPage = () => {
envelope,
setLocalEnvelope,
editorFields,
editorContents,
editorConfig,
isEmbedded,
navigateToStep,
@@ -139,7 +140,7 @@ export const EnvelopeEditorUploadPage = () => {
);
const { mutateAsync: replaceEnvelopeItemPdf } = trpc.envelope.item.replacePdf.useMutation({
onSuccess: ({ data, fields }) => {
onSuccess: ({ data, fields, contents }) => {
// Update the envelope item with the new documentDataId.
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.map((item) =>
@@ -153,6 +154,12 @@ export const EnvelopeEditorUploadPage = () => {
setLocalEnvelope({ fields });
editorFields.resetForm(fields);
}
// Same for contents.
if (contents) {
setLocalEnvelope({ contents });
editorContents.resetForm(contents);
}
},
});
@@ -181,13 +188,18 @@ export const EnvelopeEditorUploadPage = () => {
// Directly commit the files for embedded documents since those are not uploaded
// until the end of the embedded flow.
if (isEmbedded) {
// New items go after the existing ones, in the order they were added.
// Deleting an item does not renumber the rest, so this continues from
// the highest order rather than the item count.
const highestOrder = envelope.envelopeItems.reduce((highest, item) => Math.max(highest, item.order), 0);
setLocalEnvelope({
envelopeItems: [
...envelope.envelopeItems,
...newUploadingFiles.map((file) => ({
...newUploadingFiles.map((file, index) => ({
id: file.envelopeItemId!,
title: file.title,
order: envelope.envelopeItems.length + 1,
order: highestOrder + index + 1,
envelopeId: envelope.id,
data: file.data!,
documentDataId: '',
@@ -273,12 +285,20 @@ export const EnvelopeEditorUploadPage = () => {
(field) => field.envelopeItemId !== envelopeItemId || field.page <= newPageCount,
);
// Contents are pruned by the same rule, matching what the server does
// for non embedded replacements.
const remainingContents = envelope.contents.filter(
(content) => content.envelopeItemId !== envelopeItemId || content.contentMeta.page <= newPageCount,
);
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.map((item) => (item.id === envelopeItemId ? { ...item, data } : item)),
fields: remainingFields,
contents: remainingContents,
});
editorFields.resetForm(remainingFields);
editorContents.resetForm(remainingContents);
return;
}
@@ -326,14 +346,17 @@ export const EnvelopeEditorUploadPage = () => {
setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.envelopeItemId !== envelopeItemId));
const fieldsWithoutDeletedItem = envelope.fields.filter((field) => field.envelopeItemId !== envelopeItemId);
const contentsWithoutDeletedItem = envelope.contents.filter((content) => content.envelopeItemId !== envelopeItemId);
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.filter((item) => item.id !== envelopeItemId),
fields: envelope.fields.filter((field) => field.envelopeItemId !== envelopeItemId),
fields: fieldsWithoutDeletedItem,
contents: contentsWithoutDeletedItem,
});
// Reset editor fields.
// Reset editor fields and contents.
editorFields.resetForm(fieldsWithoutDeletedItem);
editorContents.resetForm(contentsWithoutDeletedItem);
};
/**
@@ -90,7 +90,13 @@ export const EnvelopeEditor = () => {
const [searchParams, setSearchParams] = useSearchParams();
const {
general: { minimizeLeftSidebar, allowUploadAndRecipientStep, allowAddFieldsStep, allowPreviewStep },
general: {
minimizeLeftSidebar,
allowUploadAndRecipientStep,
allowAddFieldsStep,
allowAddContentsStep,
allowPreviewStep,
},
actions: {
allowDistributing,
allowDirectLink,
@@ -108,7 +114,7 @@ export const EnvelopeEditor = () => {
steps.push(UPLOAD_STEP);
}
if (allowAddFieldsStep) {
if (allowAddFieldsStep || allowAddContentsStep) {
steps.push(ADD_FIELDS_STEP);
}
@@ -524,11 +530,16 @@ export const EnvelopeEditor = () => {
pageToRender,
allowUploadAndRecipientStep,
allowAddFieldsStep,
allowAddContentsStep,
allowPreviewStep,
})
.with({ pageToRender: 'loading' }, () => <SpinnerBox className="py-32" />)
.with({ pageToRender: 'upload', allowUploadAndRecipientStep: true }, () => <EnvelopeEditorUploadPage />)
.with({ pageToRender: 'addFields', allowAddFieldsStep: true }, () => <EnvelopeEditorFieldsPage />)
.with(
{ pageToRender: 'addFields', allowAddFieldsStep: true },
{ pageToRender: 'addFields', allowAddContentsStep: true },
() => <EnvelopeEditorFieldsPage />,
)
.with({ pageToRender: 'preview', allowPreviewStep: true }, () => <EnvelopeEditorPreviewPage />)
.otherwise(() => null)}
</div>
@@ -5,7 +5,9 @@ import {
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { renderStaticContents } from '@documenso/lib/universal/content-renderer/render-static-contents';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { areContentsImprinted } from '@documenso/lib/utils/envelope';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
import { useLingui } from '@lingui/react/macro';
@@ -22,9 +24,12 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
const analytics = useAnalytics();
const {
version,
envelopeStatus,
currentEnvelopeItem,
fields,
contents,
contentImages,
signatures,
recipients,
getRecipientColorKey,
@@ -32,16 +37,40 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
overrideSettings,
} = useCurrentEnvelopeRender();
/**
* Once an envelope is sent its contents are inserted into the current PDF, so
* rendering them again would double them up.
*/
const shouldRenderContents = !(version === 'current' && areContentsImprinted(envelopeStatus));
const signaturesByFieldId = useMemo(() => {
return new Map(signatures.map((signature) => [signature.fieldId, signature]));
}, [signatures]);
const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer(({ stage, pageLayer }) => {
const {
stage,
pageLayer,
konvaContainer,
unscaledViewport,
fieldsVisibility,
applyPageItemsVisibility,
hasFailedImage,
} = usePageRenderer(({ stage, pageLayer }) => {
createPageCanvas(stage, pageLayer);
}, pageData);
const { scale, pageNumber } = pageData;
/**
* A content image which failed to load would render the page differently
* to how it was authored, so it is treated like any other render failure.
*/
useEffect(() => {
if (shouldRenderContents && hasFailedImage) {
setRenderError(true);
}
}, [shouldRenderContents, hasFailedImage]);
const localPageFields = useMemo((): GenericLocalField[] => {
if (envelopeStatus === DocumentStatus.COMPLETED) {
return [];
@@ -105,7 +134,8 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
translations: fieldTranslations,
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
color: getRecipientColorKey(field.recipientId),
// Muted fields are rendered with the read-only styling.
color: fieldsVisibility === 'muted' ? 'readOnly' : getRecipientColorKey(field.recipientId),
editable: false,
mode: overrideSettings?.mode ?? 'edit',
});
@@ -131,6 +161,12 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
* Initialize the Konva page canvas and all fields and interactions.
*/
const createPageCanvas = (_currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
// Render the contents beneath the fields. Contents are static, so they
// only need to be rendered when the canvas is created.
if (shouldRenderContents) {
renderContents(currentPageLayer);
}
// Render the fields.
for (const field of localPageFields) {
renderFieldOnLayer(field);
@@ -139,6 +175,28 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
currentPageLayer.batchDraw();
};
/**
* Render the authored contents of the page beneath the fields.
*/
const renderContents = (currentPageLayer: Konva.Layer) => {
try {
renderStaticContents({
contents: contents.filter(
(content) => content.contentMeta.page === pageNumber && content.envelopeItemId === currentEnvelopeItem?.id,
),
pageLayer: currentPageLayer,
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
scale,
mode: overrideSettings?.mode ?? 'edit',
images: contentImages.images,
});
} catch (err) {
console.error(err);
setRenderError(true);
}
};
/**
* Render fields when they are added or removed
*/
@@ -159,8 +217,10 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
renderFieldOnLayer(field);
});
applyPageItemsVisibility();
pageLayer.current.batchDraw();
}, [localPageFields, signaturesByFieldId]);
}, [localPageFields, signaturesByFieldId, fieldsVisibility]);
if (!currentEnvelopeItem) {
return null;
@@ -170,6 +230,7 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
<>
{overrideSettings?.showRecipientTooltip &&
pageData.imageLoadingState === 'loaded' &&
fieldsVisibility === 'visible' &&
localPageFields.map((field) => (
<EnvelopeRecipientFieldTooltip
key={field.id}
@@ -0,0 +1,235 @@
import type { TEditorRecipientsFormSchema } 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 { isCcRecipient } from '@documenso/lib/utils/recipients';
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
import {
RecipientAutoCompleteInput,
type RecipientAutoCompleteOption,
} from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form';
import type { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd';
import { useLingui } from '@lingui/react/macro';
import { EnvelopeType, type RecipientRole } from '@prisma/client';
import { GripVerticalIcon, TrashIcon } from 'lucide-react';
import { memo } from 'react';
import { useFormContext } from 'react-hook-form';
type TEditorSigner = TEditorRecipientsFormSchema['signers'][number];
export type RecipientRowProps = {
signerIndex: number;
signer: TEditorSigner;
isSequential: boolean;
isInputDisabled: boolean;
canBeModified: boolean;
isRemoveDisabled: boolean;
showAdvancedSettings: boolean;
dragHandleProps?: DraggableProvidedDragHandleProps | null;
recipientSuggestions: RecipientAutoCompleteOption[];
isLoadingSuggestions: boolean;
onRoleChange: (signerIndex: number, role: RecipientRole) => void;
onRemove: (signerIndex: number) => void;
onAutoCompleteSelect: (signerIndex: number, suggestion: RecipientAutoCompleteOption) => void;
onSearchQueryChange: (query: string) => void;
};
const RecipientRowInner = ({
signerIndex,
signer,
isSequential,
isInputDisabled,
canBeModified,
isRemoveDisabled,
showAdvancedSettings,
dragHandleProps,
recipientSuggestions,
isLoadingSuggestions,
onRoleChange,
onRemove,
onAutoCompleteSelect,
onSearchQueryChange,
}: RecipientRowProps) => {
const { t } = useLingui();
const { envelope, editorConfig } = useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation();
const form = useFormContext<TEditorRecipientsFormSchema>();
const { isSubmitting } = form.formState;
const isDirectRecipient =
envelope.type === EnvelopeType.TEMPLATE &&
envelope.directLink !== null &&
signer.id === envelope.directLink.directTemplateRecipientId;
const isFieldDisabled = isInputDisabled || isSubmitting || !canBeModified;
const rowErrors = form.formState.errors.signers?.[signerIndex];
return (
<fieldset data-native-id={signer.id} disabled={isSubmitting || !canBeModified} className="py-1">
<div className="flex flex-row items-center gap-x-2">
{isSequential && !isCcRecipient(signer) && (
<span
{...(dragHandleProps ?? {})}
data-testid="recipient-row-drag-handle"
className={cn(
'mt-auto -ml-1.5 flex h-10 w-8 flex-shrink-0 cursor-grab items-center justify-center rounded-md hover:bg-foreground/5 active:cursor-grabbing',
{
'mb-6': rowErrors,
'cursor-default hover:bg-transparent': !dragHandleProps,
},
)}
>
<GripVerticalIcon
className={cn('h-5 w-5 flex-shrink-0 opacity-40', {
'opacity-10': !dragHandleProps,
})}
/>
</span>
)}
<FormField
control={form.control}
name={`signers.${signerIndex}.email`}
render={({ field }) => (
<FormItem
className={cn('relative w-full', {
'mb-6': rowErrors && !rowErrors.email,
})}
>
<FormControl>
<RecipientAutoCompleteInput
type="email"
aria-label={t`Email`}
placeholder={t`Email`}
value={field.value}
disabled={isFieldDisabled || isDirectRecipient}
options={recipientSuggestions}
onSelect={(suggestion) => onAutoCompleteSelect(signerIndex, suggestion)}
onSearchQueryChange={(query) => {
field.onChange(query);
onSearchQueryChange(query);
}}
loading={isLoadingSuggestions}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${signerIndex}.name`}
render={({ field }) => (
<FormItem
className={cn('w-full', {
'mb-6': rowErrors && !rowErrors.name,
})}
>
<FormControl>
<RecipientAutoCompleteInput
type="text"
aria-label={t`Name`}
placeholder={t`Recipient ${signerIndex + 1}`}
{...field}
disabled={isFieldDisabled || isDirectRecipient}
options={recipientSuggestions}
onSelect={(suggestion) => onAutoCompleteSelect(signerIndex, suggestion)}
onSearchQueryChange={(query) => {
field.onChange(query);
onSearchQueryChange(query);
}}
loading={isLoadingSuggestions}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${signerIndex}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto w-fit', {
'mb-6': rowErrors && !rowErrors.role,
})}
>
<FormControl>
<RecipientRoleSelect
{...field}
hideAssistantRole={!editorConfig.recipients?.allowAssistantRole}
hideCCerRole={!editorConfig.recipients?.allowCCerRole}
hideViewerRole={!editorConfig.recipients?.allowViewerRole}
hideApproverRole={!editorConfig.recipients?.allowApproverRole}
isAssistantEnabled={isSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
onRoleChange(signerIndex, value as RecipientRole);
}}
disabled={isFieldDisabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': rowErrors,
})}
data-testid="remove-signer-button"
disabled={isFieldDisabled || isRemoveDisabled || isDirectRecipient}
onClick={() => onRemove(signerIndex)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
{showAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${signerIndex}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('mt-2 w-full', {
'mb-6': rowErrors && !rowErrors.actionAuth,
'pl-6': isSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect {...field} onValueChange={field.onChange} disabled={isFieldDisabled} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</fieldset>
);
};
/**
* Memoized: rows contain heavy inputs (autocomplete, role select) and would
* otherwise re-render on every drag state change, making drags feel sluggish.
* All callback props are stable (useCallback in the list) and `signer` object
* identities only change when form values actually change.
*/
export const RecipientRow = memo(RecipientRowInner);
@@ -0,0 +1,261 @@
import type { TEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import type { RecipientStep } from '@documenso/lib/utils/recipient-groups';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import type { DraggableProvided, DraggableStateSnapshot } from '@hello-pangea/dnd';
import { Draggable, Droppable } from '@hello-pangea/dnd';
import { Plural, Trans } from '@lingui/react/macro';
import { GripVerticalIcon, Users2Icon } from 'lucide-react';
import { RecipientRow, type RecipientRowProps } from './recipient-row';
type TEditorSigner = TEditorRecipientsFormSchema['signers'][number];
export type DraggingType = 'STEP' | 'RECIPIENT' | null;
/**
* Skips the drop animation. The post-drop state update re-sorts and renumbers
* the groups anyway, so gliding to the predicted slot first makes every drop
* feel like it settles twice — snapping hands control to the real re-render
* immediately instead.
*/
const getDraggableStyle = (provided: DraggableProvided, snapshot: DraggableStateSnapshot) => {
if (!snapshot.isDropAnimating) {
return provided.draggableProps.style;
}
return {
...provided.draggableProps.style,
transitionDuration: '0.001s',
};
};
export type RecipientStepCardSharedRowProps = Pick<
RecipientRowProps,
| 'showAdvancedSettings'
| 'recipientSuggestions'
| 'isLoadingSuggestions'
| 'onRoleChange'
| 'onRemove'
| 'onAutoCompleteSelect'
| 'onSearchQueryChange'
>;
export type RecipientStepCardProps = {
stepIndex: number;
step: RecipientStep<TEditorSigner>;
isLastStep: boolean;
draggableProvided: DraggableProvided;
draggableSnapshot: DraggableStateSnapshot;
draggingType: DraggingType;
/**
* Whether recipients may be combined into signing groups. False on CSC
* (AES/QES) instances, where every signing recipient must hold a distinct
* step. Constant for the session, so disabling the drop-zone with it does
* not violate the "never toggle `isDropDisabled` mid-drag" constraint.
*/
isGroupingEnabled: boolean;
isStepLocked: boolean;
isRemoveDisabled: boolean;
flatIndexByFormId: Map<string, number>;
canSignerBeModified: (signer: TEditorSigner) => boolean;
isSubmitting: boolean;
onUngroup: (stepIndex: number) => void;
rowProps: RecipientStepCardSharedRowProps;
};
/**
* The drop-zone strip rendered above each group card (and below the last one)
* that receives recipient-row drops. Invisible until a dragged row hovers it,
* then it shows a full-width green line marking the insertion point.
*
* Notes:
* - It lives INSIDE the step's Draggable so it shifts together with the card
* while groups are being reordered — a static strip between draggables
* would stay behind while the cards around it are displaced, making group
* drags look broken.
* - Its `droppableId` must stay STABLE while mounted (anchored to a formId,
* never a positional index): @hello-pangea/dnd does not support changing
* ids on mounted droppables/draggables, which silently breaks them.
* - `type="RECIPIENT"` already scopes it to recipient-row drags, and
* `isDropDisabled` must not be toggled based on the active drag, as
* @hello-pangea/dnd snapshots it at drag start (before state updates land).
* - It must keep a CONSTANT size: droppable geometry is captured when a drag
* starts, so resizing during the drag would leave the visible strip and the
* actual hit area in different places. Only colors may change mid-drag.
*/
const RecipientStepGap = ({ droppableId }: { droppableId: string }) => (
<Droppable droppableId={droppableId} type="RECIPIENT">
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
data-testid="recipient-step-gap"
className={cn('flex h-6 items-center', {
'gap-active': snapshot.isDraggingOver,
})}
>
<div
className={cn('h-[3px] w-full rounded-full bg-primary opacity-0 transition-opacity duration-100', {
'opacity-100': snapshot.isDraggingOver,
})}
/>
{provided.placeholder}
</div>
)}
</Droppable>
);
export const RecipientStepCard = ({
stepIndex,
step,
isLastStep,
draggableProvided,
draggableSnapshot,
draggingType,
isGroupingEnabled,
isStepLocked,
isRemoveDisabled,
flatIndexByFormId,
canSignerBeModified,
isSubmitting,
onUngroup,
rowProps,
}: RecipientStepCardProps) => {
const isGroup = step.members.length > 1;
const isCombineTarget = draggingType === 'STEP' && Boolean(draggableSnapshot.combineTargetFor);
const stepLabel = step.order ?? stepIndex + 1;
// All droppable ids are anchored to the first member's formId (never a
// positional index) so they stay stable while cards are reordered —
// @hello-pangea/dnd does not support changing ids on mounted elements.
const stepAnchor = step.members[0].formId;
return (
<div
ref={draggableProvided.innerRef}
{...draggableProvided.draggableProps}
style={getDraggableStyle(draggableProvided, draggableSnapshot)}
className={cn({
'pointer-events-none': draggableSnapshot.isDragging,
})}
>
<RecipientStepGap droppableId={`gap-${stepAnchor}`} />
<Droppable droppableId={`step-members-${stepAnchor}`} type="RECIPIENT" isDropDisabled={!isGroupingEnabled}>
{(droppableProvided, droppableSnapshot) => {
const isJoinTarget = draggingType === 'RECIPIENT' && droppableSnapshot.isDraggingOver;
const isHighlighted = isCombineTarget || isJoinTarget;
return (
<div
ref={droppableProvided.innerRef}
{...droppableProvided.droppableProps}
data-testid="recipient-step-card"
className={cn('relative rounded-lg border bg-background px-3 pt-2 pb-1 transition-shadow', {
'border-primary/60 bg-primary/5': isGroup,
'bg-widget-foreground shadow-lg': draggableSnapshot.isDragging,
'border-primary ring-1 ring-primary': isHighlighted,
})}
>
{isHighlighted && (
<Badge
variant="default"
size="small"
className="absolute -top-3 right-4 z-10 flex items-center gap-x-1 shadow-sm"
>
<Users2Icon className="h-3 w-3" />
<Trans>Release to group</Trans>
</Badge>
)}
<div className="flex flex-row items-center gap-x-1">
<span
{...(draggableProvided.dragHandleProps ?? {})}
data-testid="step-drag-handle"
className={cn(
'-my-1 -ml-1.5 flex h-8 w-8 flex-shrink-0 cursor-grab items-center justify-center rounded-md hover:bg-foreground/5 active:cursor-grabbing',
{ 'pointer-events-none opacity-30': isStepLocked },
)}
>
<GripVerticalIcon className="h-4 w-4 opacity-60" />
</span>
<Badge variant={isGroup ? 'default' : 'neutral'} size="small">
<Trans>Group {stepLabel}</Trans>
</Badge>
{isGroup && (
<>
<span className="ml-1 flex items-center gap-x-1.5 text-green-700 text-xs dark:text-green-400">
<Users2Icon className="h-3.5 w-3.5" />
<Plural
value={step.members.length}
one="# recipient · any order"
other="# recipients · any order"
/>
</span>
<Button
type="button"
variant="link"
size="sm"
data-testid="ungroup-step-button"
className="ml-auto h-auto p-0 text-xs"
disabled={isStepLocked || isSubmitting}
onClick={() => onUngroup(stepIndex)}
>
<Trans>Ungroup</Trans>
</Button>
</>
)}
</div>
{step.members.map((member, memberIndex) => {
const signerIndex = flatIndexByFormId.get(member.formId) ?? -1;
const canBeModified = canSignerBeModified(member);
return (
<Draggable
key={member.formId}
draggableId={`recipient-${member.formId}`}
index={memberIndex}
isDragDisabled={isSubmitting || isStepLocked}
>
{(memberProvided, memberSnapshot) => (
<div
ref={memberProvided.innerRef}
{...memberProvided.draggableProps}
style={getDraggableStyle(memberProvided, memberSnapshot)}
className={cn({
'rounded-md bg-widget-foreground shadow-lg': memberSnapshot.isDragging,
})}
>
<RecipientRow
signerIndex={signerIndex}
signer={member}
isSequential={true}
isInputDisabled={memberSnapshot.isDragging || draggableSnapshot.isDragging}
canBeModified={canBeModified}
isRemoveDisabled={isRemoveDisabled}
dragHandleProps={memberProvided.dragHandleProps}
{...rowProps}
/>
</div>
)}
</Draggable>
);
})}
{droppableProvided.placeholder}
</div>
);
}}
</Droppable>
{isLastStep && <RecipientStepGap droppableId="gap-end" />}
</div>
);
};
@@ -0,0 +1,398 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import {
type TEditorRecipientsFormSchema,
updateEditorSigners,
} from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import {
extractRecipientToNewStep,
getLastLockedStepIndex,
groupRecipientsBySigningOrder,
isSigningOrderFrozen,
mergeSteps,
moveRecipientToStep,
normalizeGroupedSigningOrders,
reorderStep,
ungroupStep,
} from '@documenso/lib/utils/recipient-groups';
import { canEditorRecipientBeModified, isAssistantLastSigner } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import type { RecipientAutoCompleteOption } from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { Badge } from '@documenso/ui/primitives/badge';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { BeforeCapture, DropResult } from '@hello-pangea/dnd';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { useCallback, useMemo, useState } from 'react';
import { useCspNonce } from '~/utils/nonce';
import { RecipientRow } from './recipient-row';
import { type DraggingType, RecipientStepCard } from './recipient-step-card';
type TEditorSigner = TEditorRecipientsFormSchema['signers'][number];
export type RecipientStepListProps = {
showAdvancedSettings: boolean;
};
export const RecipientStepList = ({ showAdvancedSettings }: RecipientStepListProps) => {
const { t } = useLingui();
const { toast } = useToast();
const cspNonce = useCspNonce();
const { envelope, editorRecipients, isEmbedded, isCscMode } = useCurrentEnvelopeEditor();
const { form } = editorRecipients;
// Signing groups are an SES feature: TSP (AES/QES) signatures must be
// strictly sequential, so on CSC instances the group affordances (card
// combine, row-to-card join) are disabled while step reordering and
// ungrouping of invalid API-created state stay available.
const isGroupingEnabled = !isCscMode;
const [draggingType, setDraggingType] = useState<DraggingType>(null);
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery(
{
query: debouncedRecipientSearchQuery,
},
{
enabled: debouncedRecipientSearchQuery.length > 1 && !isEmbedded,
retry: false,
},
);
const recipientSuggestions = recipientSuggestionsData?.results || [];
const watchedSigners = form.watch('signers');
const isSequential = form.watch('signingOrder') === DocumentSigningOrder.SEQUENTIAL;
const { isSubmitting } = form.formState;
const { steps, ccRecipients } = useMemo(() => groupRecipientsBySigningOrder(watchedSigners), [watchedSigners]);
// Signing is sequential, so anyone who has already acted is at or before the
// current step. Those steps hold persisted orders that cannot be rewritten,
// so ordering is locked up to and including the last of them; everything
// after can still be rearranged freely.
const lastLockedStepIndex = useMemo(
() => getLastLockedStepIndex(steps, (signer) => canEditorRecipientBeModified(envelope, signer.id)),
[steps, envelope],
);
const isOrderingFrozen = useMemo(
() => isSigningOrderFrozen(steps, (signer) => canEditorRecipientBeModified(envelope, signer.id)),
[steps, envelope],
);
const isRemoveDisabled = watchedSigners.length === 1;
const flatIndexByFormId = useMemo(
() => new Map(watchedSigners.map((signer, index) => [signer.formId, index])),
[watchedSigners],
);
const canSignerBeModified = useCallback(
(signer: TEditorSigner) => canEditorRecipientBeModified(envelope, signer.id),
[envelope],
);
const applySigners = useCallback(
(updatedSigners: TEditorSigner[], options: { warnWhenAssistantLast?: boolean } = {}) => {
const { warnWhenAssistantLast = true } = options;
updateEditorSigners(form, updatedSigners);
if (warnWhenAssistantLast && isAssistantLastSigner(updatedSigners)) {
toast({
title: t`Warning: Assistant as last signer`,
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
});
}
void form.trigger('signers');
},
[form, t, toast],
);
const handleRoleChange = useCallback(
(signerIndex: number, role: RecipientRole) => {
const currentSigners = form.getValues('signers');
const signingOrder = form.getValues('signingOrder');
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, {
shouldValidate: true,
shouldDirty: true,
});
toast({
title: t`Signing order is enabled.`,
description: t`You cannot add assistants when signing order is disabled.`,
variant: 'destructive',
});
return;
}
const updatedSigners = normalizeGroupedSigningOrders(
currentSigners.map((signer, index) => ({
...signer,
role: index === signerIndex ? role : signer.role,
})),
canSignerBeModified,
);
applySigners(updatedSigners, { warnWhenAssistantLast: role === RecipientRole.ASSISTANT });
},
[form, toast, t, canSignerBeModified, applySigners],
);
const handleRemove = useCallback(
(signerIndex: number) => {
const signer = form.getValues('signers')[signerIndex];
if (!signer) {
return;
}
if (!canSignerBeModified(signer)) {
toast({
title: t`Cannot remove signer`,
description: t`This signer has already signed the document.`,
variant: 'destructive',
});
return;
}
const updatedSigners = normalizeGroupedSigningOrders(
form.getValues('signers').filter((s) => s.formId !== signer.formId),
canSignerBeModified,
);
applySigners(updatedSigners, { warnWhenAssistantLast: false });
},
[form, toast, t, canSignerBeModified, applySigners],
);
const handleUngroup = useCallback(
(stepIndex: number) => {
applySigners(ungroupStep(form.getValues('signers'), stepIndex, canSignerBeModified));
},
[form, canSignerBeModified, applySigners],
);
const handleAutoCompleteSelect = useCallback(
(signerIndex: number, suggestion: RecipientAutoCompleteOption) => {
form.setValue(`signers.${signerIndex}.email`, suggestion.email, {
shouldValidate: true,
shouldDirty: true,
});
form.setValue(`signers.${signerIndex}.name`, suggestion.name || '', {
shouldValidate: true,
shouldDirty: true,
});
},
[form],
);
const onBeforeCapture = useCallback((before: BeforeCapture) => {
setDraggingType(before.draggableId.startsWith('step-') ? 'STEP' : 'RECIPIENT');
}, []);
const onDragEnd = useCallback(
(result: DropResult) => {
setDraggingType(null);
const currentSigners = form.getValues('signers');
// Drag-and-drop ids are anchored to the first member's formId so they
// stay stable across reorders; resolve them back to step indexes here.
const { steps: currentSteps } = groupRecipientsBySigningOrder(currentSigners);
const findStepIndexByAnchor = (anchorFormId: string) =>
currentSteps.findIndex((step) => step.members[0]?.formId === anchorFormId);
if (result.type === 'STEP') {
if (result.combine) {
// Unreachable while combining is disabled, but kept as a guard so a
// stray combine result can never form a group on a CSC envelope.
if (!isGroupingEnabled) {
return;
}
const targetStepIndex = findStepIndexByAnchor(result.combine.draggableId.slice('step-'.length));
if (targetStepIndex === -1) {
return;
}
applySigners(mergeSteps(currentSigners, result.source.index, targetStepIndex, canSignerBeModified));
return;
}
if (result.destination) {
applySigners(reorderStep(currentSigners, result.source.index, result.destination.index, canSignerBeModified));
}
return;
}
if (result.type === 'RECIPIENT' && result.destination) {
const formId = result.draggableId.slice('recipient-'.length);
const { droppableId } = result.destination;
if (droppableId === 'gap-end') {
applySigners(extractRecipientToNewStep(currentSigners, formId, currentSteps.length, canSignerBeModified));
return;
}
if (droppableId.startsWith('gap-')) {
const insertStepIndex = findStepIndexByAnchor(droppableId.slice('gap-'.length));
if (insertStepIndex === -1) {
return;
}
applySigners(extractRecipientToNewStep(currentSigners, formId, insertStepIndex, canSignerBeModified));
return;
}
if (droppableId.startsWith('step-members-')) {
// Unreachable while the card drop-zones are disabled, but kept as a
// guard so a stray drop can never form a group on a CSC envelope.
if (!isGroupingEnabled) {
return;
}
const targetStepIndex = findStepIndexByAnchor(droppableId.slice('step-members-'.length));
if (targetStepIndex === -1) {
return;
}
applySigners(moveRecipientToStep(currentSigners, formId, targetStepIndex, canSignerBeModified));
}
}
},
[form, canSignerBeModified, applySigners, isGroupingEnabled],
);
const sharedRowProps = {
showAdvancedSettings,
recipientSuggestions,
isLoadingSuggestions: isLoading,
onRoleChange: handleRoleChange,
onRemove: handleRemove,
onAutoCompleteSelect: handleAutoCompleteSelect,
onSearchQueryChange: setRecipientSearchQuery,
};
return (
<div>
{!showAdvancedSettings && !isSequential && (
<div className="mb-1 flex flex-row gap-x-2 text-sm">
<span className="w-full">
<Trans>Email</Trans>
</span>
<span className="w-full">
<Trans>Name</Trans>
</span>
<span className="w-[7.5rem] flex-shrink-0" />
</div>
)}
{!isSequential ? (
<div className="flex w-full flex-col">
{watchedSigners.map((signer, index) => (
<RecipientRow
key={signer.formId}
signerIndex={index}
signer={signer}
isSequential={false}
isInputDisabled={false}
canBeModified={canSignerBeModified(signer)}
isRemoveDisabled={isRemoveDisabled}
dragHandleProps={null}
{...sharedRowProps}
/>
))}
</div>
) : (
<>
<DragDropContext nonce={cspNonce} onBeforeCapture={onBeforeCapture} onDragEnd={onDragEnd}>
<Droppable droppableId="recipient-steps" type="STEP" isCombineEnabled={isGroupingEnabled}>
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="flex w-full flex-col">
{steps.map((step, stepIndex) => {
const isStepLocked = isOrderingFrozen || stepIndex <= lastLockedStepIndex;
return (
<Draggable
key={`step-${step.members[0].formId}`}
draggableId={`step-${step.members[0].formId}`}
index={stepIndex}
isDragDisabled={isSubmitting || isStepLocked}
>
{(draggableProvided, draggableSnapshot) => (
<RecipientStepCard
stepIndex={stepIndex}
step={step}
isLastStep={stepIndex === steps.length - 1}
draggableProvided={draggableProvided}
draggableSnapshot={draggableSnapshot}
draggingType={draggingType}
isGroupingEnabled={isGroupingEnabled}
isStepLocked={isStepLocked}
isRemoveDisabled={isRemoveDisabled}
flatIndexByFormId={flatIndexByFormId}
canSignerBeModified={canSignerBeModified}
isSubmitting={isSubmitting}
onUngroup={handleUngroup}
rowProps={sharedRowProps}
/>
)}
</Draggable>
);
})}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
{ccRecipients.length > 0 && (
<div className="my-1 rounded-lg border px-3 py-1.5">
<Badge variant="neutral" size="small">
<Trans>Receives Copy</Trans>
</Badge>
{ccRecipients.map((signer) => (
<div key={signer.formId} className="my-1">
<RecipientRow
signerIndex={flatIndexByFormId.get(signer.formId) ?? -1}
signer={signer}
isSequential={true}
isInputDisabled={false}
canBeModified={canSignerBeModified(signer)}
isRemoveDisabled={isRemoveDisabled}
dragHandleProps={null}
{...sharedRowProps}
/>
</div>
))}
</div>
)}
</>
)}
</div>
);
};
@@ -10,6 +10,7 @@ import { isBase64Image } from '@documenso/lib/constants/signatures';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { ZFullFieldSchema } from '@documenso/lib/types/field';
import { renderStaticContents } from '@documenso/lib/universal/content-renderer/render-static-contents';
import {
createFieldCanvasStyleCache,
type FieldCanvasStyleCache,
@@ -17,6 +18,7 @@ import {
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { areContentsImprinted } from '@documenso/lib/utils/envelope';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
@@ -49,7 +51,7 @@ type GenericLocalField = TEnvelope['fields'][number] & {
export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
const { t, i18n } = useLingui();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
const { currentEnvelopeItem, contentImages, setRenderError } = useCurrentEnvelopeRender();
const { sessionData } = useOptionalSession();
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
@@ -535,10 +537,39 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
}
};
/**
* Render the contents when required.
*
* Since contents are imprinted on sent, we still need to render them for direct templates.
*/
const renderContents = () => {
if (!pageLayer.current || areContentsImprinted(envelope.status)) {
return;
}
try {
renderStaticContents({
contents: envelope.contents.filter(
(content) => content.contentMeta.page === pageNumber && content.envelopeItemId === currentEnvelopeItem?.id,
),
pageLayer: pageLayer.current,
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
scale,
mode: 'sign',
images: contentImages.images,
});
} catch (err) {
console.error(err);
setRenderError(true);
}
};
/**
* Initialize the Konva page canvas and all fields and interactions.
*/
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
renderContents();
renderFields();
currentPageLayer.batchDraw();
};
@@ -577,6 +608,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
pageLayer.current.destroyChildren();
cachedRenderFields.current.clear();
renderContents();
renderFields();
pageLayer.current.batchDraw();
@@ -31,6 +31,8 @@ type FilterPillCommonProps = {
enableSearch?: boolean;
searchPlaceholder?: string;
loading?: boolean;
/** Whether the selection can be removed. Defaults to true. */
clearable?: boolean;
testId?: string;
};
@@ -61,7 +63,7 @@ export type FilterPillProps = FilterPillSingleProps | FilterPillMultipleProps;
* selections followed by a "+N more" chip.
*/
export const FilterPill = (props: FilterPillProps) => {
const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, testId } = props;
const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, clearable = true, testId } = props;
const [open, setOpen] = useState(false);
@@ -84,7 +86,7 @@ export const FilterPill = (props: FilterPillProps) => {
return;
}
props.onChange(nextValue === props.value ? null : nextValue);
props.onChange(nextValue === props.value && clearable ? null : nextValue);
setOpen(false);
};
@@ -168,7 +170,7 @@ export const FilterPill = (props: FilterPillProps) => {
))}
</CommandGroup>
{hasSelection && (
{hasSelection && clearable && (
<>
<CommandSeparator />
<CommandGroup>
@@ -7,9 +7,10 @@ export type CardMetricProps = {
value?: string | number;
className?: string;
children?: React.ReactNode;
testId?: string;
};
export const CardMetric = ({ icon: Icon, title, value, className, children }: CardMetricProps) => {
export const CardMetric = ({ icon: Icon, title, value, className, children, testId }: CardMetricProps) => {
return (
<div
className={cn(
@@ -29,7 +30,7 @@ export const CardMetric = ({ icon: Icon, title, value, className, children }: Ca
</div>
{children || (
<p className="mt-auto font-semibold text-4xl text-foreground leading-8">
<p className="mt-auto font-semibold text-4xl text-foreground leading-8" data-testid={testId}>
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
</p>
)}
@@ -6,9 +6,13 @@ import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import {
canAccessOrganisationAnalytics,
canExecuteOrganisationAction,
formatOrganisationAnalyticsPath,
} from '@documenso/lib/utils/organisations';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { canExecuteTeamAction, formatAnalyticsPath } from '@documenso/lib/utils/teams';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { LanguageSwitcherDialog } from '@documenso/ui/components/common/language-switcher-dialog';
import { cn } from '@documenso/ui/lib/utils';
@@ -62,6 +66,13 @@ export const OrgMenuSwitcher = () => {
const canAccessTeamSettings = currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole);
// Team analytics take precedence when in a team context, the team page links to organisation analytics.
const analyticsPath = canAccessTeamSettings
? formatAnalyticsPath(currentTeam.url)
: currentOrganisation && canAccessOrganisationAnalytics(currentOrganisation.currentOrganisationRole)
? formatOrganisationAnalyticsPath(currentOrganisation.url)
: null;
// Use hovered org for teams display if available,
// otherwise use current team's org if in a team,
// finally fallback to selected org
@@ -271,6 +282,14 @@ export const OrgMenuSwitcher = () => {
</Link>
</DropdownMenuItem>
{analyticsPath && (
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to={analyticsPath}>
<Trans>Analytics</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link
to={
@@ -8,20 +8,40 @@ import { useRef } from 'react';
import type { PDFViewerProps } from './pdf-viewer';
import PDFViewerLazy from './pdf-viewer-lazy';
import type { EnvelopePdfViewerToolbarControl } from './pdf-viewer-toolbar';
import { EnvelopePdfViewerToolbar } from './pdf-viewer-toolbar';
export type EnvelopePdfViewerProps = {
/**
* The error message to render when there is an error.
*/
errorMessage: { title: MessageDescriptor; description: MessageDescriptor } | null;
/**
* The controls to display in the floating viewer toolbar.
*
* When omitted no toolbar is rendered.
*/
toolbar?: EnvelopePdfViewerToolbarControl[];
/**
* Additional class names for the floating viewer toolbar.
*/
toolbarClassName?: string;
} & Omit<PDFViewerProps, 'data'>;
export const EnvelopePdfViewer = ({ errorMessage, className, ...props }: EnvelopePdfViewerProps) => {
export const EnvelopePdfViewer = ({
errorMessage,
toolbar,
toolbarClassName,
className,
...props
}: EnvelopePdfViewerProps) => {
const { t } = useLingui();
const $el = useRef<HTMLDivElement>(null);
const { currentEnvelopeItem, renderError } = useCurrentEnvelopeRender();
const { currentEnvelopeItem, renderError, viewerControls } = useCurrentEnvelopeRender();
if (renderError || !currentEnvelopeItem) {
return (
@@ -45,12 +65,24 @@ export const EnvelopePdfViewer = ({ errorMessage, className, ...props }: Envelop
}
return (
<PDFViewerLazy
key={`${currentEnvelopeItem.envelopeId}-${currentEnvelopeItem.id}`}
{...props}
className={cn('h-full w-full max-w-[800px]', className)}
data={currentEnvelopeItem.data}
/>
<>
<PDFViewerLazy
key={`${currentEnvelopeItem.envelopeId}-${currentEnvelopeItem.id}`}
{...props}
className={cn('h-full w-full', className)}
data={currentEnvelopeItem.data}
zoom={viewerControls.zoom}
maxPageWidth={800}
/>
{toolbar && toolbar.length > 0 && (
<EnvelopePdfViewerToolbar
controls={toolbar}
scrollParentRef={props.scrollParentRef}
className={toolbarClassName}
/>
)}
</>
);
};
@@ -5,15 +5,26 @@ import { Trans } from '@lingui/react/macro';
type PdfViewerPageImageProps = {
imageLoadingState: ImageLoadingState;
/**
* Whether the page and everything drawn on it are ready to be shown.
*/
isPageReady: boolean;
imageProps: React.ImgHTMLAttributes<HTMLImageElement> & Record<string, unknown> & { alt: '' };
};
export const PdfViewerPageImage = ({ imageLoadingState, imageProps }: PdfViewerPageImageProps) => {
export const PdfViewerPageImage = ({ imageLoadingState, isPageReady, imageProps }: PdfViewerPageImageProps) => {
const isLoading = !isPageReady && imageLoadingState !== 'error';
return (
<>
{/* Loading State */}
{imageLoadingState === 'loading' && (
<div className="absolute inset-0 z-10 flex items-center justify-center text-muted-foreground opacity-20">
{isLoading && (
<div
className="absolute inset-0 z-10 flex items-center justify-center text-muted-foreground opacity-20"
data-testid="page-loader"
>
<Spinner />
</div>
)}
@@ -28,7 +39,12 @@ export const PdfViewerPageImage = ({ imageLoadingState, imageProps }: PdfViewerP
{/* The PDF image. */}
{imageProps.src && (
<img {...imageProps} className={cn(imageProps.className, 'select-none')} draggable={false} alt="" />
<img
{...imageProps}
className={cn(imageProps.className, 'select-none', !isPageReady && 'invisible')}
draggable={false}
alt=""
/>
)}
</>
);
@@ -0,0 +1,222 @@
import {
ENVELOPE_VIEWER_MAX_ZOOM,
ENVELOPE_VIEWER_MIN_ZOOM,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { cn } from '@documenso/ui/lib/utils';
import { Trans, useLingui } from '@lingui/react/macro';
import { EyeIcon, EyeOffIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react';
import { useLayoutEffect, useState } from 'react';
import type { ScrollTarget } from '../virtual-list/use-virtual-list';
/**
* The gap between the toolbar and the bottom of the visible viewer area.
*/
const TOOLBAR_BOTTOM_OFFSET = 24;
export type EnvelopePdfViewerToolbarControl = 'zoom' | 'fields' | 'contents';
export type EnvelopePdfViewerToolbarProps = {
/**
* The controls to display in the toolbar.
*/
controls?: EnvelopePdfViewerToolbarControl[];
/**
* The scroll container the toolbar is aligned against.
*
* The toolbar is fixed to the viewport, horizontally centered over the
* scroll container and pinned to the bottom of its visible area.
*/
scrollParentRef: ScrollTarget;
className?: string;
};
/**
* A floating toolbar for the envelope PDF viewer providing zoom and
* fields/contents visibility controls.
*
* Rendered with fixed positioning aligned to the viewer's scroll container,
* so it can be mounted anywhere within the viewer tree, including inside the
* scroll container itself.
*/
export const EnvelopePdfViewerToolbar = ({
controls = ['zoom'],
scrollParentRef,
className,
}: EnvelopePdfViewerToolbarProps) => {
const { t } = useLingui();
const { viewerControls } = useCurrentEnvelopeRender();
const {
zoom,
setZoom,
zoomIn,
zoomOut,
fieldsVisibility,
setFieldsVisibility,
contentsVisibility,
setContentsVisibility,
} = viewerControls;
const [position, setPosition] = useState<{ left: number; bottom: number } | null>(null);
/**
* Keep the toolbar aligned with the visible area of the scroll container.
*/
useLayoutEffect(() => {
// The toolbar renders inside the scroll container, so the container's ref
// is not attached yet when this first runs. It is therefore observed as
// soon as measuring finds it, which also covers it being replaced.
let observedScrollEl: HTMLElement | null = null;
function measure() {
if (scrollParentRef === 'window') {
setPosition({
left: window.innerWidth / 2,
bottom: TOOLBAR_BOTTOM_OFFSET,
});
return;
}
const scrollEl = scrollParentRef.current;
if (!scrollEl) {
setPosition(null);
return;
}
if (observedScrollEl !== scrollEl) {
if (observedScrollEl) {
resizeObserver.unobserve(observedScrollEl);
}
resizeObserver.observe(scrollEl);
observedScrollEl = scrollEl;
}
const rect = scrollEl.getBoundingClientRect();
const visibleLeft = Math.max(0, rect.left);
const visibleRight = Math.min(window.innerWidth, rect.right);
const visibleBottom = Math.min(window.innerHeight, rect.bottom);
setPosition({
left: visibleLeft + (visibleRight - visibleLeft) / 2,
bottom: window.innerHeight - visibleBottom + TOOLBAR_BOTTOM_OFFSET,
});
}
const resizeObserver = new ResizeObserver(() => measure());
measure();
window.addEventListener('resize', measure);
// Covers viewport and page level layout changes, and delivers an initial
// notification which picks up the scroll container once it is attached.
resizeObserver.observe(document.body);
return () => {
window.removeEventListener('resize', measure);
resizeObserver.disconnect();
};
}, [scrollParentRef]);
const showZoom = controls.includes('zoom');
const showFieldsToggle = controls.includes('fields');
const showContentsToggle = controls.includes('contents');
const isFieldsHidden = fieldsVisibility === 'hidden';
const isContentsHidden = contentsVisibility === 'hidden';
if (!position) {
return null;
}
return (
<div
className={cn(
'fixed z-40 flex w-fit -translate-x-1/2 items-center gap-x-0.5 rounded-xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-black/10 dark:ring-white/10',
className,
)}
style={{
left: position.left,
bottom: position.bottom,
}}
>
{showZoom && (
<>
<button
type="button"
title={t`Zoom out`}
disabled={zoom <= ENVELOPE_VIEWER_MIN_ZOOM}
onClick={zoomOut}
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
>
<ZoomOutIcon className="h-4 w-4" />
</button>
<button
type="button"
title={t`Reset zoom`}
onClick={() => setZoom(1)}
className="min-w-12 rounded-md px-1 py-1.5 text-center text-muted-foreground text-xs tabular-nums transition-colors hover:bg-muted hover:text-foreground"
>
{Math.round(zoom * 100)}%
</button>
<button
type="button"
title={t`Zoom in`}
disabled={zoom >= ENVELOPE_VIEWER_MAX_ZOOM}
onClick={zoomIn}
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
>
<ZoomInIcon className="h-4 w-4" />
</button>
</>
)}
{showZoom && (showFieldsToggle || showContentsToggle) && <div className="mx-1 h-5 w-px bg-border" />}
{showFieldsToggle && (
<button
type="button"
title={isFieldsHidden ? t`Show fields` : t`Hide fields`}
onClick={() => setFieldsVisibility(isFieldsHidden ? 'visible' : 'hidden')}
className={cn(
'flex items-center gap-x-1.5 rounded-md px-2 py-1.5 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground',
{
'text-muted-foreground/60': isFieldsHidden,
},
)}
>
{isFieldsHidden ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
<Trans>Fields</Trans>
</button>
)}
{showContentsToggle && (
<button
type="button"
title={isContentsHidden ? t`Show contents` : t`Hide contents`}
onClick={() => setContentsVisibility(isContentsHidden ? 'visible' : 'hidden')}
className={cn(
'flex items-center gap-x-1.5 rounded-md px-2 py-1.5 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground',
{
'text-muted-foreground/60': isContentsHidden,
},
)}
>
{isContentsHidden ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
<Trans>Contents</Trans>
</button>
)}
</div>
);
};
@@ -5,10 +5,10 @@ import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
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 * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import pdfjsWorker from 'pdfjs-dist/legacy/build/pdf.worker.mjs?url';
import type React from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { ScrollTarget } from '../virtual-list/use-virtual-list';
import { useVirtualList } from '../virtual-list/use-virtual-list';
@@ -29,6 +29,16 @@ const LOW_RENDER_RESOLUTION = 1;
const HIGH_RENDER_RESOLUTION = 2;
const IDLE_RENDER_DELAY = 200;
/**
* The additional height of each virtual page item on top of the scaled page
* height.
*
* 32px for the page number text and margins (my-2 = 8px * 2 + text height ~16px)
* plus 2px so the page outline ring (drawn outside the page box) does not touch
* the neighbouring items.
*/
const PAGE_ITEM_EXTRA_HEIGHT = 34;
export type PDFViewerProps = {
className?: string;
@@ -52,6 +62,23 @@ export type PDFViewerProps = {
onDocumentLoad?: () => void;
/**
* The zoom factor to render the pages at.
*
* The rendered page width is `min(containerWidth, maxPageWidth) * zoom`,
* derived in the same render pass as the layout so zoom changes apply in a
* single paint without any intermediate layout shift.
*
* Values above 1 can overflow the container horizontally, so the scroll
* parent should allow horizontal scrolling.
*/
zoom?: number;
/**
* The maximum base width of a page before the zoom is applied.
*/
maxPageWidth?: number;
/**
* Additional component to render next to the image, such as a Konva canvas
* for rendering fields.
@@ -64,6 +91,8 @@ export default function PDFViewer({
data,
scrollParentRef,
onDocumentLoad,
zoom = 1,
maxPageWidth,
customPageRenderer,
...props
}: PDFViewerProps) {
@@ -213,6 +242,8 @@ export default function PDFViewer({
numPages={pages.length}
pages={pages}
pdf={pdfRef.current}
zoom={zoom}
maxPageWidth={maxPageWidth}
customPageRenderer={customPageRenderer}
/>
)}
@@ -226,6 +257,8 @@ type VirtualizedPageListProps = {
pages: PageMeta[];
numPages: number;
pdf: pdfjsLib.PDFDocumentProxy;
zoom: number;
maxPageWidth?: number;
customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>;
};
@@ -235,6 +268,8 @@ const VirtualizedPageList = ({
pages,
numPages,
pdf,
zoom,
maxPageWidth,
customPageRenderer,
}: VirtualizedPageListProps) => {
const contentRef = useRef<HTMLDivElement>(null);
@@ -244,22 +279,81 @@ const VirtualizedPageList = ({
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;
},
itemSize: (index, width) => getPageItemSize(pages[index], getDisplayWidth(width, zoom, maxPageWidth)),
overscan: 5,
});
/**
* The width the pages are rendered at.
*
* Derived from the measured available width in the same render pass as the
* zoom, so zoom changes update the page layout and page sizes within a
* single commit, avoiding intermediate layout shifts.
*/
const displayWidth = getDisplayWidth(constraintWidth, zoom, maxPageWidth);
useScrollToPage(contentRef, scrollToItem);
const previousDisplayWidthRef = useRef(displayWidth);
/**
* Anchor the scroll position to the bottom center when the rendered page width
* changes (zoom or container resize) so zooming feels centered on the middle of the visible
* area instead of the top left of the content.
*/
useLayoutEffect(() => {
const previousDisplayWidth = previousDisplayWidthRef.current;
previousDisplayWidthRef.current = displayWidth;
if (previousDisplayWidth === displayWidth || previousDisplayWidth === 0 || displayWidth === 0) {
return;
}
const contentEl = contentRef.current;
const scrollEl = scrollParentRef === 'window' ? document.scrollingElement : scrollParentRef.current;
if (!contentEl || !scrollEl) {
return;
}
const viewportHeight = scrollParentRef === 'window' ? window.innerHeight : scrollEl.clientHeight;
// The offset of the content element from the top of the scrollable content.
// Only depends on the content above the page list, which is unaffected by zoom.
const contentOffsetTop =
contentEl.getBoundingClientRect().top -
(scrollParentRef === 'window' ? 0 : scrollEl.getBoundingClientRect().top) +
scrollEl.scrollTop;
const oldMetrics = computePageMetrics(pages, previousDisplayWidth);
const newMetrics = computePageMetrics(pages, displayWidth);
// The content-space Y coordinate currently at the vertical center of the viewport.
const oldCenterY = Math.min(
Math.max(0, scrollEl.scrollTop + viewportHeight / 2 - contentOffsetTop),
oldMetrics.totalSize,
);
// Locate which page the center point is on, and how far through it.
let pageIndex = 0;
for (let i = 0; i < pages.length; i += 1) {
if (oldMetrics.offsets[i] <= oldCenterY) {
pageIndex = i;
} else {
break;
}
}
const pageFraction =
oldMetrics.sizes[pageIndex] > 0 ? (oldCenterY - oldMetrics.offsets[pageIndex]) / oldMetrics.sizes[pageIndex] : 0;
const newCenterY = newMetrics.offsets[pageIndex] + pageFraction * newMetrics.sizes[pageIndex];
scrollEl.scrollTop = Math.max(0, newCenterY + contentOffsetTop - viewportHeight / 2);
scrollEl.scrollLeft = Math.max(0, (scrollEl.scrollWidth - scrollEl.clientWidth) / 2);
}, [displayWidth, pages, scrollParentRef]);
return (
<div
ref={contentRef}
@@ -268,7 +362,11 @@ const VirtualizedPageList = ({
data-page-count={numPages}
style={{
height: `${totalSize}px`,
width: '100%',
width: displayWidth > 0 ? `${displayWidth}px` : '100%',
// Center the pages when they fit within the container, while safely
// falling back to a start alignment when they overflow so the scroll
// container can reach all of the content.
margin: '0 auto',
position: 'relative',
}}
>
@@ -277,11 +375,11 @@ const VirtualizedPageList = ({
const pageMeta = pages[index];
const pageNumber = index + 1;
// Calculate scale based on constraint width
const scale = constraintWidth / pageMeta.width;
// Calculate scale based on the rendered page width.
const scale = displayWidth / pageMeta.width;
const scaledWidth = Math.floor(pageMeta.width * scale);
const scaledHeight = Math.floor(pageMeta.height * scale);
const scaledWidth = displayWidth;
const scaledHeight = Math.round(pageMeta.height * scale);
return (
<div
@@ -290,7 +388,7 @@ const VirtualizedPageList = ({
position: 'absolute',
top: 0,
left: 0,
width: constraintWidth,
width: displayWidth,
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
@@ -306,7 +404,12 @@ const VirtualizedPageList = ({
customPageRenderer={customPageRenderer}
/>
<p className="my-2 text-center text-[11px] text-muted-foreground/80">
<p
className={cn('my-2 text-center text-[11px] text-muted-foreground/80', {
// Allocate room for the floating viewer toolbar.
'pb-20': index === numPages - 1,
})}
>
<Trans>
Page {pageNumber} of {numPages}
</Trans>
@@ -318,6 +421,54 @@ const VirtualizedPageList = ({
);
};
/**
* The width pages are rendered at for a given available width, zoom and
* maximum base page width.
*/
const getDisplayWidth = (constraintWidth: number, zoom: number, maxPageWidth?: number) => {
if (constraintWidth === 0) {
return 0;
}
const baseWidth = maxPageWidth !== undefined ? Math.min(constraintWidth, maxPageWidth) : constraintWidth;
return Math.floor(baseWidth * zoom);
};
/**
* The height of a single virtual list item for a page rendered at the given
* width: the page height scaled to that width plus the page number footer.
*
* Single source of truth for both the virtual list `itemSize` and
* `computePageMetrics`, so the two can never drift apart.
*/
const getPageItemSize = (pageMeta: PageMeta, displayWidth: number) => {
const aspectRatio = pageMeta.height / pageMeta.width;
return displayWidth * aspectRatio + PAGE_ITEM_EXTRA_HEIGHT;
};
/**
* Compute the virtual list offsets and sizes of every page for a given
* rendered page width.
*/
const computePageMetrics = (pages: PageMeta[], displayWidth: number) => {
const offsets: number[] = [];
const sizes: number[] = [];
let totalSize = 0;
for (const pageMeta of pages) {
const size = getPageItemSize(pageMeta, displayWidth);
offsets.push(totalSize);
sizes.push(size);
totalSize += size;
}
return { offsets, sizes, totalSize };
};
type PdfViewerPageProps = {
pageNumber: number;
pdf: pdfjsLib.PDFDocumentProxy;
@@ -349,8 +500,19 @@ const PdfViewerPage = ({
scale,
});
/**
* A custom page renderer may have to load things of its own before the page
* can be drawn, which it can only begin once the page image is ready. The
* page image is held back until then so both appear at once.
*/
const [isCustomRendererReady, setIsCustomRendererReady] = useState(false);
const isPageReady = imageLoadingState === 'loaded' && (!CustomPageRenderer || isCustomRendererReady);
return (
<div className="relative w-full rounded border border-border" style={{ width: scaledWidth, height: scaledHeight }}>
// Must use ring instead of border since borders take up space inside the box,
// which shrinks the page image (constrained to the box by `max-width: 100%`)
<div className="relative w-full rounded ring-1 ring-border" style={{ width: scaledWidth, height: scaledHeight }}>
{CustomPageRenderer && imageLoadingState === 'loaded' && (
<CustomPageRenderer
pageData={{
@@ -360,11 +522,12 @@ const PdfViewerPage = ({
pageWidth: unscaledWidth,
pageHeight: unscaledHeight,
imageLoadingState,
onReadyChange: setIsCustomRendererReady,
}}
/>
)}
<PdfViewerPageImage imageLoadingState={imageLoadingState} imageProps={imageProps} />
<PdfViewerPageImage imageLoadingState={imageLoadingState} isPageReady={isPageReady} imageProps={imageProps} />
</div>
);
};
@@ -502,8 +665,16 @@ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }:
const imageProps = useMemo(
(): React.ImgHTMLAttributes<HTMLImageElement> & Record<string, unknown> & { alt: '' } => ({
className: PDF_VIEWER_PAGE_CLASSNAME,
width: Math.floor(scaledWidth),
height: Math.floor(scaledHeight),
width: scaledWidth,
height: scaledHeight,
// Pin the rendered size to the page size. Tailwind's preflight applies
// `max-width: 100%; height: auto` to images, which would otherwise let
// the container clamp the image out of alignment with the page overlay.
style: {
width: scaledWidth,
height: scaledHeight,
maxWidth: 'none',
},
alt: '',
onLoad: () => setImageLoadingState('loaded'),
onError: () => setImageLoadingState('error'),
@@ -1,37 +0,0 @@
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { Trans } from '@lingui/react/macro';
import { ChevronLeft, Loader } from 'lucide-react';
import { Link } from 'react-router';
export default function DocumentEditSkeleton() {
return (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
<Link to="/" className="flex grow-0 items-center text-documenso-700 hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Documents</Trans>
</Link>
<h1 className="mt-4 grow-0 truncate font-semibold text-2xl md:text-3xl">
<Trans>Loading Document...</Trans>
</h1>
<div className="flex h-10 items-center">
<Skeleton className="my-6 h-4 w-24 rounded-2xl" />
</div>
<div className="mt-4 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
<div className="col-span-12 rounded-xl border-2 border-border bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7 dark:bg-background">
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
<Loader className="h-12 w-12 animate-spin text-documenso" />
<p className="mt-4 text-muted-foreground">
<Trans>Loading document...</Trans>
</p>
</div>
</div>
<div className="col-span-12 rounded-xl border-2 border-border bg-background before:rounded-xl lg:col-span-6 xl:col-span-5" />
</div>
</div>
);
}

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