mirror of
https://github.com/documenso/documenso.git
synced 2026-09-30 00:34:35 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d024a07f7 |
@@ -80,8 +80,6 @@ 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.
|
||||
|
||||
@@ -15,8 +15,6 @@ 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. |
|
||||
@@ -39,28 +37,7 @@ 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
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@ 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).
|
||||
@@ -21,13 +19,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` | string | ID of the parent envelope |
|
||||
| `envelopeId` | number | ID of the parent envelope |
|
||||
| `envelopeItemId` | string | ID of the PDF item the field is placed on |
|
||||
| `page` | number | Page number (1-indexed) |
|
||||
| `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 |
|
||||
| `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) |
|
||||
| `customText` | string | Value entered by the recipient |
|
||||
| `inserted` | boolean | Whether the field has been completed |
|
||||
| `fieldMeta` | object \| null | Type-specific configuration options |
|
||||
@@ -40,19 +38,18 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
"secondaryId": "field_abc123",
|
||||
"type": "SIGNATURE",
|
||||
"recipientId": 123,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
|
||||
"envelopeId": 789,
|
||||
"envelopeItemId": "envelope_item_xyz",
|
||||
"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,
|
||||
"overflow": "auto"
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -64,7 +61,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
| Type | Description | Auto-filled |
|
||||
| ---------------- | ----------------------------------------- | ----------- |
|
||||
| `SIGNATURE` | Drawn, typed, or uploaded signature | 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 |
|
||||
| `FREE_SIGNATURE` | Unrestricted signature without validation | No |
|
||||
| `INITIALS` | Recipient's initials | No |
|
||||
| `NAME` | Recipient's full name | Yes |
|
||||
| `EMAIL` | Recipient's email address | Yes |
|
||||
@@ -137,12 +134,10 @@ POST /envelope/field/create-many
|
||||
|
||||
### Request Body
|
||||
|
||||
| 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`.
|
||||
| Field | Type | Required | Description |
|
||||
| ----------- | ------ | -------- | ------------------------------- |
|
||||
| `documentId`| number | Yes | The document ID |
|
||||
| `fields` | array | Yes | Array of field configurations |
|
||||
|
||||
### Code Examples
|
||||
|
||||
@@ -153,32 +148,32 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/field/create-many" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"data": [
|
||||
"documentId": 123,
|
||||
"fields": [
|
||||
{
|
||||
"type": "SIGNATURE",
|
||||
"recipientId": 456,
|
||||
"page": 1,
|
||||
"positionX": 10,
|
||||
"positionY": 80,
|
||||
"pageNumber": 1,
|
||||
"pageX": 10,
|
||||
"pageY": 80,
|
||||
"width": 30,
|
||||
"height": 5
|
||||
},
|
||||
{
|
||||
"type": "DATE",
|
||||
"recipientId": 456,
|
||||
"page": 1,
|
||||
"positionX": 50,
|
||||
"positionY": 80,
|
||||
"pageNumber": 1,
|
||||
"pageX": 50,
|
||||
"pageY": 80,
|
||||
"width": 20,
|
||||
"height": 3
|
||||
},
|
||||
{
|
||||
"type": "TEXT",
|
||||
"recipientId": 456,
|
||||
"page": 1,
|
||||
"positionX": 10,
|
||||
"positionY": 70,
|
||||
"pageNumber": 1,
|
||||
"pageX": 10,
|
||||
"pageY": 70,
|
||||
"width": 40,
|
||||
"height": 4,
|
||||
"fieldMeta": {
|
||||
@@ -204,32 +199,32 @@ const response = await fetch(
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
envelopeId: 'envelope_abcdefhiklmnorst',
|
||||
data: [
|
||||
documentId: 123,
|
||||
fields: [
|
||||
{
|
||||
type: 'SIGNATURE',
|
||||
recipientId: 456,
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 80,
|
||||
pageNumber: 1,
|
||||
pageX: 10,
|
||||
pageY: 80,
|
||||
width: 30,
|
||||
height: 5,
|
||||
},
|
||||
{
|
||||
type: 'DATE',
|
||||
recipientId: 456,
|
||||
page: 1,
|
||||
positionX: 50,
|
||||
positionY: 80,
|
||||
pageNumber: 1,
|
||||
pageX: 50,
|
||||
pageY: 80,
|
||||
width: 20,
|
||||
height: 3,
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
recipientId: 456,
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 70,
|
||||
pageNumber: 1,
|
||||
pageX: 10,
|
||||
pageY: 70,
|
||||
width: 40,
|
||||
height: 4,
|
||||
fieldMeta: {
|
||||
@@ -244,8 +239,8 @@ const response = await fetch(
|
||||
}
|
||||
);
|
||||
|
||||
const { data } = await response.json();
|
||||
console.log(`Created ${data.length} fields`);
|
||||
const { fields } = await response.json();
|
||||
console.log(`Created ${fields.length} fields`);
|
||||
|
||||
````
|
||||
</Tab>
|
||||
@@ -255,68 +250,36 @@ console.log(`Created ${data.length} fields`);
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
"fields": [
|
||||
{
|
||||
"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",
|
||||
"customText": "",
|
||||
"inserted": false,
|
||||
"fieldMeta": {
|
||||
"type": "signature",
|
||||
"fontSize": 18,
|
||||
"overflow": "auto"
|
||||
}
|
||||
"positionX": 10,
|
||||
"positionY": 80,
|
||||
"width": 30,
|
||||
"height": 5
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"customText": "",
|
||||
"inserted": false,
|
||||
"fieldMeta": {
|
||||
"type": "date",
|
||||
"fontSize": 12,
|
||||
"textAlign": "left",
|
||||
"overflow": "auto"
|
||||
}
|
||||
"positionX": 50,
|
||||
"positionY": 80,
|
||||
"width": 20,
|
||||
"height": 3
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"customText": "",
|
||||
"inserted": false,
|
||||
"fieldMeta": {
|
||||
"type": "text",
|
||||
"label": "Job Title",
|
||||
"placeholder": "Enter your job title",
|
||||
"required": true
|
||||
}
|
||||
"positionX": 10,
|
||||
"positionY": 70,
|
||||
"width": 40,
|
||||
"height": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -336,10 +299,8 @@ POST /envelope/field/update-many
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------ | ------ | -------- | ----------------------------- |
|
||||
| `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.
|
||||
| `documentId` | number | Yes | The document ID |
|
||||
| `fields` | array | Yes | Array of field update objects |
|
||||
|
||||
### Code Examples
|
||||
|
||||
@@ -350,17 +311,17 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/field/update-many" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"data": [
|
||||
"documentId": 123,
|
||||
"fields": [
|
||||
{
|
||||
"id": 101,
|
||||
"type": "SIGNATURE",
|
||||
"positionY": 85
|
||||
"pageY": 85
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"type": "DATE",
|
||||
"positionY": 85
|
||||
"pageY": 85
|
||||
}
|
||||
]
|
||||
}'
|
||||
@@ -377,16 +338,16 @@ const response = await fetch(
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
envelopeId: 'envelope_abcdefhiklmnorst',
|
||||
data: [
|
||||
{ id: 101, type: 'SIGNATURE', positionY: 85 },
|
||||
{ id: 102, type: 'DATE', positionY: 85 },
|
||||
documentId: 123,
|
||||
fields: [
|
||||
{ id: 101, type: 'SIGNATURE', pageY: 85 },
|
||||
{ id: 102, type: 'DATE', pageY: 85 },
|
||||
],
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const { data } = await response.json();
|
||||
const { fields } = await response.json();
|
||||
|
||||
````
|
||||
</Tab>
|
||||
@@ -396,48 +357,9 @@ const { data } = await response.json();
|
||||
|
||||
```json
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
"fields": [
|
||||
{ "id": 101, "type": "SIGNATURE", "positionY": 85 },
|
||||
{ "id": 102, "type": "DATE", "positionY": 85 }
|
||||
]
|
||||
}
|
||||
````
|
||||
@@ -521,8 +443,8 @@ Fields use percentage-based coordinates relative to the PDF page dimensions.
|
||||
(0,0) ─────────────────────────── (100,0)
|
||||
│ │
|
||||
│ ┌─────────┐ │
|
||||
│ │ Field │ (positionX: 10, │
|
||||
│ │ │ positionY: 20, │
|
||||
│ │ Field │ (pageX: 10, │
|
||||
│ │ │ pageY: 20, │
|
||||
│ └─────────┘ width: 30, │
|
||||
│ height: 5) │
|
||||
│ │
|
||||
@@ -535,9 +457,9 @@ Fields use percentage-based coordinates relative to the PDF page dimensions.
|
||||
const field = {
|
||||
type: 'SIGNATURE',
|
||||
recipientId: 123,
|
||||
page: 1,
|
||||
positionX: 60, // 60% from left
|
||||
positionY: 85, // 85% from top (near bottom)
|
||||
pageNumber: 1,
|
||||
pageX: 60, // 60% from left
|
||||
pageY: 85, // 85% from top (near bottom)
|
||||
width: 30, // 30% of page width
|
||||
height: 8, // 8% of page height
|
||||
};
|
||||
@@ -557,33 +479,6 @@ 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
|
||||
@@ -748,15 +643,15 @@ All field types support these base options:
|
||||
Create a document with a signature block containing multiple field types:
|
||||
|
||||
```typescript
|
||||
async function addSignatureBlock(envelopeId: string, recipientId: number) {
|
||||
const data = [
|
||||
async function addSignatureBlock(documentId: number, recipientId: number) {
|
||||
const fields = [
|
||||
// Signature
|
||||
{
|
||||
type: 'SIGNATURE',
|
||||
recipientId,
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 80,
|
||||
pageNumber: 1,
|
||||
pageX: 10,
|
||||
pageY: 80,
|
||||
width: 30,
|
||||
height: 8,
|
||||
fieldMeta: {
|
||||
@@ -768,9 +663,9 @@ async function addSignatureBlock(envelopeId: string, recipientId: number) {
|
||||
{
|
||||
type: 'NAME',
|
||||
recipientId,
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 90,
|
||||
pageNumber: 1,
|
||||
pageX: 10,
|
||||
pageY: 90,
|
||||
width: 30,
|
||||
height: 4,
|
||||
fieldMeta: {
|
||||
@@ -782,9 +677,9 @@ async function addSignatureBlock(envelopeId: string, recipientId: number) {
|
||||
{
|
||||
type: 'DATE',
|
||||
recipientId,
|
||||
page: 1,
|
||||
positionX: 50,
|
||||
positionY: 80,
|
||||
pageNumber: 1,
|
||||
pageX: 50,
|
||||
pageY: 80,
|
||||
width: 20,
|
||||
height: 4,
|
||||
fieldMeta: {
|
||||
@@ -796,9 +691,9 @@ async function addSignatureBlock(envelopeId: string, recipientId: number) {
|
||||
{
|
||||
type: 'TEXT',
|
||||
recipientId,
|
||||
page: 1,
|
||||
positionX: 50,
|
||||
positionY: 90,
|
||||
pageNumber: 1,
|
||||
pageX: 50,
|
||||
pageY: 90,
|
||||
width: 30,
|
||||
height: 4,
|
||||
fieldMeta: {
|
||||
@@ -815,7 +710,7 @@ async function addSignatureBlock(envelopeId: string, recipientId: number) {
|
||||
Authorization: 'api_xxxxxxxxxxxxxxxx',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ envelopeId, data }),
|
||||
body: JSON.stringify({ documentId, fields }),
|
||||
});
|
||||
|
||||
return response.json();
|
||||
|
||||
@@ -60,8 +60,8 @@ Authorization: api_xxxxxxxxxxxxxxxx
|
||||
href="/docs/developers/api/templates"
|
||||
/>
|
||||
<Card
|
||||
title="Team-scoped access"
|
||||
description="Use team-scoped API tokens with envelope endpoints."
|
||||
title="Teams"
|
||||
description="Manage teams and team members."
|
||||
href="/docs/developers/api/teams"
|
||||
/>
|
||||
</Cards>
|
||||
|
||||
@@ -6,8 +6,6 @@ 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).
|
||||
@@ -18,7 +16,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"envelopeId": "clu1abc2def3ghi4jkl",
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"role": "SIGNER",
|
||||
@@ -136,7 +134,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": "envelope_abcdefhiklmnorst",
|
||||
"envelopeId": "clu1abc2def3ghi4jkl",
|
||||
"data": [
|
||||
{
|
||||
"email": "signer@example.com",
|
||||
@@ -166,7 +164,7 @@ const response = await fetch(
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
envelopeId: 'envelope_abcdefhiklmnorst',
|
||||
envelopeId: 'clu1abc2def3ghi4jkl',
|
||||
data: [
|
||||
{
|
||||
email: 'signer@example.com',
|
||||
@@ -198,7 +196,7 @@ const { data: recipients } = await response.json();
|
||||
"data": [
|
||||
{
|
||||
"id": 789,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"envelopeId": "clu1abc2def3ghi4jkl",
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"role": "SIGNER",
|
||||
@@ -211,7 +209,7 @@ const { data: recipients } = await response.json();
|
||||
},
|
||||
{
|
||||
"id": 790,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"envelopeId": "clu1abc2def3ghi4jkl",
|
||||
"email": "approver@example.com",
|
||||
"name": "Jane Smith",
|
||||
"role": "APPROVER",
|
||||
@@ -264,7 +262,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": "envelope_abcdefhiklmnorst",
|
||||
"envelopeId": "clu1abc2def3ghi4jkl",
|
||||
"data": [
|
||||
{
|
||||
"id": 789,
|
||||
@@ -286,7 +284,7 @@ const response = await fetch(
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
envelopeId: 'envelope_abcdefhiklmnorst',
|
||||
envelopeId: 'clu1abc2def3ghi4jkl',
|
||||
data: [
|
||||
{
|
||||
id: 789,
|
||||
@@ -389,7 +387,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/recipien
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
envelopeId: 'envelope_abcdefhiklmnorst',
|
||||
envelopeId: 'clu1abc2def3ghi4jkl',
|
||||
data: [
|
||||
{
|
||||
email: 'approver@example.com',
|
||||
@@ -464,7 +462,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/recipien
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
envelopeId: 'envelope_abcdefhiklmnorst',
|
||||
envelopeId: 'clu1abc2def3ghi4jkl',
|
||||
data: [
|
||||
{
|
||||
email: 'signer@example.com',
|
||||
|
||||
@@ -1,29 +1,44 @@
|
||||
---
|
||||
title: Team-Scoped API Access
|
||||
description: Use team-scoped API tokens with document and template envelopes.
|
||||
title: Teams API
|
||||
description: Manage team resources, documents, and templates with team-scoped API tokens.
|
||||
---
|
||||
|
||||
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 Context
|
||||
## Team Object
|
||||
|
||||
<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>
|
||||
A team object contains the following properties:
|
||||
|
||||
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.
|
||||
| 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"
|
||||
}
|
||||
```
|
||||
|
||||
## Team-Scoped API Tokens
|
||||
|
||||
@@ -141,26 +156,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?type=DOCUMENT" \
|
||||
curl -X GET "https://app.documenso.com/api/v2/envelope" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx"
|
||||
|
||||
# Filter by status
|
||||
curl -X GET "https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=PENDING" \
|
||||
curl -X GET "https://app.documenso.com/api/v2/envelope?status=PENDING" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx"
|
||||
````
|
||||
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
const response = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT', {
|
||||
const response = await fetch('https://app.documenso.com/api/v2/envelope', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: TEAM_API_TOKEN,
|
||||
},
|
||||
});
|
||||
|
||||
const { data, count } = await response.json();
|
||||
console.log(`Found ${count} team documents`);
|
||||
const { data, pagination } = await response.json();
|
||||
console.log(`Found ${pagination.totalItems} team documents`);
|
||||
|
||||
````
|
||||
</Tab>
|
||||
@@ -175,11 +190,10 @@ 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/envelope/create" \
|
||||
curl -X POST "https://app.documenso.com/api/v2/template/create" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-F 'payload={
|
||||
"type": "TEMPLATE",
|
||||
"title": "NDA Template",
|
||||
"recipients": [
|
||||
{
|
||||
@@ -209,7 +223,6 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/create" \
|
||||
const form = new FormData();
|
||||
|
||||
const payload = {
|
||||
type: 'TEMPLATE',
|
||||
title: 'NDA Template',
|
||||
recipients: [
|
||||
{
|
||||
@@ -236,7 +249,7 @@ form.append('files', fs.createReadStream('./nda-template.pdf'), {
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
|
||||
const response = await fetch('https://app.documenso.com/api/v2/envelope/create', {
|
||||
const response = await fetch('https://app.documenso.com/api/v2/template/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: TEAM_API_TOKEN,
|
||||
@@ -244,8 +257,8 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/create',
|
||||
body: form,
|
||||
});
|
||||
|
||||
const { id } = await response.json();
|
||||
console.log('Created team template envelope:', id);
|
||||
const template = await response.json();
|
||||
console.log('Created team template:', template.id);
|
||||
````
|
||||
</Tab>
|
||||
</Tabs>
|
||||
@@ -255,14 +268,14 @@ console.log('Created team template envelope:', id);
|
||||
<Tabs items={['curl', 'TypeScript']}>
|
||||
<Tab value="curl">
|
||||
```bash
|
||||
curl -X GET "https://app.documenso.com/api/v2/envelope?type=TEMPLATE" \
|
||||
curl -X GET "https://app.documenso.com/api/v2/template" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx"
|
||||
````
|
||||
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
const response = await fetch('https://app.documenso.com/api/v2/envelope?type=TEMPLATE', {
|
||||
const response = await fetch('https://app.documenso.com/api/v2/template', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: TEAM_API_TOKEN,
|
||||
@@ -317,19 +330,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?type=DOCUMENT&status=PENDING', {
|
||||
const salesResponse = await fetch('https://app.documenso.com/api/v2/envelope?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?type=DOCUMENT&status=COMPLETED', {
|
||||
const legalResponse = await fetch('https://app.documenso.com/api/v2/envelope?status=COMPLETED', {
|
||||
headers: { Authorization: LEGAL_TEAM_TOKEN },
|
||||
});
|
||||
const legalDocs = await legalResponse.json();
|
||||
|
||||
console.log(`Sales team: ${salesDocs.count} pending`);
|
||||
console.log(`Legal team: ${legalDocs.count} completed`);
|
||||
console.log(`Sales team: ${salesDocs.pagination.totalItems} pending`);
|
||||
console.log(`Legal team: ${legalDocs.pagination.totalItems} completed`);
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
@@ -13,194 +13,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
see the [OpenAPI Reference](https://openapi.documenso.com).
|
||||
</Callout>
|
||||
|
||||
## 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
|
||||
## Template Object
|
||||
|
||||
A template object contains the following properties:
|
||||
|
||||
@@ -278,7 +91,7 @@ A template object contains the following properties:
|
||||
}
|
||||
```
|
||||
|
||||
## List Templates (Deprecated)
|
||||
## List Templates
|
||||
|
||||
Retrieve a paginated list of templates.
|
||||
|
||||
@@ -326,8 +139,8 @@ const response = await fetch(`${BASE_URL}/template`, {
|
||||
},
|
||||
});
|
||||
|
||||
const { data, count } = await response.json();
|
||||
console.log(`Found ${count} templates`);
|
||||
const { data, pagination } = await response.json();
|
||||
console.log(`Found ${pagination.totalItems} templates`);
|
||||
|
||||
// Filter by type
|
||||
const privateResponse = await fetch(
|
||||
@@ -368,16 +181,18 @@ const privateTemplates = await privateResponse.json();
|
||||
]
|
||||
}
|
||||
],
|
||||
"count": 25,
|
||||
"currentPage": 1,
|
||||
"perPage": 10,
|
||||
"totalPages": 3
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"perPage": 10,
|
||||
"totalPages": 3,
|
||||
"totalItems": 25
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Template (Deprecated)
|
||||
## Get Template
|
||||
|
||||
Retrieve a single template by ID.
|
||||
|
||||
@@ -423,9 +238,9 @@ Returns the full template object including recipients, fields, and metadata.
|
||||
|
||||
---
|
||||
|
||||
## Create Document from Template (Deprecated)
|
||||
## Create Document from Template
|
||||
|
||||
Create a new document using the deprecated template endpoint.
|
||||
Create a new document using a template. This is the primary way to use templates programmatically.
|
||||
|
||||
<Callout type="info">
|
||||
This endpoint does not support [PDF placeholder parsing](/docs/users/documents/advanced/pdf-placeholders). Use `POST /envelope/create` for placeholder-based field positioning.
|
||||
@@ -600,57 +415,32 @@ const prefilledDocument = await prefillResponse.json();
|
||||
|
||||
### Response
|
||||
|
||||
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`.
|
||||
Returns the created document object with recipients and signing URLs.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 789,
|
||||
"envelopeId": "envelope_xyz789",
|
||||
"id": "envelope_xyz789",
|
||||
"type": "DOCUMENT",
|
||||
"status": "PENDING",
|
||||
"source": "TEMPLATE",
|
||||
"title": "Employment Contract",
|
||||
"source": "TEMPLATE",
|
||||
"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",
|
||||
"signingOrder": 1
|
||||
"signingUrl": "https://app.documenso.com/sign/abc123"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
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 (Deprecated)
|
||||
## Override Template Settings
|
||||
|
||||
When creating a document from a template, you can override various settings:
|
||||
|
||||
@@ -698,7 +488,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
|
||||
|
||||
---
|
||||
|
||||
## Prefill Fields (Deprecated)
|
||||
## Prefill Fields
|
||||
|
||||
Prefill field values when creating a document from a template. This is useful for populating known data before sending.
|
||||
|
||||
@@ -787,7 +577,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
|
||||
|
||||
---
|
||||
|
||||
## Update Template (Deprecated)
|
||||
## Update Template
|
||||
|
||||
Update a template's properties.
|
||||
|
||||
@@ -853,7 +643,7 @@ const template = await response.json();
|
||||
|
||||
---
|
||||
|
||||
## Duplicate Template (Deprecated)
|
||||
## Duplicate Template
|
||||
|
||||
Create a copy of an existing template.
|
||||
|
||||
@@ -905,7 +695,7 @@ console.log('New template ID:', duplicatedTemplate.id);
|
||||
|
||||
---
|
||||
|
||||
## Delete Template (Deprecated)
|
||||
## Delete Template
|
||||
|
||||
Delete a template.
|
||||
|
||||
@@ -964,7 +754,7 @@ const { success } = await response.json();
|
||||
|
||||
---
|
||||
|
||||
## Direct Link Templates (Deprecated)
|
||||
## Direct Link Templates
|
||||
|
||||
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.
|
||||
|
||||
@@ -1108,7 +898,7 @@ const { success } = await response.json();
|
||||
|
||||
---
|
||||
|
||||
## Custom Document Data (Deprecated)
|
||||
## Custom Document Data
|
||||
|
||||
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.
|
||||
|
||||
@@ -1123,7 +913,7 @@ See the [OpenAPI Reference](https://openapi.documenso.com) for the full request
|
||||
|
||||
---
|
||||
|
||||
## Template Types (Legacy)
|
||||
## Template Types
|
||||
|
||||
| Type | Description |
|
||||
| --------- | ------------------------------------------------------------------ |
|
||||
@@ -1132,7 +922,7 @@ See the [OpenAPI Reference](https://openapi.documenso.com) for the full request
|
||||
|
||||
---
|
||||
|
||||
## Complete Legacy Example: Contract Workflow (Deprecated)
|
||||
## Complete Example: Contract Workflow
|
||||
|
||||
This example demonstrates a complete workflow for using templates to send contracts.
|
||||
|
||||
@@ -1206,29 +996,16 @@ async function sendEmploymentContract(employeeData: {
|
||||
subject: `Employment Contract for ${employeeData.name}`,
|
||||
message: `Hi ${employeeData.name},\n\nPlease review and sign your employment contract.`,
|
||||
},
|
||||
distributeDocument: false,
|
||||
distributeDocument: true,
|
||||
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 {
|
||||
envelopeId: document.envelopeId,
|
||||
signingUrl: distribution.recipients[0].signingUrl,
|
||||
documentId: document.id,
|
||||
signingUrl: document.recipients[0].signingUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1241,7 +1018,7 @@ const result = await sendEmploymentContract({
|
||||
startDate: '2025-03-01',
|
||||
});
|
||||
|
||||
console.log('Document created:', result.envelopeId);
|
||||
console.log('Document created:', result.documentId);
|
||||
console.log('Signing URL:', result.signingUrl);
|
||||
````
|
||||
|
||||
|
||||
@@ -120,25 +120,20 @@ 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 |
|
||||
| `.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 |
|
||||
| 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 |
|
||||
|
||||
### Field Data Attributes
|
||||
|
||||
|
||||
@@ -262,11 +262,8 @@ 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 and prefill data
|
||||
</Step>
|
||||
<Step>
|
||||
Distribute the returned envelope via <code>POST /envelope/distribute</code> and read its signing
|
||||
links
|
||||
Call <code>POST /template/use</code> with recipients, prefill data, and{' '}
|
||||
<code>distributeDocument: true</code>
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
@@ -300,7 +297,7 @@ type TemplateRecipient = {
|
||||
async function sendEmploymentContract(
|
||||
templateId: number,
|
||||
employee: EmployeeData,
|
||||
): Promise<{ documentId: number; signingUrl: string }> {
|
||||
): Promise<{ documentId: string; signingUrl: string }> {
|
||||
const templateResponse = await fetch(`${BASE_URL}/template/${templateId}`, {
|
||||
headers: { Authorization: API_TOKEN },
|
||||
});
|
||||
@@ -377,6 +374,7 @@ 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]}`,
|
||||
}),
|
||||
});
|
||||
@@ -388,25 +386,9 @@ 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: distributeResult.recipients[0].signingUrl,
|
||||
signingUrl: document.recipients[0].signingUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -471,17 +453,12 @@ 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\"
|
||||
}")
|
||||
|
||||
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')"
|
||||
echo "Document created:"
|
||||
echo $RESPONSE | jq '{id, signingUrl: .recipients[0].signingUrl}'
|
||||
````
|
||||
|
||||
</Tab>
|
||||
@@ -499,10 +476,8 @@ 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>
|
||||
</Step>
|
||||
<Step>
|
||||
Distribute each returned envelope via <code>POST /envelope/distribute</code>
|
||||
For each recipient, call <code>POST /template/use</code> with{' '}
|
||||
<code>distributeDocument: true</code>
|
||||
</Step>
|
||||
<Step>
|
||||
Process in batches with a short delay to respect rate limits (e.g. 1000 requests/minute)
|
||||
@@ -565,6 +540,7 @@ async function bulkSendFromTemplate(
|
||||
recipients: [
|
||||
{ id: signerSlot.id, email: recipient.email, name: recipient.name },
|
||||
],
|
||||
distributeDocument: true,
|
||||
externalId: `bulk-${Date.now()}-${recipient.email}`,
|
||||
}),
|
||||
});
|
||||
@@ -575,26 +551,10 @@ 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.envelopeId,
|
||||
signingUrl: distributeResult.recipients[0].signingUrl,
|
||||
envelopeId: document.id,
|
||||
signingUrl: document.recipients[0].signingUrl,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -667,24 +627,14 @@ for RECIPIENT in "${RECIPIENTS[@]}"; do
|
||||
\"email\": \"${EMAIL}\",
|
||||
\"name\": \"${NAME}\"
|
||||
}],
|
||||
\"distributeDocument\": true,
|
||||
\"externalId\": \"bulk-$(date +%s)-${EMAIL}\"
|
||||
}")
|
||||
|
||||
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
|
||||
if echo $RESPONSE | jq -e '.id' > /dev/null 2>&1; then
|
||||
echo " Success: $(echo $RESPONSE | jq -r '.id')"
|
||||
else
|
||||
echo " Failed to create: $(echo $RESPONSE | jq -r '.message')"
|
||||
echo " Failed: $(echo $RESPONSE | jq -r '.message')"
|
||||
fi
|
||||
|
||||
# Rate limiting delay
|
||||
@@ -887,15 +837,13 @@ 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 = 'original' | 'pending' | 'signed';
|
||||
type DownloadVersion = 'signed' | 'original';
|
||||
|
||||
async function downloadDocument(
|
||||
envelopeId: string,
|
||||
@@ -949,7 +897,7 @@ async function downloadAllCompletedDocuments(outputDir: string): Promise<void> {
|
||||
{ headers: { Authorization: API_TOKEN } },
|
||||
);
|
||||
|
||||
const { data, count, currentPage, perPage, totalPages } = await response.json();
|
||||
const { data, pagination } = await response.json();
|
||||
|
||||
for (const envelope of data) {
|
||||
try {
|
||||
@@ -963,8 +911,8 @@ async function downloadAllCompletedDocuments(outputDir: string): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
hasMore = currentPage < totalPages;
|
||||
page = currentPage + 1;
|
||||
hasMore = page < pagination.totalPages;
|
||||
page++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,18 +24,20 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
### Select a team
|
||||
### Open settings
|
||||
|
||||
Log in to Documenso and open the team that the integration should access. API tokens are scoped to a
|
||||
team.
|
||||
- Log in to your Documenso account
|
||||
- Click your avatar in the top right corner
|
||||
- Select **Settings** from the dropdown menu
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Open API Tokens
|
||||
### Navigate to the API Tokens tab
|
||||
|
||||
Go to **Team Settings** → **API Tokens**, or open
|
||||
`/t/{teamUrl}/settings/tokens` after replacing `{teamUrl}` with your team's URL.
|
||||
Go to **Settings** and open the **API Tokens** tab.
|
||||
|
||||

|
||||
|
||||
@@ -46,7 +48,7 @@ Go to **Team Settings** → **API Tokens**, or open
|
||||
|
||||
- Click **Create Token**
|
||||
- Enter a descriptive name (e.g., `production-backend`, `zapier-integration`)
|
||||
- Select an expiration period: 7 days, 1 month, 3 months, 6 months, 12 months, or Never
|
||||
- Select an expiration period: never expires, 7 days, 1 month, 3 months, 6 months, or 1 year
|
||||
- Click **Create Token**
|
||||
|
||||
</Step>
|
||||
@@ -73,21 +75,21 @@ Include the token in the `Authorization` header of your HTTP requests.
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
curl https://app.documenso.com/api/v2/envelope \
|
||||
curl https://app.documenso.com/api/v2/document \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx"
|
||||
```
|
||||
|
||||
### JavaScript / TypeScript
|
||||
|
||||
```typescript
|
||||
const response = await fetch('https://app.documenso.com/api/v2/envelope', {
|
||||
const response = await fetch('https://app.documenso.com/api/v2/document', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'api_xxxxxxxxxxxxxxxx',
|
||||
},
|
||||
});
|
||||
|
||||
const envelopes = await response.json();
|
||||
const documents = await response.json();
|
||||
```
|
||||
|
||||
### Using the TypeScript SDK
|
||||
@@ -127,7 +129,7 @@ SDKs are available for [TypeScript](https://github.com/documenso/sdk-typescript)
|
||||
|
||||
## Token Security
|
||||
|
||||
API tokens grant full API access to the team they were created for. Follow these practices to keep them secure:
|
||||
API tokens grant full access to your account. 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.
|
||||
@@ -153,11 +155,12 @@ const client = new Documenso({
|
||||
|
||||
## Token Scope
|
||||
|
||||
API tokens have full API access to the team they were created for, including:
|
||||
API tokens have full access to your account, 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.
|
||||
|
||||
@@ -168,7 +171,7 @@ To revoke a token:
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
Go to **Team Settings** → **API Tokens**
|
||||
Go to **Settings** > **API Tokens**
|
||||
</Step>
|
||||
<Step>
|
||||
Find the token you want to revoke
|
||||
@@ -191,7 +194,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 team.
|
||||
Ensure you're accessing resources owned by the token's account.
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
|
||||
|
||||
@@ -78,10 +78,12 @@ A successful response returns a list of your documents (envelopes):
|
||||
"createdAt": "2025-01-15T10:30:00.000Z"
|
||||
}
|
||||
],
|
||||
"count": 1,
|
||||
"currentPage": 1,
|
||||
"perPage": 10,
|
||||
"totalPages": 1
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"perPage": 10,
|
||||
"totalPages": 1,
|
||||
"totalItems": 1
|
||||
}
|
||||
}
|
||||
````
|
||||
|
||||
@@ -226,12 +228,9 @@ 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/distribute" \
|
||||
curl -X POST "https://app.documenso.com/api/v2/envelope/envelope_abc123/distribute" \
|
||||
-H "Authorization: YOUR_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"envelopeId": "envelope_abc123"
|
||||
}'
|
||||
-H "Content-Type: application/json"
|
||||
````
|
||||
|
||||
</Tab>
|
||||
@@ -239,14 +238,16 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/distribute" \
|
||||
```javascript
|
||||
const envelopeId = 'envelope_abc123';
|
||||
|
||||
const response = await fetch('https://app.documenso.com/api/v2/envelope/distribute', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'YOUR_API_TOKEN',
|
||||
'Content-Type': 'application/json',
|
||||
const response = await fetch(
|
||||
`https://app.documenso.com/api/v2/envelope/${envelopeId}/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);
|
||||
@@ -336,14 +337,16 @@ 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/distribute`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': API_TOKEN,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ envelopeId: envelope.id }),
|
||||
});
|
||||
const distributeResponse = await fetch(
|
||||
`${BASE_URL}/envelope/${envelope.id}/distribute`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': API_TOKEN,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!distributeResponse.ok) {
|
||||
const error = await distributeResponse.json();
|
||||
@@ -419,12 +422,9 @@ echo "Created envelope: ${ENVELOPE_ID}"
|
||||
# Step 2: Send the document for signing
|
||||
|
||||
echo "Sending document..."
|
||||
curl -s -X POST "${BASE_URL}/envelope/distribute" \
|
||||
curl -s -X POST "${BASE_URL}/envelope/${ENVELOPE_ID}/distribute" \
|
||||
-H "Authorization: ${API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"envelopeId\": \"${ENVELOPE_ID}\"
|
||||
}"
|
||||
-H "Content-Type: application/json"
|
||||
|
||||
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 limit or plan quota. If `Retry-After` is present, retry the request. |
|
||||
| `429` | Rate limited - wait 60 seconds and retry |
|
||||
| `500` | Server error - retry or contact support |
|
||||
|
||||
### Error Response Format
|
||||
@@ -485,9 +485,7 @@ The API returns standard HTTP status codes and JSON error responses:
|
||||
|
||||
### Handling Rate Limits
|
||||
|
||||
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.
|
||||
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:
|
||||
|
||||
```javascript
|
||||
async function fetchWithRetry(url, options, maxRetries = 3) {
|
||||
@@ -495,15 +493,8 @@ async function fetchWithRetry(url, options, maxRetries = 3) {
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (response.status === 429) {
|
||||
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));
|
||||
console.log('Rate limited, waiting 60 seconds...');
|
||||
await new Promise((resolve) => setTimeout(resolve, 60000));
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,142 @@
|
||||
/**
|
||||
* Apply the public statistics API's wildcard CORS policy, without credentials.
|
||||
* 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
|
||||
*/
|
||||
export default async function cors(req: Request, res: Response): Promise<Response> {
|
||||
|
||||
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 };
|
||||
const { headers } = res;
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// 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') {
|
||||
headers.set('Access-Control-Allow-Methods', 'GET,HEAD,PUT,PATCH,POST,DELETE');
|
||||
headers.set('Vary', 'Access-Control-Request-Headers');
|
||||
if (opts.methods) {
|
||||
const methods = Array.isArray(opts.methods) ? opts.methods.join(',') : opts.methods;
|
||||
|
||||
const allowedHeaders = req.headers.get('Access-Control-Request-Headers');
|
||||
headers.set('Access-Control-Allow-Methods', methods);
|
||||
}
|
||||
|
||||
if (allowedHeaders) {
|
||||
headers.set('Access-Control-Allow-Headers', allowedHeaders);
|
||||
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;
|
||||
}
|
||||
|
||||
headers.set('Content-Length', '0');
|
||||
return new Response(null, { status: 204, headers });
|
||||
return new Response(null, { status: opts.optionsSuccessStatus, 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);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ 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';
|
||||
@@ -32,7 +31,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 { Plural, Trans, useLingui } from '@lingui/react/macro';
|
||||
import { 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';
|
||||
@@ -163,25 +162,6 @@ 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';
|
||||
@@ -195,26 +175,8 @@ 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,
|
||||
contentsMissingImages,
|
||||
contentLimits,
|
||||
]);
|
||||
}, [envelope.recipients, recipientsMissingRequiredEmail, recipientsMissingSignatureFields]);
|
||||
|
||||
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
|
||||
try {
|
||||
@@ -558,33 +520,6 @@ 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,7 +44,6 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
|
||||
defaultValues: {
|
||||
includeRecipients: true,
|
||||
includeFields: true,
|
||||
includeContents: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -68,14 +67,13 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
|
||||
});
|
||||
|
||||
const onDuplicate = async () => {
|
||||
const { includeRecipients, includeFields, includeContents } = form.getValues();
|
||||
const { includeRecipients, includeFields } = form.getValues();
|
||||
|
||||
try {
|
||||
await duplicateEnvelope({
|
||||
envelopeId,
|
||||
includeRecipients,
|
||||
includeFields: includeRecipients && includeFields,
|
||||
includeContents,
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
@@ -161,23 +159,6 @@ 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 { formatFileSize, megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
import { 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 or is password protected.`,
|
||||
description: t`The file is not a valid PDF.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -195,6 +195,18 @@ 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,10 +44,7 @@ export const EnvelopesBulkDeleteDialog = ({
|
||||
if (isDocument) {
|
||||
await trpcUtils.document.findDocumentsInternal.invalidate();
|
||||
} else {
|
||||
await Promise.all([
|
||||
trpcUtils.template.findTemplates.invalidate(),
|
||||
trpcUtils.template.findTemplatesInternal.invalidate(),
|
||||
]);
|
||||
await trpcUtils.template.findTemplates.invalidate();
|
||||
}
|
||||
|
||||
if (result.failedIds.length > 0) {
|
||||
|
||||
@@ -299,7 +299,7 @@ export const EnvelopesBulkDownloadDialog = ({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<fieldset disabled={isDownloading} className="min-w-0 space-y-4">
|
||||
<fieldset disabled={isDownloading} className="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,10 +96,7 @@ export const EnvelopesBulkMoveDialog = ({
|
||||
if (isDocument) {
|
||||
await trpcUtils.document.findDocumentsInternal.invalidate();
|
||||
} else {
|
||||
await Promise.all([
|
||||
trpcUtils.template.findTemplates.invalidate(),
|
||||
trpcUtils.template.findTemplatesInternal.invalidate(),
|
||||
]);
|
||||
await trpcUtils.template.findTemplates.invalidate();
|
||||
}
|
||||
|
||||
await onSuccess?.(data.folderId);
|
||||
|
||||
@@ -6,9 +6,8 @@ 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';
|
||||
@@ -24,7 +23,6 @@ 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';
|
||||
@@ -40,70 +38,33 @@ import { useNavigate } from 'react-router';
|
||||
import * as z from 'zod';
|
||||
import { getTemplateUseErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
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(
|
||||
const ZAddRecipientsForNewDocumentSchema = z.object({
|
||||
distributeDocument: z.boolean(),
|
||||
useCustomDocument: z.boolean().default(false),
|
||||
customDocumentData: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
email: ZRecipientEmailSchema,
|
||||
name: z.string(),
|
||||
signingOrder: z.number().optional(),
|
||||
title: z.string(),
|
||||
data: z.instanceof(File).optional(),
|
||||
envelopeItemId: z.string(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.refine((data) => data.documentNameSource !== DOCUMENT_NAME_SOURCE.CUSTOM || data.customDocumentName.length > 0, {
|
||||
message: msg`Document name is required`.id,
|
||||
path: ['customDocumentName'],
|
||||
});
|
||||
)
|
||||
.optional(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
email: ZRecipientEmailSchema,
|
||||
name: z.string(),
|
||||
signingOrder: z.number().optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
||||
|
||||
export type TemplateUseDialogProps = {
|
||||
envelopeId: string;
|
||||
templateId: number;
|
||||
templateSigningOrder?: DocumentSigningOrder | null;
|
||||
recipients: TRecipientLite[];
|
||||
documentDistributionMethod?: DocumentDistributionMethod;
|
||||
@@ -116,6 +77,7 @@ export function TemplateUseDialog({
|
||||
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
|
||||
documentRootPath,
|
||||
envelopeId,
|
||||
templateId,
|
||||
templateSigningOrder,
|
||||
trigger,
|
||||
}: TemplateUseDialogProps) {
|
||||
@@ -125,7 +87,6 @@ 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(
|
||||
{
|
||||
@@ -144,10 +105,7 @@ 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,
|
||||
@@ -180,66 +138,32 @@ export function TemplateUseDialog({
|
||||
name: 'customDocumentData',
|
||||
});
|
||||
|
||||
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 { mutateAsync: createDocumentFromTemplate } = trpc.template.createDocumentFromTemplate.useMutation();
|
||||
|
||||
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
||||
try {
|
||||
const documentTitle = getDocumentTitle(data);
|
||||
|
||||
const customFilesToUpload = (data.customDocumentData ?? []).filter(
|
||||
(item): item is typeof item & { data: File } => item.data !== undefined,
|
||||
const customFilesToUpload = (data.customDocumentData || []).filter(
|
||||
(item): item is { data: File; envelopeItemId: string; title: string } =>
|
||||
item.data !== undefined && item.envelopeItemId !== undefined && item.title !== undefined,
|
||||
);
|
||||
|
||||
// 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,
|
||||
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,
|
||||
recipients: data.recipients,
|
||||
distributeDocument: data.distributeDocument,
|
||||
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);
|
||||
customDocumentData,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Document created`),
|
||||
@@ -247,7 +171,7 @@ export function TemplateUseDialog({
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
let documentPath = `${documentRootPath}/${createdEnvelopeId}`;
|
||||
let documentPath = `${documentRootPath}/${envelopeId}`;
|
||||
|
||||
if (data.distributeDocument && documentDistributionMethod === DocumentDistributionMethod.NONE) {
|
||||
documentPath += '?action=view-signing-links';
|
||||
@@ -271,14 +195,9 @@ 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]);
|
||||
|
||||
@@ -319,9 +238,9 @@ export function TemplateUseDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<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">
|
||||
<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">
|
||||
{formRecipients.map((recipient, index) => (
|
||||
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
|
||||
{templateSigningOrder === DocumentSigningOrder.SEQUENTIAL && (
|
||||
@@ -469,46 +388,6 @@ 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"
|
||||
@@ -522,17 +401,7 @@ export function TemplateUseDialog({
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
if (!checked) {
|
||||
const customDocumentData = form.getValues('customDocumentData');
|
||||
|
||||
form.setValue(
|
||||
'customDocumentData',
|
||||
customDocumentData?.map((item) => ({
|
||||
...item,
|
||||
data: undefined,
|
||||
})),
|
||||
);
|
||||
form.clearErrors('customDocumentData');
|
||||
updateLastUploadedFile(undefined);
|
||||
form.setValue('customDocumentData', undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -559,7 +428,7 @@ export function TemplateUseDialog({
|
||||
)}
|
||||
/>
|
||||
|
||||
{useCustomDocument && (
|
||||
{form.watch('useCustomDocument') && (
|
||||
<div className="my-4 space-y-2">
|
||||
{isLoadingEnvelopeItems ? (
|
||||
<SpinnerBox className="py-16" />
|
||||
@@ -574,7 +443,7 @@ export function TemplateUseDialog({
|
||||
<FormControl>
|
||||
<div
|
||||
key={item.id}
|
||||
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"
|
||||
className="flex items-center gap-4 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">
|
||||
@@ -582,15 +451,13 @@ export function TemplateUseDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="truncate font-medium text-foreground text-sm">{item.title}</h4>
|
||||
<p className="mt-0.5 text-muted-foreground text-xs">
|
||||
{field.value ? (
|
||||
<span>
|
||||
<div>
|
||||
<Trans>Custom {(field.value.size / (1024 * 1024)).toFixed(2)} MB file</Trans>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Trans>Default file</Trans>
|
||||
)}
|
||||
@@ -608,18 +475,6 @@ 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" />
|
||||
@@ -662,7 +517,7 @@ export function TemplateUseDialog({
|
||||
}
|
||||
|
||||
if (file.type !== 'application/pdf') {
|
||||
form.setError(`customDocumentData.${i}.data`, {
|
||||
form.setError('customDocumentData', {
|
||||
type: 'manual',
|
||||
message: _(msg`Please select a PDF file`),
|
||||
});
|
||||
@@ -671,7 +526,7 @@ export function TemplateUseDialog({
|
||||
}
|
||||
|
||||
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) {
|
||||
form.setError(`customDocumentData.${i}.data`, {
|
||||
form.setError('customDocumentData', {
|
||||
type: 'manual',
|
||||
message: _(
|
||||
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
|
||||
@@ -682,8 +537,6 @@ export function TemplateUseDialog({
|
||||
}
|
||||
|
||||
field.onChange(file);
|
||||
form.clearErrors(`customDocumentData.${i}.data`);
|
||||
updateLastUploadedFile(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -697,112 +550,6 @@ 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,5 +1,4 @@
|
||||
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';
|
||||
@@ -99,6 +98,16 @@ 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,7 +2,6 @@ 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;
|
||||
@@ -11,8 +10,12 @@ 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 overflow-hidden p-6">
|
||||
<div className="embed--DocumentCompletedCard w-full max-w-sm md:max-w-md">
|
||||
<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">
|
||||
<SigningCard3D
|
||||
className="mx-auto w-full"
|
||||
name={name || 'Documenso'}
|
||||
@@ -21,19 +24,10 @@ export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentComplet
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -55,7 +55,6 @@ export type EmbedSignDocumentV1ClientPageProps = {
|
||||
completedFields: DocumentField[];
|
||||
metadata?: DocumentMeta | null;
|
||||
isCompleted?: boolean;
|
||||
isRejected?: boolean;
|
||||
hidePoweredBy?: boolean;
|
||||
allowWhitelabelling?: boolean;
|
||||
allRecipients?: RecipientWithFields[];
|
||||
@@ -71,7 +70,6 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
completedFields,
|
||||
metadata,
|
||||
isCompleted,
|
||||
isRejected,
|
||||
hidePoweredBy = false,
|
||||
allowWhitelabelling = false,
|
||||
allRecipients = [],
|
||||
@@ -85,9 +83,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
|
||||
const [hasRejectedDocument, setHasRejectedDocument] = useState(
|
||||
isRejected ?? recipient.signingStatus === SigningStatus.REJECTED,
|
||||
);
|
||||
const [hasRejectedDocument, setHasRejectedDocument] = useState(recipient.signingStatus === SigningStatus.REJECTED);
|
||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
|
||||
allRecipients.length > 0 ? allRecipients[0].id : null,
|
||||
);
|
||||
@@ -267,44 +263,6 @@ 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,18 +40,12 @@ 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(
|
||||
{
|
||||
@@ -118,8 +112,6 @@ export const EmbedSignDocumentV2ClientPage = ({
|
||||
recipientId: number;
|
||||
reason?: string;
|
||||
}) => {
|
||||
setHasRejectedDocument(true);
|
||||
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
@@ -227,26 +219,23 @@ export const EmbedSignDocumentV2ClientPage = ({
|
||||
}
|
||||
}, [isRejected, envelope.id, recipient.id, recipient.token]);
|
||||
|
||||
if (hasRejectedDocument) {
|
||||
if (isRejected) {
|
||||
return <EmbedDocumentRejected />;
|
||||
}
|
||||
|
||||
if (hasCompletedDocument) {
|
||||
const completedSignature =
|
||||
recipient.fields.find((field) => field.signature)?.signature ?? recipientSignature ?? null;
|
||||
|
||||
if (isCompleted) {
|
||||
return (
|
||||
<EmbedDocumentCompleted
|
||||
name={fullName}
|
||||
signature={
|
||||
completedSignature
|
||||
recipientSignature
|
||||
? {
|
||||
id: 1,
|
||||
fieldId: 1,
|
||||
recipientId: recipient.id,
|
||||
created: new Date(),
|
||||
signatureImageAsBase64: completedSignature.signatureImageAsBase64,
|
||||
typedSignature: completedSignature.typedSignature,
|
||||
signatureImageAsBase64: recipientSignature.signatureImageAsBase64,
|
||||
typedSignature: recipientSignature.typedSignature,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,136 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,105 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,78 +0,0 @@
|
||||
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.
|
||||
// biome-ignore lint/suspicious/noExplicitAny: See above
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type FormControlType = Control<any>;
|
||||
|
||||
export const EditorGenericFontSizeField = ({
|
||||
|
||||
@@ -54,8 +54,6 @@ 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,
|
||||
@@ -187,54 +185,6 @@ 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,371 +0,0 @@
|
||||
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';
|
||||
@@ -1,203 +0,0 @@
|
||||
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);
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,214 +0,0 @@
|
||||
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';
|
||||
};
|
||||
@@ -1,67 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,264 +0,0 @@
|
||||
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],
|
||||
}));
|
||||
@@ -1,63 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,198 +0,0 @@
|
||||
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 }));
|
||||
};
|
||||
@@ -1,145 +0,0 @@
|
||||
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,10 +599,18 @@ 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,9 +1,6 @@
|
||||
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';
|
||||
@@ -25,7 +22,6 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
const { organisations } = useSession();
|
||||
|
||||
const currentTeam = useOptionalCurrentTeam();
|
||||
const currentOrganisation = useOptionalCurrentOrganisation();
|
||||
|
||||
const { data: unreadCountData } = trpc.document.inbox.getCount.useQuery(
|
||||
{
|
||||
@@ -41,19 +37,18 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
};
|
||||
|
||||
const menuNavigationLinks = useMemo(() => {
|
||||
const navigationTeam =
|
||||
currentTeam ??
|
||||
(organisations.length === 1 && organisations[0].teams.length === 1 ? organisations[0].teams[0] : null);
|
||||
let teamUrl = currentTeam?.url || null;
|
||||
|
||||
if (!navigationTeam) {
|
||||
if (!teamUrl && organisations.length === 1 && organisations[0].teams.length === 1) {
|
||||
teamUrl = organisations[0].teams[0].url;
|
||||
}
|
||||
|
||||
if (!teamUrl) {
|
||||
return [
|
||||
{
|
||||
href: '/inbox',
|
||||
text: t`Inbox`,
|
||||
},
|
||||
...(currentOrganisation && canAccessOrganisationAnalytics(currentOrganisation.currentOrganisationRole)
|
||||
? [{ href: formatOrganisationAnalyticsPath(currentOrganisation.url), text: t`Analytics` }]
|
||||
: []),
|
||||
{
|
||||
href: '/settings/profile',
|
||||
text: t`Settings`,
|
||||
@@ -61,8 +56,6 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
];
|
||||
}
|
||||
|
||||
const teamUrl = navigationTeam.url;
|
||||
|
||||
return [
|
||||
{
|
||||
href: `/t/${teamUrl}/documents`,
|
||||
@@ -76,15 +69,12 @@ 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, currentOrganisation, organisations, t]);
|
||||
}, [currentTeam, organisations]);
|
||||
|
||||
return (
|
||||
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
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,18 +1,7 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AlertTriangleIcon } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
export const DirectTemplateInvalidPageView = () => {
|
||||
return (
|
||||
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
|
||||
<div>
|
||||
@@ -23,20 +12,10 @@ export const DirectTemplateInvalidPageView = ({ reason }: DirectTemplateInvalidP
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
{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()}
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,6 @@ 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,
|
||||
@@ -224,10 +223,27 @@ export const DirectTemplateSigningForm = ({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return getNextDictatableRecipient({
|
||||
recipients: template.recipients,
|
||||
currentRecipientId: directRecipient.id,
|
||||
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;
|
||||
});
|
||||
|
||||
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 (
|
||||
@@ -419,7 +435,7 @@ export const DirectTemplateSigningForm = ({
|
||||
fields={localFields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
recipient={directRecipient}
|
||||
allowDictateNextSigner={Boolean(nextRecipient && template.templateMeta?.allowDictateNextSigner)}
|
||||
allowDictateNextSigner={nextRecipient && template.templateMeta?.allowDictateNextSigner}
|
||||
defaultNextSigner={nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+2
-4
@@ -102,10 +102,8 @@ export const DocumentSigningCompleteDialog = ({
|
||||
|
||||
const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {};
|
||||
|
||||
const canDictateNextSigner = allowDictateNextSigner && Boolean(defaultNextSigner);
|
||||
|
||||
const form = useForm<TNextSignerFormSchema>({
|
||||
resolver: canDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
||||
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
||||
defaultValues: {
|
||||
name: defaultNextSigner?.name ?? '',
|
||||
email: defaultNextSigner?.email ?? '',
|
||||
@@ -326,7 +324,7 @@ export const DocumentSigningCompleteDialog = ({
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
{canDictateNextSigner && (
|
||||
{allowDictateNextSigner && defaultNextSigner && (
|
||||
<div className="mb-4 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<FormField
|
||||
|
||||
@@ -39,11 +39,7 @@ export type DocumentSigningFormProps = {
|
||||
}) => Promise<void>;
|
||||
isSubmitting: boolean;
|
||||
fieldsValidated: () => void;
|
||||
/**
|
||||
* 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'>;
|
||||
nextRecipient?: RecipientWithFields;
|
||||
};
|
||||
|
||||
export const DocumentSigningForm = ({
|
||||
@@ -88,10 +84,6 @@ 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();
|
||||
@@ -159,8 +151,10 @@ export const DocumentSigningForm = ({
|
||||
completeDocument({ nextSigner, accessAuthOptions })
|
||||
}
|
||||
recipient={recipient}
|
||||
allowDictateNextSigner={allowDictateNextSigner}
|
||||
defaultNextSigner={defaultNextSigner}
|
||||
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||
defaultNextSigner={
|
||||
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,8 +223,8 @@ export const DocumentSigningForm = ({
|
||||
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
|
||||
onConfirm={handleAssistantConfirmDialogSubmit}
|
||||
isSubmitting={isAssistantSubmitting}
|
||||
allowDictateNextSigner={allowDictateNextSigner}
|
||||
defaultNextSigner={defaultNextSigner}
|
||||
allowDictateNextSigner={nextRecipient && document.documentMeta?.allowDictateNextSigner}
|
||||
defaultNextSigner={nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined}
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
@@ -297,8 +291,10 @@ export const DocumentSigningForm = ({
|
||||
})
|
||||
}
|
||||
recipient={recipient}
|
||||
allowDictateNextSigner={allowDictateNextSigner}
|
||||
defaultNextSigner={defaultNextSigner}
|
||||
allowDictateNextSigner={nextRecipient && document.documentMeta?.allowDictateNextSigner}
|
||||
defaultNextSigner={
|
||||
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
+29
-8
@@ -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, Recipient } from '@prisma/client';
|
||||
import type { Field } from '@prisma/client';
|
||||
import { FieldType, RecipientRole } from '@prisma/client';
|
||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
@@ -60,12 +60,6 @@ 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;
|
||||
};
|
||||
@@ -77,7 +71,6 @@ export const DocumentSigningPageViewV1 = ({
|
||||
completedFields,
|
||||
isRecipientsTurn,
|
||||
allRecipients = [],
|
||||
nextRecipient,
|
||||
includeSenderDetails,
|
||||
branding,
|
||||
}: DocumentSigningPageViewV1Props) => {
|
||||
@@ -140,6 +133,34 @@ 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;
|
||||
|
||||
|
||||
+1
-7
@@ -235,10 +235,7 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="embed--DocumentContainer min-w-0 flex-1 overflow-x-auto overflow-y-auto"
|
||||
ref={scrollableContainerRef}
|
||||
>
|
||||
<div className="embed--DocumentContainer min-w-0 flex-1 overflow-y-auto" ref={scrollableContainerRef}>
|
||||
<div className="flex flex-col">
|
||||
{/* Horizontal envelope item selector */}
|
||||
{envelopeItems.length > 1 && (
|
||||
@@ -270,9 +267,6 @@ 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,8 +6,6 @@ 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';
|
||||
@@ -238,16 +236,12 @@ export const EnvelopeSigningProvider = ({
|
||||
}, [envelopeData.recipient.fields]);
|
||||
|
||||
/**
|
||||
* Assistant recipients are those positioned strictly after the assistant —
|
||||
* never their own group peers.
|
||||
* Assistant recipients are those that have a signing order after the assistant.
|
||||
*/
|
||||
const assistantRecipients = useMemo(() => {
|
||||
if (recipient.role !== RecipientRole.ASSISTANT) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return envelope.recipients.filter((r) => isRecipientBefore(recipient, r));
|
||||
}, [envelope.recipients, recipient]);
|
||||
const assistantRecipients =
|
||||
recipient.role === RecipientRole.ASSISTANT
|
||||
? envelope.recipients.filter((r) => (r.signingOrder ?? 0) > (recipient.signingOrder ?? 0))
|
||||
: [];
|
||||
|
||||
/**
|
||||
* Assistant fields are those fulfill all of the following:
|
||||
@@ -255,11 +249,12 @@ export const EnvelopeSigningProvider = ({
|
||||
* - After the assistant signing order
|
||||
* - Are not signature fields
|
||||
*/
|
||||
const assistantFields = useMemo(() => {
|
||||
return assistantRecipients
|
||||
.filter((r) => r.signingStatus !== SigningStatus.SIGNED)
|
||||
.flatMap((r) => r.fields.filter((field) => field.type !== FieldType.SIGNATURE));
|
||||
}, [assistantRecipients]);
|
||||
const assistantFields =
|
||||
recipient.role === RecipientRole.ASSISTANT
|
||||
? assistantRecipients
|
||||
.filter((r) => r.signingStatus !== SigningStatus.SIGNED)
|
||||
.flatMap((r) => r.fields.filter((field) => field.type !== FieldType.SIGNATURE))
|
||||
: [];
|
||||
|
||||
/**
|
||||
* The recipient that the assistant has currently selected to sign on behalf of.
|
||||
@@ -274,7 +269,7 @@ export const EnvelopeSigningProvider = ({
|
||||
|
||||
const selectedAssistantRecipientFields = useMemo(() => {
|
||||
return assistantFields.filter((field) => field.recipientId === selectedAssistantRecipient?.id);
|
||||
}, [assistantFields, selectedAssistantRecipient]);
|
||||
}, [recipientFields, selectedAssistantRecipient]);
|
||||
|
||||
/**
|
||||
* Fields that have been completed by other recipients.
|
||||
@@ -295,14 +290,32 @@ export const EnvelopeSigningProvider = ({
|
||||
.filter((field) => field.inserted);
|
||||
|
||||
const nextRecipient = useMemo(() => {
|
||||
if (envelope.documentMeta.signingOrder !== 'SEQUENTIAL') {
|
||||
if (!envelope.documentMeta.signingOrder || envelope.documentMeta.signingOrder !== 'SEQUENTIAL') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getNextDictatableRecipient({
|
||||
recipients: envelope.recipients,
|
||||
currentRecipientId: recipient.id,
|
||||
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;
|
||||
});
|
||||
|
||||
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 { useQueryStates } from 'nuqs';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
@@ -10,26 +10,16 @@ import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
export const DocumentSearch = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [{ query }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
query: documentsSearchParams.query,
|
||||
page: documentsSearchParams.page,
|
||||
},
|
||||
{ history: 'push' },
|
||||
);
|
||||
const [query, setQuery] = useQueryState('query', documentsSearchParams.query);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(query ?? '');
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchTerm !== (query ?? '')) {
|
||||
// Reset pagination so a new search never lands on an empty page.
|
||||
void setSearchParams({
|
||||
query: debouncedSearchTerm || null,
|
||||
page: null,
|
||||
});
|
||||
void setQuery(debouncedSearchTerm || null);
|
||||
}
|
||||
}, [debouncedSearchTerm, query, setSearchParams]);
|
||||
}, [debouncedSearchTerm, query, setQuery]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
-423
@@ -1,423 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
-169
@@ -1,169 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
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;
|
||||
};
|
||||
-112
@@ -1,112 +0,0 @@
|
||||
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();
|
||||
}
|
||||
};
|
||||
-448
@@ -1,448 +0,0 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
-472
@@ -1,472 +0,0 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
-195
@@ -1,195 +0,0 @@
|
||||
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 };
|
||||
};
|
||||
-201
@@ -1,201 +0,0 @@
|
||||
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()),
|
||||
);
|
||||
};
|
||||
-88
@@ -1,88 +0,0 @@
|
||||
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 };
|
||||
};
|
||||
-258
@@ -1,258 +0,0 @@
|
||||
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>;
|
||||
@@ -1,323 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,246 +0,0 @@
|
||||
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),
|
||||
};
|
||||
};
|
||||
-411
@@ -1,411 +0,0 @@
|
||||
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();
|
||||
};
|
||||
+1
-11
@@ -94,22 +94,12 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
selectedRecipientId,
|
||||
selectedEnvelopeItemId,
|
||||
}: EnvelopeEditorFieldDragDropProps) => {
|
||||
const { envelope, editorFields, isTemplate, getRecipientColorKey, setIsPlacingItem } = useCurrentEnvelopeEditor();
|
||||
const { envelope, editorFields, isTemplate, getRecipientColorKey } = 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(() => {
|
||||
|
||||
+878
-231
File diff suppressed because it is too large
Load Diff
+306
-458
@@ -1,10 +1,8 @@
|
||||
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,
|
||||
@@ -27,33 +25,20 @@ 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 { TabsList, TabsTrigger } from '@radix-ui/react-tabs';
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
FileTextIcon,
|
||||
LayoutGridIcon,
|
||||
MousePointerIcon,
|
||||
PencilIcon,
|
||||
SparklesIcon,
|
||||
} from 'lucide-react';
|
||||
import { AlertTriangleIcon, FileTextIcon, 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';
|
||||
@@ -66,9 +51,7 @@ 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';
|
||||
@@ -89,14 +72,6 @@ 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();
|
||||
|
||||
@@ -104,33 +79,12 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
envelope,
|
||||
editorFields,
|
||||
editorContents,
|
||||
navigateToStep,
|
||||
editorConfig,
|
||||
selectedEditorTab,
|
||||
setSelectedEditorTab,
|
||||
} = useCurrentEnvelopeEditor();
|
||||
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { currentEnvelopeItem, setCurrentEnvelopeItem, viewerControls } = useCurrentEnvelopeRender();
|
||||
const { currentEnvelopeItem, setCurrentEnvelopeItem } = 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();
|
||||
@@ -142,11 +96,6 @@ 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
|
||||
@@ -237,21 +186,6 @@ 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);
|
||||
@@ -268,405 +202,319 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
});
|
||||
};
|
||||
|
||||
if (!editorConfig.general?.allowAddFieldsStep && !editorConfig.general?.allowAddContentsStep) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*
|
||||
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
|
||||
}
|
||||
/>
|
||||
|
||||
<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"
|
||||
<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,
|
||||
})}
|
||||
/>
|
||||
|
||||
{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
|
||||
}
|
||||
<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}`}
|
||||
>
|
||||
<SparklesIcon className="mr-2 -ml-1 h-4 w-4" />
|
||||
<Trans>Detect with AI</Trans>
|
||||
</Button>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<AiFieldDetectionDialog
|
||||
open={isAiFieldDialogOpen}
|
||||
onOpenChange={setIsAiFieldDialogOpen}
|
||||
onComplete={onFieldDetectionComplete}
|
||||
envelopeId={envelope.id}
|
||||
teamId={envelope.teamId}
|
||||
/>
|
||||
<EnvelopeEditorInvalidDirectTemplateAlert />
|
||||
|
||||
<AiFeaturesEnableDialog
|
||||
open={isAiEnableDialogOpen}
|
||||
onOpenChange={setIsAiEnableDialogOpen}
|
||||
onEnabled={onAiFeaturesEnabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
{/* 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>
|
||||
|
||||
{/* Field details section. */}
|
||||
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
|
||||
{selectedField && (
|
||||
<section>
|
||||
<Separator className="my-4" />
|
||||
<Button variant="outline" onClick={() => void navigateToStep('upload')}>
|
||||
<Trans>Add Recipients</Trans>
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{searchParams.get('devmode') && (
|
||||
<>
|
||||
<div className="px-4">
|
||||
<h3 className="mb-3 font-semibold text-foreground text-sm">
|
||||
<Trans>Developer Mode</Trans>
|
||||
</h3>
|
||||
{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="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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{currentEnvelopeItem !== null ? (
|
||||
<EnvelopePdfViewer
|
||||
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
|
||||
scrollParentRef={scrollableContainerRef}
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
</ContentSettingsFormProvider>
|
||||
</div>
|
||||
|
||||
<ContentImageUploadDialog.Root />
|
||||
</>
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
|
||||
import { getEnvelopeItemPermissions, mapSecondaryIdToTemplateId } 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,6 +222,7 @@ 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,7 +215,6 @@ export const EnvelopeEditorPreviewPage = () => {
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={undefined}
|
||||
fields={fieldsWithPlaceholders}
|
||||
contents={envelope.contents}
|
||||
recipients={envelope.recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
@@ -226,7 +225,7 @@ export const EnvelopeEditorPreviewPage = () => {
|
||||
}}
|
||||
>
|
||||
<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}>
|
||||
<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} />
|
||||
|
||||
@@ -248,7 +247,6 @@ 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">
|
||||
|
||||
+542
-43
@@ -1,30 +1,42 @@
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import {
|
||||
updateEditorSigners,
|
||||
ZEditorRecipientsFormSchema,
|
||||
} from '@documenso/lib/client-only/hooks/use-editor-recipients';
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { 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 { normalizeGroupedSigningOrders } from '@documenso/lib/utils/recipient-groups';
|
||||
import { canEditorRecipientBeModified } from '@documenso/lib/utils/recipients';
|
||||
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 { 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 } from '@documenso/ui/primitives/form/form';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } 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 } from '@lingui/react/macro';
|
||||
import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client';
|
||||
import { HelpCircleIcon, PlusIcon, SparklesIcon } from 'lucide-react';
|
||||
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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useFieldArray, useWatch } from 'react-hook-form';
|
||||
import { useRevalidator, useSearchParams } from 'react-router';
|
||||
@@ -33,8 +45,7 @@ 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 { RecipientStepList } from './recipient-step-list';
|
||||
import { useCspNonce } from '~/utils/nonce';
|
||||
|
||||
export const EnvelopeEditorRecipientForm = () => {
|
||||
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients, isEmbedded, editorConfig } =
|
||||
@@ -42,7 +53,9 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
const team = useCurrentTeam();
|
||||
const cspNonce = useCspNonce();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { remaining } = useLimits();
|
||||
const { sessionData } = useOptionalSession();
|
||||
@@ -50,6 +63,7 @@ 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
|
||||
@@ -95,8 +109,23 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
|
||||
|
||||
const $sensorApi = useRef<SensorAPI | null>(null);
|
||||
const isFirstRender = useRef(true);
|
||||
const { recipients } = envelope;
|
||||
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 { form } = editorRecipients;
|
||||
|
||||
@@ -134,16 +163,15 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
}, [watchedSigners]);
|
||||
|
||||
const normalizeSigningOrders = (signers: typeof watchedSigners) => {
|
||||
return normalizeGroupedSigningOrders(signers, (signer) => canRecipientBeModified(signer.id));
|
||||
return normalizeRecipientSigningOrders(signers, (signer) => canRecipientBeModified(signer.id));
|
||||
};
|
||||
|
||||
// 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({
|
||||
const activeRecipientCount = watchedSigners.filter((signer) => !isCcRecipient(signer)).length;
|
||||
|
||||
const { fields: signers, remove: removeSigner } = useFieldArray({
|
||||
control,
|
||||
name: 'signers',
|
||||
keyName: 'nativeId',
|
||||
});
|
||||
|
||||
const emptySignerIndex = watchedSigners.findIndex(
|
||||
@@ -157,22 +185,39 @@ 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) => Boolean(currentEditorEmail) && signer.email?.toLowerCase() === currentEditorEmail?.toLowerCase(),
|
||||
(signer) => signer.email.toLowerCase() === currentEditorEmail?.toLowerCase(),
|
||||
);
|
||||
|
||||
const hasDocumentBeenSent = recipients.some(
|
||||
(recipient) => recipient.role !== RecipientRole.CC && recipient.sendStatus === SendStatus.SENT,
|
||||
);
|
||||
|
||||
const canRecipientBeModified = (recipientId?: number) => canEditorRecipientBeModified(envelope, recipientId);
|
||||
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 appendNormalizedSigner = (signer: (typeof watchedSigners)[number], shouldFocus = false) => {
|
||||
const updatedSigners = normalizeSigningOrders([...form.getValues('signers'), signer]);
|
||||
|
||||
updateEditorSigners(form, updatedSigners);
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
if (shouldFocus) {
|
||||
const signerIndex = updatedSigners.findIndex((updatedSigner) => updatedSigner.formId === signer.formId);
|
||||
@@ -190,17 +235,20 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
email: '',
|
||||
role: RecipientRole.SIGNER,
|
||||
actionAuth: [],
|
||||
signingOrder: undefined,
|
||||
signingOrder: activeRecipientCount + 1,
|
||||
});
|
||||
};
|
||||
|
||||
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) {
|
||||
updateEditorSigners(
|
||||
form,
|
||||
form.setValue(
|
||||
'signers',
|
||||
detectedRecipients.map((recipient, index) => ({
|
||||
formId: nanoid(12),
|
||||
name: recipient.name,
|
||||
@@ -209,6 +257,10 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
actionAuth: [],
|
||||
signingOrder: index + 1,
|
||||
})),
|
||||
{
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
},
|
||||
);
|
||||
|
||||
return;
|
||||
@@ -229,11 +281,16 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
actionAuth: [],
|
||||
signingOrder: undefined,
|
||||
signingOrder: nextSigningOrder,
|
||||
});
|
||||
|
||||
nextSigningOrder += 1;
|
||||
}
|
||||
|
||||
updateEditorSigners(form, normalizeSigningOrders(currentSigners));
|
||||
form.setValue('signers', normalizeSigningOrders(currentSigners), {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: plural(detectedRecipients.length, {
|
||||
@@ -247,6 +304,32 @@ 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 ?? '', {
|
||||
@@ -267,7 +350,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
email: currentEditorEmail ?? '',
|
||||
role: RecipientRole.SIGNER,
|
||||
actionAuth: [],
|
||||
signingOrder: undefined,
|
||||
signingOrder: activeRecipientCount + 1,
|
||||
},
|
||||
true,
|
||||
);
|
||||
@@ -276,6 +359,142 @@ 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);
|
||||
|
||||
@@ -287,8 +506,10 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
})),
|
||||
);
|
||||
|
||||
updateEditorSigners(form, updatedSigners);
|
||||
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
@@ -316,17 +537,14 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
|
||||
const { data } = validatedFormValues;
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
const envelopeRecipients = data.signers.map((recipient) => {
|
||||
if (!canRecipientBeModified(recipient.id)) {
|
||||
const persistedRecipient = recipients.find((envelopeRecipient) => envelopeRecipient.id === recipient.id);
|
||||
|
||||
return {
|
||||
...recipient,
|
||||
signingOrder: persistedRecipient?.signingOrder ?? undefined,
|
||||
signingOrder: recipient.signingOrder,
|
||||
};
|
||||
}
|
||||
return recipient;
|
||||
@@ -352,7 +570,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
signer.email !== recipient.email ||
|
||||
signer.name !== recipient.name ||
|
||||
signer.role !== recipient.role ||
|
||||
(signer.signingOrder ?? null) !== (recipient.signingOrder ?? null) ||
|
||||
signer.signingOrder !== recipient.signingOrder ||
|
||||
!isDeepEqual(signerActionAuth, recipientActionAuth)
|
||||
);
|
||||
});
|
||||
@@ -372,7 +590,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
}, [formValues]);
|
||||
|
||||
const recipientCountLimit = organisation.organisationClaim.recipientCount;
|
||||
const isOverRecipientLimit = recipientCountLimit > 0 && watchedSigners.length > recipientCountLimit;
|
||||
const isOverRecipientLimit = recipientCountLimit > 0 && signers.length > recipientCountLimit;
|
||||
|
||||
return (
|
||||
<Card backdropBlur={false} className="border">
|
||||
@@ -428,7 +646,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
type="button"
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
disabled={isSubmitting || watchedSigners.length >= remaining.recipients}
|
||||
disabled={isSubmitting || signers.length >= remaining.recipients}
|
||||
onClick={() => onAddSigner()}
|
||||
>
|
||||
<PlusIcon className="mr-1 -ml-1 h-5 w-5" />
|
||||
@@ -578,7 +796,288 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RecipientStepList showAdvancedSettings={showAdvancedSettings} />
|
||||
<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>
|
||||
|
||||
<FormErrorMessage
|
||||
className="mt-2"
|
||||
|
||||
-1
@@ -18,7 +18,6 @@ export const EnvelopeEditorRenderProviderWrapper = ({
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
fields={envelope.fields}
|
||||
contents={envelope.contents}
|
||||
recipients={envelope.recipients}
|
||||
token={token}
|
||||
presignToken={presignedToken}
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@ import {
|
||||
ZDocumentMetaTimezoneSchema,
|
||||
} from '@documenso/lib/types/document-meta';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { isHttpUrl } from '@documenso/lib/utils/is-http-url';
|
||||
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-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 === '' || isHttpUrl(value), {
|
||||
.refine((value) => value === undefined || value === '' || isValidRedirectUrl(value), {
|
||||
message: 'Please enter a valid URL, make sure you include http:// or https:// part of the url.',
|
||||
}),
|
||||
language: z
|
||||
|
||||
@@ -54,7 +54,6 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
envelope,
|
||||
setLocalEnvelope,
|
||||
editorFields,
|
||||
editorContents,
|
||||
editorConfig,
|
||||
isEmbedded,
|
||||
navigateToStep,
|
||||
@@ -140,7 +139,7 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
);
|
||||
|
||||
const { mutateAsync: replaceEnvelopeItemPdf } = trpc.envelope.item.replacePdf.useMutation({
|
||||
onSuccess: ({ data, fields, contents }) => {
|
||||
onSuccess: ({ data, fields }) => {
|
||||
// Update the envelope item with the new documentDataId.
|
||||
setLocalEnvelope({
|
||||
envelopeItems: envelope.envelopeItems.map((item) =>
|
||||
@@ -154,12 +153,6 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
setLocalEnvelope({ fields });
|
||||
editorFields.resetForm(fields);
|
||||
}
|
||||
|
||||
// Same for contents.
|
||||
if (contents) {
|
||||
setLocalEnvelope({ contents });
|
||||
editorContents.resetForm(contents);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -188,18 +181,13 @@ 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, index) => ({
|
||||
...newUploadingFiles.map((file) => ({
|
||||
id: file.envelopeItemId!,
|
||||
title: file.title,
|
||||
order: highestOrder + index + 1,
|
||||
order: envelope.envelopeItems.length + 1,
|
||||
envelopeId: envelope.id,
|
||||
data: file.data!,
|
||||
documentDataId: '',
|
||||
@@ -285,20 +273,12 @@ 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;
|
||||
}
|
||||
@@ -346,17 +326,14 @@ 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: fieldsWithoutDeletedItem,
|
||||
contents: contentsWithoutDeletedItem,
|
||||
fields: envelope.fields.filter((field) => field.envelopeItemId !== envelopeItemId),
|
||||
});
|
||||
|
||||
// Reset editor fields and contents.
|
||||
// Reset editor fields.
|
||||
editorFields.resetForm(fieldsWithoutDeletedItem);
|
||||
editorContents.resetForm(contentsWithoutDeletedItem);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -90,13 +90,7 @@ export const EnvelopeEditor = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const {
|
||||
general: {
|
||||
minimizeLeftSidebar,
|
||||
allowUploadAndRecipientStep,
|
||||
allowAddFieldsStep,
|
||||
allowAddContentsStep,
|
||||
allowPreviewStep,
|
||||
},
|
||||
general: { minimizeLeftSidebar, allowUploadAndRecipientStep, allowAddFieldsStep, allowPreviewStep },
|
||||
actions: {
|
||||
allowDistributing,
|
||||
allowDirectLink,
|
||||
@@ -114,7 +108,7 @@ export const EnvelopeEditor = () => {
|
||||
steps.push(UPLOAD_STEP);
|
||||
}
|
||||
|
||||
if (allowAddFieldsStep || allowAddContentsStep) {
|
||||
if (allowAddFieldsStep) {
|
||||
steps.push(ADD_FIELDS_STEP);
|
||||
}
|
||||
|
||||
@@ -530,16 +524,11 @@ 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 },
|
||||
{ pageToRender: 'addFields', allowAddContentsStep: true },
|
||||
() => <EnvelopeEditorFieldsPage />,
|
||||
)
|
||||
.with({ pageToRender: 'addFields', allowAddFieldsStep: true }, () => <EnvelopeEditorFieldsPage />)
|
||||
.with({ pageToRender: 'preview', allowPreviewStep: true }, () => <EnvelopeEditorPreviewPage />)
|
||||
.otherwise(() => null)}
|
||||
</div>
|
||||
|
||||
+3
-64
@@ -5,9 +5,7 @@ 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';
|
||||
@@ -24,12 +22,9 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const {
|
||||
version,
|
||||
envelopeStatus,
|
||||
currentEnvelopeItem,
|
||||
fields,
|
||||
contents,
|
||||
contentImages,
|
||||
signatures,
|
||||
recipients,
|
||||
getRecipientColorKey,
|
||||
@@ -37,40 +32,16 @@ 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,
|
||||
fieldsVisibility,
|
||||
applyPageItemsVisibility,
|
||||
hasFailedImage,
|
||||
} = usePageRenderer(({ stage, pageLayer }) => {
|
||||
const { stage, pageLayer, konvaContainer, unscaledViewport } = 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 [];
|
||||
@@ -134,8 +105,7 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
|
||||
translations: fieldTranslations,
|
||||
pageWidth: unscaledViewport.width,
|
||||
pageHeight: unscaledViewport.height,
|
||||
// Muted fields are rendered with the read-only styling.
|
||||
color: fieldsVisibility === 'muted' ? 'readOnly' : getRecipientColorKey(field.recipientId),
|
||||
color: getRecipientColorKey(field.recipientId),
|
||||
editable: false,
|
||||
mode: overrideSettings?.mode ?? 'edit',
|
||||
});
|
||||
@@ -161,12 +131,6 @@ 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);
|
||||
@@ -175,28 +139,6 @@ 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
|
||||
*/
|
||||
@@ -217,10 +159,8 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
|
||||
renderFieldOnLayer(field);
|
||||
});
|
||||
|
||||
applyPageItemsVisibility();
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
}, [localPageFields, signaturesByFieldId, fieldsVisibility]);
|
||||
}, [localPageFields, signaturesByFieldId]);
|
||||
|
||||
if (!currentEnvelopeItem) {
|
||||
return null;
|
||||
@@ -230,7 +170,6 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
|
||||
<>
|
||||
{overrideSettings?.showRecipientTooltip &&
|
||||
pageData.imageLoadingState === 'loaded' &&
|
||||
fieldsVisibility === 'visible' &&
|
||||
localPageFields.map((field) => (
|
||||
<EnvelopeRecipientFieldTooltip
|
||||
key={field.id}
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
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);
|
||||
@@ -1,261 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,398 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
+1
-33
@@ -10,7 +10,6 @@ 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,
|
||||
@@ -18,7 +17,6 @@ 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';
|
||||
@@ -51,7 +49,7 @@ type GenericLocalField = TEnvelope['fields'][number] & {
|
||||
|
||||
export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
|
||||
const { t, i18n } = useLingui();
|
||||
const { currentEnvelopeItem, contentImages, setRenderError } = useCurrentEnvelopeRender();
|
||||
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
||||
const { sessionData } = useOptionalSession();
|
||||
|
||||
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
||||
@@ -537,39 +535,10 @@ 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();
|
||||
};
|
||||
@@ -608,7 +577,6 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
pageLayer.current.destroyChildren();
|
||||
cachedRenderFields.current.clear();
|
||||
|
||||
renderContents();
|
||||
renderFields();
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
|
||||
@@ -31,8 +31,6 @@ type FilterPillCommonProps = {
|
||||
enableSearch?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
loading?: boolean;
|
||||
/** Whether the selection can be removed. Defaults to true. */
|
||||
clearable?: boolean;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
@@ -63,7 +61,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, clearable = true, testId } = props;
|
||||
const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, testId } = props;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -86,7 +84,7 @@ export const FilterPill = (props: FilterPillProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
props.onChange(nextValue === props.value && clearable ? null : nextValue);
|
||||
props.onChange(nextValue === props.value ? null : nextValue);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
@@ -170,7 +168,7 @@ export const FilterPill = (props: FilterPillProps) => {
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
{hasSelection && clearable && (
|
||||
{hasSelection && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
|
||||
@@ -7,10 +7,9 @@ export type CardMetricProps = {
|
||||
value?: string | number;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
export const CardMetric = ({ icon: Icon, title, value, className, children, testId }: CardMetricProps) => {
|
||||
export const CardMetric = ({ icon: Icon, title, value, className, children }: CardMetricProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -30,7 +29,7 @@ export const CardMetric = ({ icon: Icon, title, value, className, children, test
|
||||
</div>
|
||||
|
||||
{children || (
|
||||
<p className="mt-auto font-semibold text-4xl text-foreground leading-8" data-testid={testId}>
|
||||
<p className="mt-auto font-semibold text-4xl text-foreground leading-8">
|
||||
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -6,13 +6,9 @@ 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 {
|
||||
canAccessOrganisationAnalytics,
|
||||
canExecuteOrganisationAction,
|
||||
formatOrganisationAnalyticsPath,
|
||||
} from '@documenso/lib/utils/organisations';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { canExecuteTeamAction, formatAnalyticsPath } from '@documenso/lib/utils/teams';
|
||||
import { canExecuteTeamAction } 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';
|
||||
@@ -66,13 +62,6 @@ 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
|
||||
@@ -282,14 +271,6 @@ 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,40 +8,20 @@ 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,
|
||||
toolbar,
|
||||
toolbarClassName,
|
||||
className,
|
||||
...props
|
||||
}: EnvelopePdfViewerProps) => {
|
||||
export const EnvelopePdfViewer = ({ errorMessage, className, ...props }: EnvelopePdfViewerProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const $el = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { currentEnvelopeItem, renderError, viewerControls } = useCurrentEnvelopeRender();
|
||||
const { currentEnvelopeItem, renderError } = useCurrentEnvelopeRender();
|
||||
|
||||
if (renderError || !currentEnvelopeItem) {
|
||||
return (
|
||||
@@ -65,24 +45,12 @@ export const EnvelopePdfViewer = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<PDFViewerLazy
|
||||
key={`${currentEnvelopeItem.envelopeId}-${currentEnvelopeItem.id}`}
|
||||
{...props}
|
||||
className={cn('h-full w-full max-w-[800px]', className)}
|
||||
data={currentEnvelopeItem.data}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,26 +5,15 @@ 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, isPageReady, imageProps }: PdfViewerPageImageProps) => {
|
||||
const isLoading = !isPageReady && imageLoadingState !== 'error';
|
||||
|
||||
export const PdfViewerPageImage = ({ imageLoadingState, imageProps }: PdfViewerPageImageProps) => {
|
||||
return (
|
||||
<>
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div
|
||||
className="absolute inset-0 z-10 flex items-center justify-center text-muted-foreground opacity-20"
|
||||
data-testid="page-loader"
|
||||
>
|
||||
{imageLoadingState === 'loading' && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center text-muted-foreground opacity-20">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
@@ -39,12 +28,7 @@ export const PdfViewerPageImage = ({ imageLoadingState, isPageReady, imageProps
|
||||
|
||||
{/* The PDF image. */}
|
||||
{imageProps.src && (
|
||||
<img
|
||||
{...imageProps}
|
||||
className={cn(imageProps.className, 'select-none', !isPageReady && 'invisible')}
|
||||
draggable={false}
|
||||
alt=""
|
||||
/>
|
||||
<img {...imageProps} className={cn(imageProps.className, 'select-none')} draggable={false} alt="" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
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/legacy/build/pdf.mjs';
|
||||
import pdfjsWorker from 'pdfjs-dist/legacy/build/pdf.worker.mjs?url';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?url';
|
||||
import type React from 'react';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { ScrollTarget } from '../virtual-list/use-virtual-list';
|
||||
import { useVirtualList } from '../virtual-list/use-virtual-list';
|
||||
@@ -29,16 +29,6 @@ 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;
|
||||
|
||||
@@ -62,23 +52,6 @@ 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.
|
||||
@@ -91,8 +64,6 @@ export default function PDFViewer({
|
||||
data,
|
||||
scrollParentRef,
|
||||
onDocumentLoad,
|
||||
zoom = 1,
|
||||
maxPageWidth,
|
||||
customPageRenderer,
|
||||
...props
|
||||
}: PDFViewerProps) {
|
||||
@@ -242,8 +213,6 @@ export default function PDFViewer({
|
||||
numPages={pages.length}
|
||||
pages={pages}
|
||||
pdf={pdfRef.current}
|
||||
zoom={zoom}
|
||||
maxPageWidth={maxPageWidth}
|
||||
customPageRenderer={customPageRenderer}
|
||||
/>
|
||||
)}
|
||||
@@ -257,8 +226,6 @@ type VirtualizedPageListProps = {
|
||||
pages: PageMeta[];
|
||||
numPages: number;
|
||||
pdf: pdfjsLib.PDFDocumentProxy;
|
||||
zoom: number;
|
||||
maxPageWidth?: number;
|
||||
customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>;
|
||||
};
|
||||
|
||||
@@ -268,8 +235,6 @@ const VirtualizedPageList = ({
|
||||
pages,
|
||||
numPages,
|
||||
pdf,
|
||||
zoom,
|
||||
maxPageWidth,
|
||||
customPageRenderer,
|
||||
}: VirtualizedPageListProps) => {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
@@ -279,81 +244,22 @@ const VirtualizedPageList = ({
|
||||
constraintRef,
|
||||
contentRef,
|
||||
itemCount: numPages,
|
||||
itemSize: (index, width) => getPageItemSize(pages[index], getDisplayWidth(width, zoom, maxPageWidth)),
|
||||
itemSize: (index, width) => {
|
||||
const pageMeta = pages[index];
|
||||
|
||||
// Calculate height based on aspect ratio and available width
|
||||
const aspectRatio = pageMeta.height / pageMeta.width;
|
||||
const scaledHeight = width * aspectRatio;
|
||||
|
||||
// Add 32px for the page number text and margins (my-2 = 8px * 2 + text height ~16px)
|
||||
// Add additional 2px for the top and bottom borders.
|
||||
return scaledHeight + 32 + 2;
|
||||
},
|
||||
overscan: 5,
|
||||
});
|
||||
|
||||
/**
|
||||
* 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}
|
||||
@@ -362,11 +268,7 @@ const VirtualizedPageList = ({
|
||||
data-page-count={numPages}
|
||||
style={{
|
||||
height: `${totalSize}px`,
|
||||
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',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
@@ -375,11 +277,11 @@ const VirtualizedPageList = ({
|
||||
const pageMeta = pages[index];
|
||||
const pageNumber = index + 1;
|
||||
|
||||
// Calculate scale based on the rendered page width.
|
||||
const scale = displayWidth / pageMeta.width;
|
||||
// Calculate scale based on constraint width
|
||||
const scale = constraintWidth / pageMeta.width;
|
||||
|
||||
const scaledWidth = displayWidth;
|
||||
const scaledHeight = Math.round(pageMeta.height * scale);
|
||||
const scaledWidth = Math.floor(pageMeta.width * scale);
|
||||
const scaledHeight = Math.floor(pageMeta.height * scale);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -388,7 +290,7 @@ const VirtualizedPageList = ({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: displayWidth,
|
||||
width: constraintWidth,
|
||||
height: `${virtualItem.size}px`,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
@@ -404,12 +306,7 @@ const VirtualizedPageList = ({
|
||||
customPageRenderer={customPageRenderer}
|
||||
/>
|
||||
|
||||
<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,
|
||||
})}
|
||||
>
|
||||
<p className="my-2 text-center text-[11px] text-muted-foreground/80">
|
||||
<Trans>
|
||||
Page {pageNumber} of {numPages}
|
||||
</Trans>
|
||||
@@ -421,54 +318,6 @@ 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;
|
||||
@@ -500,19 +349,8 @@ 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 (
|
||||
// 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 }}>
|
||||
<div className="relative w-full rounded border border-border" style={{ width: scaledWidth, height: scaledHeight }}>
|
||||
{CustomPageRenderer && imageLoadingState === 'loaded' && (
|
||||
<CustomPageRenderer
|
||||
pageData={{
|
||||
@@ -522,12 +360,11 @@ const PdfViewerPage = ({
|
||||
pageWidth: unscaledWidth,
|
||||
pageHeight: unscaledHeight,
|
||||
imageLoadingState,
|
||||
onReadyChange: setIsCustomRendererReady,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PdfViewerPageImage imageLoadingState={imageLoadingState} isPageReady={isPageReady} imageProps={imageProps} />
|
||||
<PdfViewerPageImage imageLoadingState={imageLoadingState} imageProps={imageProps} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -665,16 +502,8 @@ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }:
|
||||
const imageProps = useMemo(
|
||||
(): React.ImgHTMLAttributes<HTMLImageElement> & Record<string, unknown> & { alt: '' } => ({
|
||||
className: PDF_VIEWER_PAGE_CLASSNAME,
|
||||
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',
|
||||
},
|
||||
width: Math.floor(scaledWidth),
|
||||
height: Math.floor(scaledHeight),
|
||||
alt: '',
|
||||
onLoad: () => setImageLoadingState('loaded'),
|
||||
onError: () => setImageLoadingState('error'),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { RecipientStatusType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
|
||||
const ZIndexes: { [key: string]: string } = {
|
||||
@@ -15,10 +14,9 @@ export type StackAvatarProps = {
|
||||
zIndex?: string;
|
||||
fallbackText?: string;
|
||||
type: RecipientStatusType;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const StackAvatar = ({ first, zIndex, fallbackText = '', type, className }: StackAvatarProps) => {
|
||||
export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => {
|
||||
let classes = '';
|
||||
let zIndexClass = '';
|
||||
const firstClass = first ? '' : '-ml-3';
|
||||
@@ -48,14 +46,7 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type, className
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
className={cn(
|
||||
zIndexClass,
|
||||
firstClass,
|
||||
'h-10 w-10 border-2 border-white border-solid dark:border-border',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Avatar className={` ${zIndexClass} ${firstClass} h-10 w-10 border-2 border-white border-solid dark:border-border`}>
|
||||
<AvatarFallback className={classes}>{fallbackText}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
|
||||
@@ -1,33 +1,16 @@
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import {
|
||||
getExtraRecipientsType,
|
||||
getRecipientType,
|
||||
RecipientStatusType,
|
||||
} from '@documenso/lib/client-only/recipient-type';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getRecipientType, RecipientStatusType } from '@documenso/lib/client-only/recipient-type';
|
||||
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 { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
CheckIcon,
|
||||
CircleCheckIcon,
|
||||
CircleDashedIcon,
|
||||
CircleXIcon,
|
||||
ClockIcon,
|
||||
CopyIcon,
|
||||
MailOpenIcon,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentStatus } from '@prisma/client';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { AvatarWithRecipient } from './avatar-with-recipient';
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
import { StackAvatars } from './stack-avatars';
|
||||
|
||||
export type StackAvatarsWithTooltipProps = {
|
||||
documentStatus: DocumentStatus;
|
||||
@@ -44,238 +27,125 @@ export const StackAvatarsWithTooltip = ({
|
||||
}: StackAvatarsWithTooltipProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const groups = groupRecipientsByStatus(recipients);
|
||||
const waitingRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.WAITING,
|
||||
);
|
||||
|
||||
return RECIPIENT_STATUS_SECTIONS.map((section) => ({
|
||||
...section,
|
||||
recipients: groups[section.type],
|
||||
})).filter((section) => section.recipients.length > 0);
|
||||
const openedRecipients = recipients.filter((recipient) => getRecipientType(recipient) === RecipientStatusType.OPENED);
|
||||
|
||||
const completedRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.COMPLETED,
|
||||
);
|
||||
|
||||
const uncompletedRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.UNSIGNED,
|
||||
);
|
||||
|
||||
const rejectedRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED,
|
||||
);
|
||||
|
||||
const sortedRecipients = useMemo(() => {
|
||||
const otherRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED,
|
||||
);
|
||||
|
||||
return [
|
||||
...rejectedRecipients.sort((a, b) => a.id - b.id),
|
||||
...otherRecipients.sort((a, b) => {
|
||||
return a.id - b.id;
|
||||
}),
|
||||
];
|
||||
}, [recipients]);
|
||||
|
||||
const canCopySigningLink = documentStatus === DocumentStatus.PENDING;
|
||||
|
||||
return (
|
||||
<PopoverHover
|
||||
trigger={children || <RecipientAvatarStack recipients={recipients} />}
|
||||
trigger={children || <StackAvatars recipients={sortedRecipients} />}
|
||||
contentProps={{
|
||||
className:
|
||||
'max-h-[var(--radix-popover-content-available-height)] w-72 divide-y divide-border/50 overflow-y-auto p-0 text-sm',
|
||||
className: 'flex flex-col gap-y-5 py-2',
|
||||
side: position,
|
||||
// Keep clear of the sticky app header (h-16, z-[60]) which paints above popovers.
|
||||
collisionPadding: { top: 72, bottom: 8, left: 8, right: 8 },
|
||||
// Opened via hover, so don't steal focus from wherever the user was.
|
||||
onOpenAutoFocus: (event) => event.preventDefault(),
|
||||
}}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<div key={section.type} className="px-3 py-2">
|
||||
<div className={cn('flex items-center gap-1.5 font-medium text-xs', section.className)}>
|
||||
<section.icon className="h-3.5 w-3.5 shrink-0" strokeWidth={2} />
|
||||
<span>{_(section.label)}</span>
|
||||
<span>{section.recipients.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1">
|
||||
{section.recipients.map((recipient) => (
|
||||
<RecipientRow
|
||||
{completedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="font-medium text-base">
|
||||
<Trans>Completed</Trans>
|
||||
</h1>
|
||||
{completedRecipients.map((recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
signingToken={canCopySigningLink && section.hasSigningLink ? recipient.token : null}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">{recipient.email || recipient.name}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
{rejectedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="font-medium text-base">
|
||||
<Trans>Rejected</Trans>
|
||||
</h1>
|
||||
{rejectedRecipients.map((recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">{recipient.email || recipient.name}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{waitingRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="font-medium text-base">
|
||||
<Trans>Waiting</Trans>
|
||||
</h1>
|
||||
{waitingRecipients.map((recipient) => (
|
||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="font-medium text-base">
|
||||
<Trans>Opened</Trans>
|
||||
</h1>
|
||||
{openedRecipients.map((recipient) => (
|
||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uncompletedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="font-medium text-base">
|
||||
<Trans>Uncompleted</Trans>
|
||||
</h1>
|
||||
{uncompletedRecipients.map((recipient) => (
|
||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PopoverHover>
|
||||
);
|
||||
};
|
||||
|
||||
type RecipientRowProps = {
|
||||
recipient: TRecipientLite;
|
||||
signingToken: string | null;
|
||||
};
|
||||
|
||||
const RecipientRow = ({ recipient, signingToken }: RecipientRowProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [, copy] = useCopyToClipboard();
|
||||
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => () => clearTimeout(copiedTimeoutRef.current ?? undefined), []);
|
||||
|
||||
const onCopySigningLink = () => {
|
||||
if (!signingToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => {
|
||||
setIsCopied(true);
|
||||
|
||||
clearTimeout(copiedTimeoutRef.current ?? undefined);
|
||||
copiedTimeoutRef.current = setTimeout(() => setIsCopied(false), COPIED_INDICATOR_DURATION_MS);
|
||||
|
||||
toast({
|
||||
title: _(msg`Copied to clipboard`),
|
||||
description: _(msg`The signing link has been copied to your clipboard.`),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<StackAvatar
|
||||
first={true}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
className="h-6 w-6 shrink-0 border-0 text-[10px]"
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1 text-xs leading-snug">
|
||||
<p className="truncate text-foreground">{recipient.email || recipient.name}</p>
|
||||
<p className="truncate text-muted-foreground">{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!signingToken) {
|
||||
return <div className={recipientRowClassName}>{content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
recipientRowClassName,
|
||||
'group w-[calc(100%+1rem)] cursor-pointer text-left transition-colors duration-300 hover:bg-muted',
|
||||
)}
|
||||
title={_(msg`Click to copy signing link for sending to recipient`)}
|
||||
onClick={onCopySigningLink}
|
||||
>
|
||||
{content}
|
||||
|
||||
{isCopied ? (
|
||||
<CheckIcon className="h-3 w-3 shrink-0 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<CopyIcon className="h-3 w-3 shrink-0 text-muted-foreground opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const RecipientAvatarStack = ({ recipients }: { recipients: TRecipientLite[] }) => {
|
||||
const sortedRecipients = useMemo(() => {
|
||||
const byId = (a: TRecipientLite, b: TRecipientLite) => a.id - b.id;
|
||||
|
||||
const rejected = recipients.filter((r) => getRecipientType(r) === RecipientStatusType.REJECTED);
|
||||
const others = recipients.filter((r) => getRecipientType(r) !== RecipientStatusType.REJECTED);
|
||||
|
||||
return [...rejected.sort(byId), ...others.sort(byId)];
|
||||
}, [recipients]);
|
||||
|
||||
const visibleRecipients = sortedRecipients.slice(0, MAX_VISIBLE_AVATARS);
|
||||
const hiddenRecipients = sortedRecipients.slice(MAX_VISIBLE_AVATARS);
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleRecipients.map((recipient, index) => {
|
||||
const isOverflowSlot = index === MAX_VISIBLE_AVATARS - 1 && hiddenRecipients.length > 0;
|
||||
const zIndex = String(50 - index * 10);
|
||||
|
||||
if (isOverflowSlot) {
|
||||
return (
|
||||
<StackAvatar
|
||||
key="extra-recipients"
|
||||
first={index === 0}
|
||||
zIndex={zIndex}
|
||||
type={getExtraRecipientsType(sortedRecipients.slice(index))}
|
||||
fallbackText={`+${hiddenRecipients.length + 1}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StackAvatar
|
||||
key={recipient.id}
|
||||
first={index === 0}
|
||||
zIndex={zIndex}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const groupRecipientsByStatus = (recipients: TRecipientLite[]) => {
|
||||
const groups: Record<RecipientStatusType, TRecipientLite[]> = {
|
||||
[RecipientStatusType.COMPLETED]: [],
|
||||
[RecipientStatusType.REJECTED]: [],
|
||||
[RecipientStatusType.WAITING]: [],
|
||||
[RecipientStatusType.OPENED]: [],
|
||||
[RecipientStatusType.UNSIGNED]: [],
|
||||
};
|
||||
|
||||
for (const recipient of recipients) {
|
||||
groups[getRecipientType(recipient)].push(recipient);
|
||||
}
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
const MAX_VISIBLE_AVATARS = 5;
|
||||
|
||||
const COPIED_INDICATOR_DURATION_MS = 2000;
|
||||
|
||||
const recipientRowClassName = '-mx-2 flex items-center gap-2 rounded-md px-2 py-1';
|
||||
|
||||
type RecipientStatusSection = {
|
||||
type: RecipientStatusType;
|
||||
label: MessageDescriptor;
|
||||
icon: LucideIcon;
|
||||
className: string;
|
||||
/** Whether recipients in this section still need to sign, so a signing link can be copied. */
|
||||
hasSigningLink: boolean;
|
||||
};
|
||||
|
||||
const RECIPIENT_STATUS_SECTIONS: RecipientStatusSection[] = [
|
||||
{
|
||||
type: RecipientStatusType.COMPLETED,
|
||||
label: msg`Completed`,
|
||||
icon: CircleCheckIcon,
|
||||
className: 'text-green-600 dark:text-green-400',
|
||||
hasSigningLink: false,
|
||||
},
|
||||
{
|
||||
type: RecipientStatusType.REJECTED,
|
||||
label: msg`Rejected`,
|
||||
icon: CircleXIcon,
|
||||
className: 'text-red-600 dark:text-red-400',
|
||||
hasSigningLink: false,
|
||||
},
|
||||
{
|
||||
type: RecipientStatusType.WAITING,
|
||||
label: msg`Waiting`,
|
||||
icon: ClockIcon,
|
||||
className: 'text-blue-600 dark:text-blue-400',
|
||||
hasSigningLink: true,
|
||||
},
|
||||
{
|
||||
type: RecipientStatusType.OPENED,
|
||||
label: msg`Opened`,
|
||||
icon: MailOpenIcon,
|
||||
className: 'text-amber-600 dark:text-amber-400',
|
||||
hasSigningLink: true,
|
||||
},
|
||||
{
|
||||
type: RecipientStatusType.UNSIGNED,
|
||||
label: msg`Uncompleted`,
|
||||
icon: CircleDashedIcon,
|
||||
className: 'text-muted-foreground',
|
||||
hasSigningLink: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { getExtraRecipientsType, getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
|
||||
export function StackAvatars({ recipients }: { recipients: TRecipientLite[] }) {
|
||||
const renderStackAvatars = (recipients: TRecipientLite[]) => {
|
||||
const zIndex = 50;
|
||||
const itemsToRender = recipients.slice(0, 5);
|
||||
const remainingItems = recipients.length - itemsToRender.length;
|
||||
|
||||
return itemsToRender.map((recipient, index: number) => {
|
||||
const first = index === 0;
|
||||
|
||||
if (index === 4 && remainingItems > 0) {
|
||||
return (
|
||||
<StackAvatar
|
||||
key="extra-recipient"
|
||||
first={first}
|
||||
zIndex={String(zIndex - index * 10)}
|
||||
type={getExtraRecipientsType(recipients.slice(4))}
|
||||
fallbackText={`+${remainingItems + 1}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StackAvatar
|
||||
key={recipient.id}
|
||||
first={first}
|
||||
zIndex={String(zIndex - index * 10)}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return <>{renderStackAvatars(recipients)}</>;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { templatesSearchParams } from '~/utils/templates-search-params';
|
||||
|
||||
export const TemplateSearch = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [{ query }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
query: templatesSearchParams.query,
|
||||
page: templatesSearchParams.page,
|
||||
},
|
||||
{ history: 'push' },
|
||||
);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(query ?? '');
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchTerm !== (query ?? '')) {
|
||||
void setSearchParams({
|
||||
query: debouncedSearchTerm || null,
|
||||
page: null,
|
||||
});
|
||||
}
|
||||
}, [debouncedSearchTerm, query, setSearchParams]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={_(msg`Search templates...`)}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
data-testid="templates-search-input"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { VerifiedIcon } from '@documenso/ui/icons/verified';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { File, User2 } from 'lucide-react';
|
||||
|
||||
export type UserProfileSkeletonProps = {
|
||||
className?: string;
|
||||
user: {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
rows?: number;
|
||||
};
|
||||
|
||||
export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSkeletonProps) => {
|
||||
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center rounded-xl bg-neutral-100 p-4 dark:bg-background', className)}>
|
||||
<div className="inline-block max-w-full truncate rounded-md border border-border bg-background px-2.5 py-1.5 text-muted-foreground text-sm lowercase">
|
||||
{baseUrl.host}/u/{user.url}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="rounded-full bg-primary/10 p-1.5">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full border-2 bg-background">
|
||||
<User2 className="h-12 w-12 text-[hsl(228,10%,90%)]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-center gap-x-2">
|
||||
<h2 className="max-w-[12rem] truncate font-semibold text-2xl">{user.name}</h2>
|
||||
|
||||
<VerifiedIcon className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-4 h-2 w-52 rounded-full bg-neutral-300 dark:bg-foreground/30" />
|
||||
<div className="mx-auto mt-2 h-2 w-36 rounded-full bg-neutral-200 dark:bg-foreground/20" />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 w-full">
|
||||
<div className="divide-y-2 divide-neutral-200 overflow-hidden rounded-lg border-2 border-neutral-200 dark:divide-foreground/30 dark:border-foreground/30">
|
||||
<div className="bg-neutral-50 p-4 font-medium text-muted-foreground dark:bg-foreground/20">
|
||||
<Trans>Documents</Trans>
|
||||
</div>
|
||||
|
||||
{Array(rows)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<div key={index} className="flex items-center justify-between gap-x-6 bg-background p-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<File className="h-8 w-8 text-muted-foreground/80" strokeWidth={1.5} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="h-1.5 w-24 rounded-full bg-neutral-300 md:w-36 dark:bg-foreground/30" />
|
||||
<div className="h-1.5 w-16 rounded-full bg-neutral-200 md:w-24 dark:bg-foreground/20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<Button type="button" size="sm" className="pointer-events-none w-32">
|
||||
<Trans>Sign</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
|
||||
@@ -14,26 +14,13 @@ import { FilterPill } from '~/components/general/filter-pill';
|
||||
import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
|
||||
type DocumentsTableStatusFilterProps = {
|
||||
/**
|
||||
* Per-status document counts, shown next to each option. When omitted no
|
||||
* counts are rendered.
|
||||
*/
|
||||
stats?: TFindDocumentsInternalResponse['stats'];
|
||||
|
||||
/**
|
||||
* The statuses available for selection. Defaults to every status that
|
||||
* makes sense for the documents page.
|
||||
*/
|
||||
statuses?: ExtendedDocumentStatus[];
|
||||
stats: TFindDocumentsInternalResponse['stats'];
|
||||
};
|
||||
|
||||
export const DocumentsTableStatusFilter = ({
|
||||
stats,
|
||||
statuses = SELECTABLE_STATUSES,
|
||||
}: DocumentsTableStatusFilterProps) => {
|
||||
export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilterProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const organisation = useOptionalCurrentOrganisation();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const [{ status }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
@@ -45,14 +32,14 @@ export const DocumentsTableStatusFilter = ({
|
||||
|
||||
const selectableStatuses = useMemo(
|
||||
() =>
|
||||
statuses.filter((value) => {
|
||||
if (organisation?.type === OrganisationType.PERSONAL) {
|
||||
SELECTABLE_STATUSES.filter((value) => {
|
||||
if (organisation.type === OrganisationType.PERSONAL) {
|
||||
return value !== ExtendedDocumentStatus.INBOX;
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
[organisation?.type, statuses],
|
||||
[organisation.type],
|
||||
);
|
||||
|
||||
const selectedStatus = useMemo(
|
||||
@@ -78,22 +65,20 @@ export const DocumentsTableStatusFilter = ({
|
||||
options={selectableStatuses.map((value) => ({
|
||||
value,
|
||||
label: <DocumentStatus status={value} />,
|
||||
trailing: stats ? formatStatsCount(stats[value]) : undefined,
|
||||
trailing: formatStatsCount(stats[value]),
|
||||
}))}
|
||||
testId="documents-table-status-filter"
|
||||
/>
|
||||
|
||||
{/* Visually hidden document counts, for screen readers and tests. */}
|
||||
{stats && (
|
||||
<span className="sr-only" data-testid="documents-status-counts">
|
||||
{[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => (
|
||||
<span key={value}>
|
||||
{_(FRIENDLY_STATUS_MAP[value].label)}:{' '}
|
||||
<span data-testid={`documents-status-count-${value}`}>{stats[value]}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<span className="sr-only" data-testid="documents-status-counts">
|
||||
{[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => (
|
||||
<span key={value}>
|
||||
{_(FRIENDLY_STATUS_MAP[value].label)}:{' '}
|
||||
<span data-testid={`documents-status-count-${value}`}>{stats[value]}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,17 +16,22 @@ import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus as DocumentStatusEnum, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
import { useMemo, useTransition } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
import { inboxSearchParams, resolveInboxStatus } from '~/utils/inbox-search-params';
|
||||
|
||||
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
|
||||
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
|
||||
|
||||
export type DocumentsTableProps = {
|
||||
data?: TFindInboxResponse;
|
||||
isLoading?: boolean;
|
||||
isLoadingError?: boolean;
|
||||
};
|
||||
|
||||
type DocumentsTableRow = TFindInboxResponse['data'][number];
|
||||
|
||||
export const InboxTable = () => {
|
||||
@@ -35,24 +40,17 @@ export const InboxTable = () => {
|
||||
const team = useOptionalCurrentTeam();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const [findInboxSearchParams] = useQueryStates(inboxSearchParams, {
|
||||
history: 'push',
|
||||
});
|
||||
|
||||
const status = resolveInboxStatus(findInboxSearchParams.status);
|
||||
const query = findInboxSearchParams.query ?? '';
|
||||
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
|
||||
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.document.inbox.find.useQuery({
|
||||
page: Math.max(findInboxSearchParams.page ?? 1, 1),
|
||||
perPage: Math.min(Math.max(findInboxSearchParams.perPage ?? 10, 1), 100),
|
||||
query: query || undefined,
|
||||
status,
|
||||
page: page || 1,
|
||||
perPage: perPage || 10,
|
||||
});
|
||||
|
||||
const hasSearchQuery = query.trim().length > 0;
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
@@ -125,20 +123,7 @@ export const InboxTable = () => {
|
||||
emptyState={
|
||||
<div className="flex h-60 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
|
||||
<p>
|
||||
{match({ hasSearchQuery, status })
|
||||
.with({ hasSearchQuery: true }, () => <Trans>No documents match your search</Trans>)
|
||||
.with({ status: DocumentStatusEnum.COMPLETED }, () => (
|
||||
<Trans>Documents that you have completed will appear here</Trans>
|
||||
))
|
||||
.with({ status: DocumentStatusEnum.REJECTED }, () => (
|
||||
<Trans>Documents that have been rejected will appear here</Trans>
|
||||
))
|
||||
.with({ status: DocumentStatusEnum.CANCELLED }, () => (
|
||||
<Trans>Documents that have been cancelled will appear here</Trans>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<Trans>Documents that require your attention will appear here</Trans>
|
||||
))}
|
||||
<Trans>Documents that require your attention will appear here</Trans>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -170,10 +170,7 @@ export const TemplatesTableActionDropdown = ({
|
||||
onOpenChange={setRenameDialogOpen}
|
||||
envelopeType="template"
|
||||
onSuccess={async () => {
|
||||
await Promise.all([
|
||||
trpcUtils.template.findTemplates.invalidate(),
|
||||
trpcUtils.template.findTemplatesInternal.invalidate(),
|
||||
]);
|
||||
await trpcUtils.template.findTemplates.invalidate();
|
||||
}}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user