Compare commits

..
Author SHA1 Message Date
ephraimduncan dcda1e905d feat: replace template view tabs with a filter pill
This commit replaces the Team/Organisation tabs on the templates page with a
FilterPill. The page uses the shared nuqs parsers for the view, page, and
perPage URL parameters. The documents page (#3145) uses the same components.
The e2e tests use the new filter test ID.
2026-08-04 13:14:11 +00:00
Lucas Smith 9c27ce6d18 feat: replace document status tabs with filter pills (#3145)
Swaps the tab row and dropdowns for faceted filter pills (status,
sender, period) with a shared reset, and moves URL param handling
to nuqs.

<img width="2198" height="1674" alt="image"
src="https://github.com/user-attachments/assets/6996431c-09c8-45c3-bc30-f0a1e503c941"
/>
2026-08-03 22:52:11 +10:00
Ephraim Duncan b3c609a549 feat: bulk download documents (#2711) 2026-08-03 20:55:43 +10:00
Ephraim Duncan 29020bcbed docs(webhooks): correct retry policy, timeout and payload reference (#3132)
## Description

Corrects the webhooks documentation, which described delivery behavior
that does not exist in the implementation.

## Changes Made

- Replaced the fabricated retry schedule (5 attempts / immediate-to-2h
backoff) with the real provider-dependent behavior: retries belong to
the job provider (`NEXT_PRIVATE_JOBS_PROVIDER`) — local (default) 4
total attempts back-to-back, BullMQ 3 attempts with exponential backoff
from 1s, Inngest 5 attempts with platform backoff.
- Fixed the webhook timeout from 30 seconds to 10 seconds
(`WEBHOOK_TIMEOUT_MS = 10_000`, hard abort).
- Clarified failure semantics: non-2xx fails, 3xx redirects are not
followed (`redirect: 'manual'`), network/SSRF-blocked calls record
response code 0; failed deliveries mark only the `WebhookCall` record —
the webhook itself is never auto-disabled.
- Corrected URL requirements: `http://` is accepted; documented the SSRF
guard (private/loopback blocked,
`NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS` bypass for self-hosters).
- Added `envelopeId` to both field tables and all payload/recipient JSON
examples; framed numeric `id` as the legacy v1 identifier.
- Removed a documented `documentMeta` field that exists in neither the
Zod schema nor Prisma; fixed timezone/dateFormat examples to the
hardcoded `Etc/UTC` / `yyyy-MM-dd hh:mm a` values.
- Added missing `REJECTED`/`CANCELLED` statuses and
`TEMPLATE_DIRECT_LINK` source; fixed `templateId` to `null` on
TEMPLATE_* examples; documented the previously missing
`RECIPIENT_EXPIRED` event across setup, events, and verification pages.

## Testing Performed

Docs-only change. Every claim verified against the implementation
(`execute-webhook-call.ts`, job clients, `webhook-payload.ts`,
`assert-webhook-url.ts`, webhook-router schema).
2026-08-03 12:16:39 +10:00
40 changed files with 1932 additions and 837 deletions
@@ -32,9 +32,9 @@ A document object contains the following properties:
| --------------- | -------------- | -------------------------------------------------------------- |
| `id` | string | Unique identifier (e.g., `envelope_abc123`) |
| `type` | string | `DOCUMENT` or `TEMPLATE` |
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, or `CANCELLED` |
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, or `REJECTED` |
| `title` | string | Document title |
| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` |
| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `API` |
| `visibility` | string | Who can view: `EVERYONE`, `ADMIN`, `MANAGER_AND_ABOVE` |
| `externalId` | string \| null | Your custom identifier for the document |
| `createdAt` | string | ISO 8601 timestamp |
@@ -53,7 +53,7 @@ A document object contains the following properties:
"id": "envelope_abc123xyz",
"type": "DOCUMENT",
"status": "PENDING",
"source": "DOCUMENT",
"source": "API",
"visibility": "EVERYONE",
"title": "Service Agreement",
"externalId": "contract-2025-001",
@@ -73,13 +73,13 @@ A document object contains the following properties:
],
"fields": [
{
"id": 123,
"id": "field_123",
"type": "SIGNATURE",
"page": 1,
"positionX": "10",
"positionY": "80",
"width": "30",
"height": "5",
"positionX": 10,
"positionY": 80,
"width": 30,
"height": 5,
"recipientId": 1
}
],
@@ -99,8 +99,6 @@ A document object contains the following properties:
}
```
Field position and size values are stored as decimals and serialized as strings in API responses.
## List Documents
Retrieve a paginated list of documents.
@@ -116,7 +114,7 @@ GET /envelope
| `page` | integer | Page number (default: 1) |
| `perPage` | integer | Results per page (default: 10, max: 100) |
| `type` | string | Filter by `DOCUMENT` or `TEMPLATE` |
| `status` | string | Filter by status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` |
| `status` | string | Filter by status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` |
| `source` | string | Filter by creation source |
| `folderId` | string | Filter by folder ID |
| `orderByColumn` | string | Sort field (only `createdAt` supported) |
@@ -156,8 +154,8 @@ const response = await fetch(`${BASE_URL}/envelope`, {
},
});
const { data, count } = await response.json();
console.log(`Found ${count} documents`);
const { data, pagination } = await response.json();
console.log(`Found ${pagination.totalItems} documents`);
// Filter by status
const pendingResponse = await fetch(
@@ -199,10 +197,12 @@ const pendingDocs = await pendingResponse.json();
]
}
],
"count": 42,
"currentPage": 1,
"perPage": 10,
"totalPages": 5
"pagination": {
"page": 1,
"perPage": 10,
"totalPages": 5,
"totalItems": 42
}
}
```
@@ -628,72 +628,6 @@ The response includes signing URLs for each recipient:
---
## Cancel Document
Cancel a pending document. This changes its status from `PENDING` to `CANCELLED`.
```
POST /envelope/cancel
```
### Request Body
| Field | Type | Required | Description |
| ------------ | ------ | -------- | ----------------------------------- |
| `envelopeId` | string | Yes | Document ID |
| `reason` | string | No | Reason for cancelling the document |
### Code Examples
<Tabs items={['curl', 'TypeScript']}>
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/envelope/cancel" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"envelopeId": "envelope_abc123",
"reason": "The agreement is no longer needed."
}'
```
</Tab>
<Tab value="TypeScript">
```typescript
const response = await fetch('https://app.documenso.com/api/v2/envelope/cancel', {
method: 'POST',
headers: {
Authorization: 'api_xxxxxxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'envelope_abc123',
reason: 'The agreement is no longer needed.',
}),
});
const { success } = await response.json();
```
</Tab>
</Tabs>
### Response
```json
{
"success": true
}
```
### Behavior
- Only documents in `PENDING` status can be cancelled. Other statuses return `400`.
- Cancellation is not idempotent. Cancelling the same document again returns `400`.
- The document owner and team members with `MANAGER` or higher permissions can cancel it. Requests for documents you cannot view return `404`; requests for visible documents without sufficient permissions return `401`.
- A successful cancellation fires the `DOCUMENT_CANCELLED` webhook.
- Cancellation emails are sent only to eligible non-CC, non-rejected recipients who were sent or opened the document.
---
## Delete Document
Delete a document. Completed documents cannot be deleted.
@@ -736,7 +670,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/delete',
const { success } = await response.json();
```
````
</Tab>
</Tabs>
@@ -746,7 +680,7 @@ const { success } = await response.json();
{
"success": true
}
```
````
---
@@ -760,11 +694,9 @@ POST /envelope/get-many
### Request Body
| Field | Type | Required | Description |
| ---------- | ------ | -------- | ---------------------------------------------------------------------------- |
| `ids` | object | Yes | ID selector containing `type` and `ids` |
| `ids.type` | string | Yes | `envelopeId`, `documentId`, or `templateId` |
| `ids.ids` | array | Yes | 1-20 IDs: strings for `envelopeId`; numbers for `documentId` or `templateId` |
| Field | Type | Required | Description |
| ------------- | ----- | -------- | --------------------- |
| `envelopeIds` | array | Yes | Array of document IDs |
### Code Examples
@@ -775,17 +707,12 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/get-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"ids": {
"type": "envelopeId",
"ids": ["envelope_abc123", "envelope_def456", "envelope_ghi789"]
}
"envelopeIds": ["envelope_abc123", "envelope_def456", "envelope_ghi789"]
}'
```
</Tab>
<Tab value="TypeScript">
```typescript
const requestedIds = ['envelope_abc123', 'envelope_def456', 'envelope_ghi789'];
const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many', {
method: 'POST',
headers: {
@@ -793,36 +720,16 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many
'Content-Type': 'application/json',
},
body: JSON.stringify({
ids: {
type: 'envelopeId',
ids: requestedIds,
},
envelopeIds: ['envelope_abc123', 'envelope_def456', 'envelope_ghi789'],
}),
});
const { data } = await response.json();
const documents = await response.json();
```
````
</Tab>
</Tabs>
### Response
```json
{
"data": [
{
"id": "envelope_abc123",
"type": "DOCUMENT",
"status": "PENDING",
"title": "Service Agreement"
}
]
}
```
The endpoint silently omits envelopes you cannot access instead of returning `404`. Compare `data.length` with `requestedIds.length` to detect omissions.
---
## Document Statuses
@@ -833,7 +740,6 @@ The endpoint silently omits envelopes you cannot access instead of returning `40
| `PENDING` | Document has been sent. Waiting for recipients to sign. |
| `COMPLETED` | All recipients have signed. Document is sealed. |
| `REJECTED` | A recipient rejected the document. |
| `CANCELLED` | The document was cancelled by its owner or a team member with `MANAGER` or higher permissions. |
### Status Transitions
@@ -841,13 +747,11 @@ The endpoint silently omits envelopes you cannot access instead of returning `40
flowchart LR
DRAFT --> PENDING --> COMPLETED
PENDING --> REJECTED
PENDING --> CANCELLED
```
- **DRAFT to PENDING**: Call the distribute endpoint
- **PENDING to COMPLETED**: All recipients complete their signing
- **PENDING to REJECTED**: A recipient rejects the document
- **PENDING to CANCELLED**: The document owner or a team member with `MANAGER` or higher permissions cancels the document
<Callout type="warn">
You cannot modify recipients or fields after a document moves to `PENDING` status.
@@ -869,8 +773,8 @@ flowchart LR
| Parameter | Values | Description |
| ---------- | ------------------------------------------- | ------------------------- |
| `type` | `DOCUMENT`, `TEMPLATE` | Filter by envelope type |
| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` | Filter by status |
| `source` | `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` | Filter by creation source |
| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` | Filter by status |
| `source` | `DOCUMENT`, `TEMPLATE`, `API` | Filter by creation source |
| `folderId` | string | Filter by folder |
### Sorting
@@ -896,10 +800,10 @@ async function getAllPendingDocuments() {
},
);
const { data, currentPage, totalPages } = await response.json();
const { data, pagination } = await response.json();
documents.push(...data);
hasMore = currentPage < totalPages;
hasMore = page < pagination.totalPages;
page++;
}
+87 -159
View File
@@ -19,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 |
@@ -38,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
}
}
```
@@ -135,10 +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 |
| Field | Type | Required | Description |
| ----------- | ------ | -------- | ------------------------------- |
| `documentId`| number | Yes | The document ID |
| `fields` | array | Yes | Array of field configurations |
### Code Examples
@@ -149,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": {
@@ -200,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: {
@@ -240,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>
@@ -251,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
}
]
}
@@ -332,8 +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 |
| `documentId` | number | Yes | The document ID |
| `fields` | array | Yes | Array of field update objects |
### Code Examples
@@ -344,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
}
]
}'
@@ -371,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>
@@ -390,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 }
]
}
````
@@ -515,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) │
│ │
@@ -529,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
};
@@ -715,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: {
@@ -735,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: {
@@ -749,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: {
@@ -763,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: {
@@ -782,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();
@@ -119,7 +119,7 @@ Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com).
| ------------------------------------------------- | ----------------------------------------------------- |
| `GET /api/v2/document` | `GET /api/v2/envelope` |
| `GET /api/v2/document/{documentId}` | `GET /api/v2/envelope/{envelopeId}` |
| `POST /api/v2/document/get-many` | `POST /api/v2/envelope/get-many` (body changes from `documentIds: number[]` to `ids: { type: "documentId"; ids: number[] }`) |
| `POST /api/v2/document/get-many` | `POST /api/v2/envelope/get-many` |
| `POST /api/v2/document/create` | `POST /api/v2/envelope/create` |
| `POST /api/v2/document/create/beta` | `POST /api/v2/envelope/create` |
| `POST /api/v2/document/update` | `POST /api/v2/envelope/update` |
@@ -140,7 +140,7 @@ Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com).
| ------------------------------------- | ------------------------------------------------ |
| `GET /api/v2/template` | `GET /api/v2/envelope` (with `type=TEMPLATE`) |
| `GET /api/v2/template/{templateId}` | `GET /api/v2/envelope/{envelopeId}` |
| `POST /api/v2/template/get-many` | `POST /api/v2/envelope/get-many` (body changes from `templateIds: number[]` to `ids: { type: "templateId"; ids: number[] }`) |
| `POST /api/v2/template/get-many` | `POST /api/v2/envelope/get-many` |
| `POST /api/v2/template/create` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
| `POST /api/v2/template/create/beta` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
| `POST /api/v2/template/update` | `POST /api/v2/envelope/update` |
@@ -11,12 +11,6 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
## HTTP Rate Limits
The rate limit applies to:
- `/api/v1/*`
- `/api/v2/*`
- `/api/v2-beta/*`
**Limit:** 1000 requests per minute per IP address
**Response:** 429 Too Many Requests
@@ -25,7 +19,7 @@ The rate limit applies to:
this value, in which case you can be rate-limited before reaching the global limit.
</Callout>
### Global per-IP 429 Response
### Rate Limit Response
```json
{
@@ -33,22 +27,10 @@ The rate limit applies to:
}
```
### Rate Limit Headers
Responses from `/api/v1/*`, `/api/v2/*`, and `/api/v2-beta/*` include these headers. The only
exception is CORS preflight (`OPTIONS`) requests, which are answered before the rate limiter runs
and carry no rate limit headers:
| Header | Description |
| ----------------------- | ---------------------------------------------------------------------- |
| `X-RateLimit-Limit` | Maximum requests allowed in the current global window |
| `X-RateLimit-Remaining` | Requests remaining in the current global window |
| `X-RateLimit-Reset` | End of the current global window, as a Unix epoch timestamp in seconds |
A 429 response from a windowed limiter also includes `Retry-After`, in seconds, with a minimum
value of `1`. The global API limit uses fixed, epoch-aligned one-minute buckets, so the actual wait
until the next window is between 1 and 60 seconds. Honor `Retry-After` exactly instead of sleeping
for a fixed 60 seconds. See the [Retry-After handling example](/docs/developers/examples/common-workflows#error-handling-patterns).
<Callout type="warn">
No rate limit headers are currently provided. When you receive a 429 response, wait at least 60
seconds before retrying.
</Callout>
## Resource Limits
@@ -62,55 +44,24 @@ Beyond HTTP rate limits, your account has usage limits based on your subscriptio
| Total Recipients | 10 | Unlimited | Unlimited | Unlimited |
| Direct Templates | 3 | Unlimited | Unlimited | Unlimited |
### Organisation Limit 429 Responses
### Error Response
Organisation windowed limits and organisation monthly quotas produce 429 responses whose body
shape depends on the API version, and neither matches the global per-IP limiter's
`{ "error": "..." }` body.
On `/api/v1/*`, the body contains only a message:
When you exceed a resource limit:
```json
{
"message": "Too many requests, please try again later. Contact support if you require higher limits."
"error": "You have reached your document limit for this month. Please upgrade your plan.",
"code": "LIMIT_EXCEEDED",
"statusCode": 400
}
```
On `/api/v2/*` and `/api/v2-beta/*`, the body is a structured error object:
```json
{
"message": "Too many requests, please try again later. Contact support if you require higher limits.",
"code": "TOO_MANY_REQUESTS",
"data": {
"code": "TOO_MANY_REQUESTS",
"httpStatus": 429,
"appError": {
"code": "TOO_MANY_REQUESTS",
"message": "Too many requests, please try again later. Contact support if you require higher limits."
}
}
}
```
Organisation windowed limit responses include the `X-RateLimit-*` headers and `Retry-After` for
their own window. Monthly quota responses carry no quota-specific rate limit headers or
`Retry-After` because the quota is not a time window; rely on the status code and message instead.
## Error Codes
| Code | Status | Description |
| ------------------- | ------ | ------------------------------------------------------------------ |
| `TOO_MANY_REQUESTS` | 429 | Global per-IP, organisation windowed, or monthly quota exceeded |
| `LIMIT_EXCEEDED` | 400 | Resource usage limit exceeded |
There are three sources of `TOO_MANY_REQUESTS` responses:
1. The global per-IP limit, returning the `{ "error": "..." }` body shown above.
2. Organisation windowed rate limits for the `api`, `document`, and `email` counters.
3. Organisation monthly quotas for the same three counters. Every authenticated API request
consumes the `api` counter, so any endpoint can return this 429 once the monthly API quota is
exhausted — not just envelope-related ones.
| Code | Status | Description |
| ------------------- | ------ | ----------------------------- |
| `TOO_MANY_REQUESTS` | 429 | HTTP rate limit exceeded |
| `LIMIT_EXCEEDED` | 400 | Resource usage limit exceeded |
---
@@ -1000,12 +1000,9 @@ async function fetchWithRetry(
// Retry on rate limit
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
// Honor Retry-After exactly; the cap only applies to the exponential fallback.
const delay = retryAfter
? parseInt(retryAfter) * 1000
: Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
const delay = retryAfter ? parseInt(retryAfter) * 1000 : baseDelayMs * Math.pow(2, attempt);
console.log(`Rate limited, waiting ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, delay));
await new Promise((resolve) => setTimeout(resolve, Math.min(delay, maxDelayMs)));
continue;
}
@@ -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 limited - wait for the duration in the `Retry-After` header |
| `429` | Rate limited - wait 60 seconds and retry |
| `500` | Server error - retry or contact support |
### Error Response Format
@@ -485,7 +485,7 @@ The API returns standard HTTP status codes and JSON error responses:
### Handling Rate Limits
The API allows 1000 requests per minute per IP address. Your organisation may have its own lower rate limits. Every response includes `X-RateLimit-Remaining` and `X-RateLimit-Reset` (an epoch timestamp in seconds). When you receive a `429` response, read the `Retry-After` header and wait for that many seconds before retrying. See [Error Handling Patterns](/docs/developers/examples/common-workflows#error-handling-patterns) for a more complete retry strategy.
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) {
@@ -493,9 +493,8 @@ async function fetchWithRetry(url, options, maxRetries = 3) {
const response = await fetch(url, options);
if (response.status === 429) {
const retryAfterSeconds = Number.parseInt(response.headers.get('Retry-After') ?? '1', 10);
console.log(`Rate limited, waiting ${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;
}
@@ -9,7 +9,7 @@ description: Receive real-time notifications for document and template events.
2. When an event occurs, Documenso sends an HTTP POST to your URL
3. Your application processes the event and responds with 200 OK
Documenso supports webhook events for the full document lifecycle (created, sent, opened, signed, completed, rejected, cancelled) as well as template events (created, updated, deleted, used).
Documenso supports webhook events for the full document lifecycle (created, sent, opened, signed, completed, rejected, cancelled), recipient-level events (recipient completed, reminder sent, recipient expired), and template events (created, updated, deleted, used).
---
@@ -304,6 +304,7 @@ Each webhook call shows the following details:
- Timestamp
- Response code
- Request and response bodies
- Response headers
Click any call to see full details including headers and response data.
</Step>
@@ -0,0 +1,377 @@
import {
createZipWriter,
sanitizeZipPathSegment,
type ZipFileEntry,
} from '@documenso/lib/client-only/create-zip-writer';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { fetchPDF } from '@documenso/lib/client-only/download-pdf';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { RadioGroupSegmented, RadioGroupSegmentedItem } from '@documenso/ui/primitives/radio-group';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { plural } from '@lingui/core/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useRef, useState } from 'react';
import { match } from 'ts-pattern';
/**
* The maximum number of documents that can be downloaded in a single bulk
* download. Each document requires fetching its full PDFs into the browser,
* so this bounds both request volume and blob storage usage. Matches the
* spirit of the server-side 100 cap on bulk move/delete/cancel.
*/
export const MAX_BULK_DOWNLOAD_ENVELOPES = 50;
type BulkDownloadVersion = 'signed' | 'original' | 'pending';
export type EnvelopeBulkDownloadItem = {
id: string;
title: string;
status: DocumentStatus;
/**
* Whether the envelope is a legacy (v1) envelope. Legacy envelopes use a
* different field-rendering pipeline that the partial PDF helper does not
* implement, so the Partial option is hidden for them.
*/
isLegacy: boolean;
};
const getDefaultVersion = (envelope: EnvelopeBulkDownloadItem): BulkDownloadVersion =>
envelope.status === DocumentStatus.COMPLETED ? 'signed' : 'original';
export type EnvelopesBulkDownloadDialogProps = {
envelopes: EnvelopeBulkDownloadItem[];
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: (successfulEnvelopeIds: string[]) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const EnvelopesBulkDownloadDialog = ({
envelopes,
open,
onOpenChange,
onSuccess,
...props
}: EnvelopesBulkDownloadDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [versionMap, setVersionMap] = useState<Record<string, BulkDownloadVersion>>({});
const [progress, setProgress] = useState(0);
const [isDownloading, setIsDownloading] = useState(false);
const abortRef = useRef(false);
const trpcUtils = trpc.useUtils();
const isOverDownloadLimit = envelopes.length > MAX_BULK_DOWNLOAD_ENVELOPES;
useEffect(() => {
if (!open) {
return;
}
setVersionMap(Object.fromEntries(envelopes.map((envelope) => [envelope.id, getDefaultVersion(envelope)])));
setProgress(0);
}, [open]);
const getDownloadVersion = (envelope: EnvelopeBulkDownloadItem): BulkDownloadVersion =>
versionMap[envelope.id] ?? getDefaultVersion(envelope);
/**
* The version options selectable for an envelope, mirroring the gating used
* by the single envelope download dialog:
* - COMPLETED: signed or original.
* - PENDING (non-legacy): partial or original. Legacy envelopes use a
* field-rendering pipeline the partial PDF helper does not implement.
* - Anything else: original only, so no choice is shown.
*/
const getVersionOptions = (
envelope: EnvelopeBulkDownloadItem,
): { value: BulkDownloadVersion; label: string }[] | null => {
if (envelope.status === DocumentStatus.COMPLETED) {
return [
{ value: 'signed', label: t({ message: 'Signed', context: 'Signed document (adjective)' }) },
{ value: 'original', label: t({ message: 'Original', context: 'Original document (adjective)' }) },
];
}
if (envelope.status === DocumentStatus.PENDING && !envelope.isLegacy) {
return [
{ value: 'pending', label: t({ message: 'Partial', context: 'Partially signed document (adjective)' }) },
{ value: 'original', label: t({ message: 'Original', context: 'Original document (adjective)' }) },
];
}
return null;
};
const getStatusLabel = (status: DocumentStatus) =>
match(status)
.with(DocumentStatus.COMPLETED, () => t`Completed`)
.with(DocumentStatus.PENDING, () => t`Pending`)
.with(DocumentStatus.DRAFT, () => t`Draft`)
.with(DocumentStatus.REJECTED, () => t`Rejected`)
.with(DocumentStatus.CANCELLED, () => t`Cancelled`)
.exhaustive();
const onDownload = async () => {
if (envelopes.length === 0 || isOverDownloadLimit || isDownloading) {
return;
}
abortRef.current = false;
setIsDownloading(true);
setProgress(0);
const zipWriter = createZipWriter();
const successfulEnvelopeIds: string[] = [];
let failedDownloads = 0;
try {
for (const envelope of envelopes) {
if (abortRef.current) {
break;
}
try {
const downloadVersion = getDownloadVersion(envelope);
const { data: envelopeItems } = await trpcUtils.envelope.item.getManyByToken.fetch({
envelopeId: envelope.id,
access: {
type: 'user',
},
});
// Each envelope's items are grouped in their own folder. The id
// prefix guarantees uniqueness, the truncated title keeps it
// readable without risking overly long extraction paths.
const folderName = sanitizeZipPathSegment(`${envelope.id}_${envelope.title}`.slice(0, 96));
// Buffer this envelope's files before writing so a failed envelope
// is either fully in the zip or not at all. Files from previous
// envelopes have already been written to the zip stream and freed.
const envelopeFiles: ZipFileEntry[] = [];
for (const envelopeItem of envelopeItems) {
const { filename, blob } = await fetchPDF({
envelopeItem,
token: undefined,
fileName: envelopeItem.title,
version: downloadVersion,
});
envelopeFiles.push({
filename: `${folderName}/${sanitizeZipPathSegment(filename)}`,
data: blob,
});
}
for (const file of envelopeFiles) {
await zipWriter.addFile(file);
}
successfulEnvelopeIds.push(envelope.id);
} catch (error) {
console.error(error);
failedDownloads++;
}
setProgress((p) => p + 1);
}
// The user intentionally stopped the download, discard anything fetched
// so far without toasting an error.
if (abortRef.current) {
zipWriter.abort();
return;
}
if (successfulEnvelopeIds.length === 0) {
zipWriter.abort();
toast({
title: t`Error`,
description: t`An error occurred while downloading the documents.`,
variant: 'destructive',
});
return;
}
try {
downloadFile({
filename: `documenso-documents-${new Date().toISOString().slice(0, 10)}.zip`,
data: zipWriter.finalize(),
});
} catch (error) {
console.error(error);
zipWriter.abort();
toast({
title: t`Error`,
description: t`An error occurred while downloading the documents.`,
variant: 'destructive',
});
return;
}
if (failedDownloads > 0) {
toast({
title: t`Documents partially downloaded`,
description: t`${plural(successfulEnvelopeIds.length, {
one: '# document downloaded.',
other: '# documents downloaded.',
})} ${plural(failedDownloads, {
one: '# document could not be downloaded.',
other: '# documents could not be downloaded.',
})}`,
variant: 'destructive',
});
onSuccess?.(successfulEnvelopeIds);
return;
}
toast({
title: t`Documents downloaded`,
description: plural(successfulEnvelopeIds.length, {
one: '# document has been downloaded.',
other: '# documents have been downloaded.',
}),
});
onSuccess?.(successfulEnvelopeIds);
onOpenChange(false);
} finally {
setIsDownloading(false);
}
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => {
if (!isDownloading) {
onOpenChange(value);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Download Documents</Trans>
</DialogTitle>
<DialogDescription>
<Plural
value={envelopes.length}
one="Select the version to download for the selected document."
other="Select the version to download for each of the # selected documents."
/>
</DialogDescription>
</DialogHeader>
{isOverDownloadLimit && (
<Alert variant="warning">
<AlertDescription>
<Trans>
You can download up to {MAX_BULK_DOWNLOAD_ENVELOPES} documents at a time. Deselect some documents to
continue.
</Trans>
</AlertDescription>
</Alert>
)}
<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) => {
const versionOptions = getVersionOptions(envelope);
return (
<div key={envelope.id} className="flex items-center gap-3 px-3 py-2.5">
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground text-sm" title={envelope.title}>
{envelope.title}
</p>
<p className="text-muted-foreground text-xs">{getStatusLabel(envelope.status)}</p>
</div>
{versionOptions && (
<RadioGroupSegmented
className="shrink-0"
value={getDownloadVersion(envelope)}
onValueChange={(value) =>
setVersionMap((prev) => ({
...prev,
[envelope.id]: value as BulkDownloadVersion,
}))
}
aria-label={t`Download version for ${envelope.title}`}
>
{versionOptions.map((option) => (
<RadioGroupSegmentedItem key={option.value} value={option.value}>
{option.label}
</RadioGroupSegmentedItem>
))}
</RadioGroupSegmented>
)}
</div>
);
})}
</div>
</div>
{isDownloading && (
<p className="text-muted-foreground text-sm">
<Trans>
Downloading {progress} / {envelopes.length}...
</Trans>
</p>
)}
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => {
if (isDownloading) {
abortRef.current = true;
} else {
onOpenChange(false);
}
}}
>
{isDownloading ? <Trans>Stop</Trans> : <Trans>Cancel</Trans>}
</Button>
<Button
type="button"
onClick={() => void onDownload()}
loading={isDownloading}
disabled={envelopes.length === 0 || isOverDownloadLimit}
>
<Trans>Download</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};
@@ -2,38 +2,24 @@ 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 { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router';
import { useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string }) => {
import { documentsSearchParams } from '~/utils/documents-search-params';
export const DocumentSearch = () => {
const { _ } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const [query, setQuery] = useQueryState('query', documentsSearchParams.query);
const [searchTerm, setSearchTerm] = useState(initialValue);
const [searchTerm, setSearchTerm] = useState(query ?? '');
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
const handleSearch = useCallback(
(term: string) => {
const params = new URLSearchParams(searchParams?.toString() ?? '');
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
setSearchParams(params);
},
[searchParams],
);
useEffect(() => {
const currentQueryParam = searchParams.get('query') || '';
if (debouncedSearchTerm !== currentQueryParam) {
handleSearch(debouncedSearchTerm);
if (debouncedSearchTerm !== (query ?? '')) {
void setQuery(debouncedSearchTerm || null);
}
}, [debouncedSearchTerm, searchParams]);
}, [debouncedSearchTerm, query, setQuery]);
return (
<Input
@@ -0,0 +1,186 @@
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { Separator } from '@documenso/ui/primitives/separator';
import { Trans } from '@lingui/react/macro';
import { CheckIcon, ChevronDownIcon } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import type { ReactNode } from 'react';
import { useState } from 'react';
export type FilterPillOption = {
value: string;
label: ReactNode;
trailing?: string;
};
type FilterPillCommonProps = {
icon: LucideIcon;
label: ReactNode;
options: FilterPillOption[];
enableSearch?: boolean;
searchPlaceholder?: string;
loading?: boolean;
testId?: string;
};
export type FilterPillSingleProps = FilterPillCommonProps & {
multiple?: false;
value: string | null;
onChange: (value: string | null) => void;
selectedLabel?: ReactNode;
};
export type FilterPillMultipleProps = FilterPillCommonProps & {
multiple: true;
value: string[];
onChange: (value: string[]) => void;
};
export type FilterPillProps = FilterPillSingleProps | FilterPillMultipleProps;
/**
* A faceted filter pill.
*
* Renders as a dashed "add a filter" pill at rest, and shows the current
* selection inline once a value is picked. Selecting the active option
* again (or the Clear row) removes it.
*
* Single select by default, closing on pick. When `multiple` is set the
* popover stays open for toggling, and the trigger shows the first two
* selections followed by a "+N more" chip.
*/
export const FilterPill = (props: FilterPillProps) => {
const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, testId } = props;
const [open, setOpen] = useState(false);
const selectedValues = props.multiple ? props.value : props.value === null ? [] : [props.value];
const selectedOptions = selectedValues
.map((value) => options.find((option) => option.value === value))
.filter((option): option is FilterPillOption => option !== undefined);
const hasSelection = selectedOptions.length > 0;
const extraCount = selectedOptions.length - 2;
const onSelect = (nextValue: string) => {
if (props.multiple) {
const newValues = selectedValues.includes(nextValue)
? selectedValues.filter((value) => value !== nextValue)
: [...selectedValues, nextValue];
props.onChange(newValues);
return;
}
props.onChange(nextValue === props.value ? null : nextValue);
setOpen(false);
};
const onClear = () => {
if (props.multiple) {
props.onChange([]);
} else {
props.onChange(null);
}
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
disabled={loading}
className={cn('border-dashed text-muted-foreground', {
'border-solid text-foreground': hasSelection,
})}
data-testid={testId}
>
<Icon className="mr-2 h-4 w-4" />
{label}
{hasSelection && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
{props.multiple ? (
<span className="flex items-center gap-x-1">
{selectedOptions.slice(0, 2).map((option) => (
<Badge key={option.value} variant="neutral" size="small">
{option.label}
</Badge>
))}
{extraCount > 0 && (
<Badge variant="neutral" size="small">
<Trans>+{extraCount} more</Trans>
</Badge>
)}
</span>
) : (
<span className="font-medium">{props.selectedLabel ?? selectedOptions[0].label}</span>
)}
</>
)}
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-52 p-0" align="start">
<Command>
{enableSearch && <CommandInput placeholder={searchPlaceholder} />}
<CommandList>
<CommandEmpty>
<Trans>No results found.</Trans>
</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem key={option.value} onSelect={() => onSelect(option.value)}>
<CheckIcon
className={cn(
'mr-2 h-4 w-4 shrink-0',
selectedValues.includes(option.value) ? 'opacity-100' : 'opacity-0',
)}
/>
{option.label}
{option.trailing !== undefined && (
<span className="ml-auto pl-4 text-muted-foreground text-xs">{option.trailing}</span>
)}
</CommandItem>
))}
</CommandGroup>
{hasSelection && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem className="justify-center text-center text-muted-foreground" onSelect={onClear}>
<Trans>Clear</Trans>
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
@@ -0,0 +1,40 @@
import { Trans } from '@lingui/react/macro';
import { CalendarIcon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { FilterPill } from '~/components/general/filter-pill';
import { DOCUMENTS_PERIOD_VALUES, documentsSearchParams } from '~/utils/documents-search-params';
const PERIOD_OPTIONS = [
{ value: '7d', label: <Trans>Last 7 days</Trans> },
{ value: '14d', label: <Trans>Last 14 days</Trans> },
{ value: '30d', label: <Trans>Last 30 days</Trans> },
];
export const DocumentsTablePeriodFilter = () => {
const [{ period }, setSearchParams] = useQueryStates(
{
period: documentsSearchParams.period,
page: documentsSearchParams.page,
},
{ history: 'push' },
);
const onChange = (newPeriod: string | null) => {
void setSearchParams({
period: DOCUMENTS_PERIOD_VALUES.find((value) => value === newPeriod) ?? null,
page: null,
});
};
return (
<FilterPill
icon={CalendarIcon}
label={<Trans>Period</Trans>}
value={period}
onChange={onChange}
options={PERIOD_OPTIONS}
testId="documents-table-period-filter"
/>
);
};
@@ -1,63 +1,61 @@
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { trpc } from '@documenso/trpc/react';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useLocation, useNavigate, useSearchParams } from 'react-router';
import { UserIcon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { FilterPill } from '~/components/general/filter-pill';
import { documentsSearchParams } from '~/utils/documents-search-params';
type DocumentsTableSenderFilterProps = {
teamId: number;
};
export const DocumentsTableSenderFilter = ({ teamId }: DocumentsTableSenderFilterProps) => {
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { _ } = useLingui();
const isMounted = useIsMounted();
const senderIds = (searchParams?.get('senderIds') ?? '').split(',').filter((value) => value !== '');
const [{ senderIds }, setSearchParams] = useQueryStates(
{
senderIds: documentsSearchParams.senderIds,
page: documentsSearchParams.page,
},
{ history: 'push' },
);
const selectedSenderIds = (senderIds ?? []).map((senderId) => senderId.toString());
const { data, isLoading } = trpc.team.member.getMany.useQuery({
teamId,
});
const comboBoxOptions = (data ?? []).map((member) => ({
const options = (data ?? []).map((member) => ({
label: member.name ?? member.email,
value: member.userId.toString(),
}));
const onChange = (newSenderIds: string[]) => {
if (!pathname) {
return;
}
const params = new URLSearchParams(searchParams?.toString());
params.set('senderIds', newSenderIds.join(','));
if (newSenderIds.length === 0) {
params.delete('senderIds');
}
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
void setSearchParams({
senderIds: newSenderIds.length > 0 ? newSenderIds.map(Number) : null,
page: null,
});
};
return (
<MultiSelectCombobox
emptySelectionPlaceholder={
<p className="font-normal text-muted-foreground">
<Trans>
<span className="text-muted-foreground/70">Sender:</span> All
</Trans>
</p>
}
enableClearAllButton={true}
inputPlaceholder={msg`Search`}
loading={!isMounted || isLoading}
options={comboBoxOptions}
selectedValues={senderIds}
<FilterPill
multiple
icon={UserIcon}
label={<Trans>Sender</Trans>}
value={selectedSenderIds}
onChange={onChange}
options={options}
enableSearch
searchPlaceholder={_(msg`Search members...`)}
loading={!isMounted || isLoading}
testId="documents-table-sender-filter"
/>
);
};
@@ -0,0 +1,98 @@
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';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { OrganisationType } from '@prisma/client';
import { ListFilterIcon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { useMemo } from 'react';
import { DocumentStatus, FRIENDLY_STATUS_MAP } from '~/components/general/document/document-status';
import { FilterPill } from '~/components/general/filter-pill';
import { documentsSearchParams } from '~/utils/documents-search-params';
type DocumentsTableStatusFilterProps = {
stats: TFindDocumentsInternalResponse['stats'];
};
export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilterProps) => {
const { _ } = useLingui();
const organisation = useCurrentOrganisation();
const [{ status }, setSearchParams] = useQueryStates(
{
status: documentsSearchParams.status,
page: documentsSearchParams.page,
},
{ history: 'push' },
);
const selectableStatuses = useMemo(
() =>
SELECTABLE_STATUSES.filter((value) => {
if (organisation.type === OrganisationType.PERSONAL) {
return value !== ExtendedDocumentStatus.INBOX;
}
return true;
}),
[organisation.type],
);
const selectedStatus = useMemo(
() => selectableStatuses.find((value) => value === status) ?? null,
[selectableStatuses, status],
);
const onChange = (newStatus: string | null) => {
void setSearchParams({
status: selectableStatuses.find((value) => value === newStatus) ?? null,
page: null,
});
};
return (
<>
<FilterPill
icon={ListFilterIcon}
label={<Trans>Status</Trans>}
value={selectedStatus}
onChange={onChange}
selectedLabel={selectedStatus && <DocumentStatus status={selectedStatus} className="[&>svg]:mr-1.5" />}
options={selectableStatuses.map((value) => ({
value,
label: <DocumentStatus status={value} />,
trailing: formatStatsCount(stats[value]),
}))}
testId="documents-table-status-filter"
/>
{/* Visually hidden document counts, for screen readers and tests. */}
<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>
</>
);
};
const SELECTABLE_STATUSES: ExtendedDocumentStatus[] = [
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.CANCELLED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.REJECTED,
ExtendedDocumentStatus.EXPIRED,
];
const formatStatsCount = (count: number) => {
return count >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : count.toString();
};
@@ -1,9 +1,11 @@
import { Button } from '@documenso/ui/primitives/button';
import { Trans, useLingui } from '@lingui/react/macro';
import { FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
import { DownloadIcon, FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
import { useEffect } from 'react';
export type EnvelopesTableBulkActionBarProps = {
selectedCount: number;
onDownloadClick?: () => void;
onMoveClick: () => void;
onDeleteClick: () => void;
onCancelClick?: () => void;
@@ -12,6 +14,7 @@ export type EnvelopesTableBulkActionBarProps = {
export const EnvelopesTableBulkActionBar = ({
selectedCount,
onDownloadClick,
onMoveClick,
onDeleteClick,
onCancelClick,
@@ -19,37 +22,106 @@ export const EnvelopesTableBulkActionBar = ({
}: EnvelopesTableBulkActionBarProps) => {
const { t } = useLingui();
useEffect(() => {
if (selectedCount === 0) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
// Radix dismissable layers (dialogs, dropdowns, etc) call preventDefault
// when handling Escape, so this only clears the selection when nothing
// else consumed the key press.
if (event.key === 'Escape' && !event.defaultPrevented) {
onClearSelection();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [selectedCount, onClearSelection]);
if (selectedCount === 0) {
return null;
}
return (
<div className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-4 rounded-lg border border-border bg-background px-4 py-3 shadow-lg">
<span className="font-medium text-sm">
<Trans>{selectedCount} selected</Trans>
</span>
<div className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-1 rounded-xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-black/10 dark:ring-white/10">
<div className="flex items-center gap-x-2 px-2">
<span className="sr-only" aria-live="polite">
<Trans>{selectedCount} selected</Trans>
</span>
<span
aria-hidden="true"
className="flex h-5 min-w-5 items-center justify-center rounded-md bg-primary px-1 font-semibold text-primary-foreground text-xs tabular-nums"
>
{selectedCount}
</span>
<span aria-hidden="true" className="font-medium text-foreground text-sm max-[420px]:hidden">
<Trans>selected</Trans>
</span>
</div>
<div className="h-6 w-px bg-border" />
<div className="mx-1 h-5 w-px bg-border" />
<Button type="button" variant="outline" size="sm" onClick={onMoveClick}>
<FolderInputIcon className="mr-2 h-4 w-4" />
<Trans>Move to Folder</Trans>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onMoveClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
>
<FolderInputIcon className="size-4 shrink-0" />
<Trans>Move</Trans>
</Button>
{onDownloadClick && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onDownloadClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
>
<DownloadIcon className="size-4 shrink-0" />
<Trans>Download</Trans>
</Button>
)}
{onCancelClick && (
<Button type="button" variant="outline" size="sm" onClick={onCancelClick}>
<XCircleIcon className="mr-2 h-4 w-4" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={onCancelClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
>
<XCircleIcon className="size-4 shrink-0" />
<Trans>Cancel</Trans>
</Button>
)}
<Button type="button" variant="destructive" size="sm" onClick={onDeleteClick}>
<Trash2Icon className="mr-2 h-4 w-4" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={onDeleteClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2 text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2Icon className="size-4 shrink-0" />
<Trans>Delete</Trans>
</Button>
<Button variant="ghost" size="sm" onClick={onClearSelection} aria-label={t`Clear selection`}>
<XIcon className="h-4 w-4" />
<div className="mx-1 h-5 w-px bg-border" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClearSelection}
aria-label={t`Clear selection`}
className="h-8 w-8 p-0"
>
<XIcon className="size-4 shrink-0" />
</Button>
</div>
);
@@ -0,0 +1,39 @@
import { Trans } from '@lingui/react/macro';
import { Building2Icon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { FilterPill } from '~/components/general/filter-pill';
import { TEMPLATES_VIEW_VALUES, templatesSearchParams } from '~/utils/templates-search-params';
const VIEW_OPTIONS = [
{ value: 'team', label: <Trans>Team</Trans> },
{ value: 'organisation', label: <Trans>Organisation</Trans> },
];
export const TemplatesTableViewFilter = () => {
const [{ view }, setSearchParams] = useQueryStates(
{
view: templatesSearchParams.view,
page: templatesSearchParams.page,
},
{ history: 'push' },
);
const onChange = (newView: string | null) => {
void setSearchParams({
view: TEMPLATES_VIEW_VALUES.find((value) => value === newView) ?? null,
page: null,
});
};
return (
<FilterPill
icon={Building2Icon}
label={<Trans>View</Trans>}
value={view}
onChange={onChange}
options={VIEW_OPTIONS}
testId="templates-table-view-filter"
/>
);
};
@@ -1,59 +1,57 @@
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
import { SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { trpc } from '@documenso/trpc/react';
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/find-documents-internal.types';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType, FolderType, OrganisationType } from '@prisma/client';
import { EnvelopeType, FolderType, type DocumentStatus as PrismaDocumentStatus } from '@prisma/client';
import { XIcon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
import { z } from 'zod';
import { useNavigate, useParams } from 'react-router';
import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-dialog';
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
import {
type EnvelopeBulkDownloadItem,
EnvelopesBulkDownloadDialog,
} from '~/components/dialogs/envelopes-bulk-download-dialog';
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
import { DocumentSearch } from '~/components/general/document/document-search';
import { DocumentStatus } from '~/components/general/document/document-status';
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
import { FolderGrid } from '~/components/general/folder/folder-grid';
import { PeriodSelector } from '~/components/general/period-selector';
import { DocumentsTable } from '~/components/tables/documents-table';
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
import { DocumentsTablePeriodFilter } from '~/components/tables/documents-table-period-filter';
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
import { DocumentsTableStatusFilter } from '~/components/tables/documents-table-status-filter';
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
import { useCurrentTeam } from '~/providers/team';
import { documentsSearchParams } from '~/utils/documents-search-params';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags(msg`Documents`);
}
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
status: true,
period: true,
page: true,
perPage: true,
query: true,
}).extend({
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
});
type EnvelopeMetaCache = Record<string, { title: string; status: PrismaDocumentStatus; isLegacy: boolean }>;
// Stable initial values: `useSessionStorage` keeps its setter identity stable
// only while the initial value reference is stable, and the metadata cache
// effect below depends on that setter.
const EMPTY_ROW_SELECTION: RowSelectionState = {};
const EMPTY_ENVELOPE_META_CACHE: EnvelopeMetaCache = {};
export default function DocumentsPage() {
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
const { folderId } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const documentsPath = formatDocumentsPath(team.url);
@@ -61,9 +59,18 @@ export default function DocumentsPage() {
const [isMovingDocument, setIsMovingDocument] = useState(false);
const [documentToMove, setDocumentToMove] = useState<string | null>(null);
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('documents-bulk-selection', {});
// Scoped by team so selections made in one team never leak into another.
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
`documents-bulk-selection-${team.id}`,
EMPTY_ROW_SELECTION,
);
const [envelopeMetaCache, setEnvelopeMetaCache] = useSessionStorage<EnvelopeMetaCache>(
`documents-bulk-selection-meta-${team.id}`,
EMPTY_ENVELOPE_META_CACHE,
);
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [isBulkDownloadDialogOpen, setIsBulkDownloadDialogOpen] = useState(false);
const [isBulkCancelDialogOpen, setIsBulkCancelDialogOpen] = useState(false);
const selectedEnvelopeIds = useMemo(() => {
@@ -81,14 +88,18 @@ export default function DocumentsPage() {
[ExtendedDocumentStatus.ALL]: 0,
});
const findDocumentSearchParams = useMemo(
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
[searchParams],
);
const [findDocumentSearchParams, setFindDocumentSearchParams] = useQueryStates(documentsSearchParams, {
history: 'push',
});
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery(
{
...findDocumentSearchParams,
status: findDocumentSearchParams.status ?? undefined,
period: findDocumentSearchParams.period ?? undefined,
senderIds: findDocumentSearchParams.senderIds ?? undefined,
page: findDocumentSearchParams.page ?? undefined,
perPage: findDocumentSearchParams.perPage ?? undefined,
query: findDocumentSearchParams.query ?? undefined,
folderId,
},
{
@@ -96,34 +107,66 @@ export default function DocumentsPage() {
},
);
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
const params = new URLSearchParams(searchParams);
useEffect(() => {
setEnvelopeMetaCache((prev) => {
const next: EnvelopeMetaCache = {};
params.set('status', value);
for (const id of Object.keys(prev)) {
if (rowSelection[id]) {
next[id] = prev[id];
}
}
if (value === ExtendedDocumentStatus.ALL) {
params.delete('status');
}
for (const document of data?.data ?? []) {
if (rowSelection[document.envelopeId]) {
next[document.envelopeId] = {
title: document.title,
status: document.status,
isLegacy: document.internalVersion === 1,
};
}
}
if (value === ExtendedDocumentStatus.INBOX && organisation.type === OrganisationType.PERSONAL) {
params.delete('status');
}
return next;
});
}, [data?.data, rowSelection, setEnvelopeMetaCache]);
if (params.has('page')) {
params.delete('page');
}
const selectedEnvelopesForDownload = useMemo(() => {
return selectedEnvelopeIds
.map((id): EnvelopeBulkDownloadItem | null => {
const meta = envelopeMetaCache[id];
let path = formatDocumentsPath(team.url);
if (!meta) {
return null;
}
if (folderId) {
path += `/f/${folderId}`;
}
return {
id,
title: meta.title,
status: meta.status,
// Stale cache entries predating this field are treated as legacy so
// the Partial option is never offered without certainty.
isLegacy: meta.isLegacy ?? true,
};
})
.filter((item): item is EnvelopeBulkDownloadItem => item !== null);
}, [selectedEnvelopeIds, envelopeMetaCache]);
if (params.toString()) {
path += `?${params.toString()}`;
}
const hasActiveFilters = useMemo(() => {
return Boolean(
(findDocumentSearchParams.status && findDocumentSearchParams.status !== ExtendedDocumentStatus.ALL) ||
findDocumentSearchParams.senderIds?.length ||
findDocumentSearchParams.period,
);
}, [findDocumentSearchParams]);
return path;
const onResetFilters = () => {
void setFindDocumentSearchParams({
status: null,
senderIds: null,
period: null,
page: null,
});
};
useEffect(() => {
@@ -137,69 +180,40 @@ export default function DocumentsPage() {
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<FolderGrid type={FolderType.DOCUMENT} parentId={folderId ?? null} />
<div className="mt-8 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
<div className="flex flex-row items-center">
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
</Avatar>
<div className="mt-8 flex flex-row items-center">
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
</Avatar>
<h2 className="font-semibold text-4xl">
<Trans>Documents</Trans>
</h2>
<h2 className="font-semibold text-4xl">
<Trans>Documents</Trans>
</h2>
</div>
<div className="mt-8 flex flex-wrap items-center gap-x-2 gap-y-4">
<div className="w-56">
<DocumentSearch />
</div>
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
<TabsList>
{[
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.CANCELLED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.REJECTED,
ExtendedDocumentStatus.EXPIRED,
ExtendedDocumentStatus.ALL,
]
.filter((value) => {
if (organisation.type === OrganisationType.PERSONAL) {
return value !== ExtendedDocumentStatus.INBOX;
}
<DocumentsTableStatusFilter stats={stats} />
return true;
})
.map((value) => (
<TabsTrigger key={value} className="min-w-[60px] hover:text-foreground" value={value} asChild>
<Link to={getTabHref(value)} preventScrollReset>
<DocumentStatus status={value} />
{team && <DocumentsTableSenderFilter teamId={team.id} />}
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 inline-block opacity-50">
{stats[value] >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : stats[value]}
</span>
)}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
<DocumentsTablePeriodFilter />
{team && <DocumentsTableSenderFilter teamId={team.id} />}
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
</div>
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<DocumentSearch initialValue={findDocumentSearchParams.query} />
</div>
</div>
{hasActiveFilters && (
<Button variant="ghost" className="px-2 text-muted-foreground lg:px-3" onClick={onResetFilters}>
<Trans>Reset</Trans>
<XIcon className="ml-1 h-4 w-4" />
</Button>
)}
</div>
<div className="mt-8">
<div>
{data && data.count === 0 ? (
<DocumentsTableEmptyState status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL} />
<DocumentsTableEmptyState status={findDocumentSearchParams.status ?? ExtendedDocumentStatus.ALL} />
) : (
<DocumentsTable
data={data}
@@ -238,12 +252,28 @@ export default function DocumentsPage() {
<EnvelopesTableBulkActionBar
selectedCount={selectedEnvelopeIds.length}
onDownloadClick={() => setIsBulkDownloadDialogOpen(true)}
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
onCancelClick={() => setIsBulkCancelDialogOpen(true)}
onClearSelection={() => setRowSelection({})}
/>
<EnvelopesBulkDownloadDialog
envelopes={selectedEnvelopesForDownload}
open={isBulkDownloadDialogOpen}
onOpenChange={setIsBulkDownloadDialogOpen}
onSuccess={(successfulEnvelopeIds) => {
setRowSelection((prev) => {
const next = { ...prev };
for (const id of successfulEnvelopeIds) {
delete next[id];
}
return next;
});
}}
/>
<EnvelopesBulkMoveDialog
envelopeIds={selectedEnvelopeIds}
envelopeType={EnvelopeType.DOCUMENT}
@@ -6,14 +6,13 @@ import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/t
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType, OrganisationType } from '@prisma/client';
import { Bird } from 'lucide-react';
import { parseAsStringLiteral, useQueryState } from 'nuqs';
import { useQueryStates } from 'nuqs';
import { useMemo, useState } from 'react';
import { useParams, useSearchParams } from 'react-router';
import { useParams } from 'react-router';
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
@@ -21,33 +20,36 @@ import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-
import { FolderGrid } from '~/components/general/folder/folder-grid';
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
import { TemplatesTable } from '~/components/tables/templates-table';
import { TemplatesTableViewFilter } from '~/components/tables/templates-table-view-filter';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
const TEMPLATE_VIEWS = ['team', 'organisation'] as const;
type TemplateView = (typeof TEMPLATE_VIEWS)[number];
import { templatesSearchParams } from '~/utils/templates-search-params';
export function meta() {
return appMetaTags(msg`Templates`);
}
// Stable initial value: `useSessionStorage` keeps its setter identity stable
// only while the initial value reference is stable.
const EMPTY_ROW_SELECTION: RowSelectionState = {};
export default function TemplatesPage() {
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
const { folderId } = useParams();
const [searchParams] = useSearchParams();
const [findTemplateSearchParams] = useQueryStates(templatesSearchParams, {
history: 'push',
});
const page = Number(searchParams.get('page')) || 1;
const perPage = Number(searchParams.get('perPage')) || 10;
const isOrgView = findTemplateSearchParams.view === 'organisation';
const showOrgFilter = organisation.type !== OrganisationType.PERSONAL;
const [view, setView] = useQueryState('view', parseAsStringLiteral(TEMPLATE_VIEWS).withDefault('team'));
const isOrgView = view === 'organisation';
const showOrgTab = organisation.type !== OrganisationType.PERSONAL;
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('templates-bulk-selection', {});
// Scoped by team so selections made in one team never leak into another.
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
`templates-bulk-selection-${team.id}`,
EMPTY_ROW_SELECTION,
);
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
@@ -60,8 +62,8 @@ export default function TemplatesPage() {
const teamTemplatesQuery = trpc.template.findTemplates.useQuery(
{
page,
perPage,
page: findTemplateSearchParams.page ?? undefined,
perPage: findTemplateSearchParams.perPage ?? undefined,
folderId,
},
{
@@ -71,8 +73,8 @@ export default function TemplatesPage() {
const orgTemplatesQuery = trpc.template.findOrganisationTemplates.useQuery(
{
page,
perPage,
page: findTemplateSearchParams.page ?? undefined,
perPage: findTemplateSearchParams.perPage ?? undefined,
},
{
enabled: isOrgView,
@@ -81,14 +83,6 @@ export default function TemplatesPage() {
const activeQuery = isOrgView ? orgTemplatesQuery : teamTemplatesQuery;
const handleViewChange = (newView: string) => {
if (newView !== 'team' && newView !== 'organisation') {
return;
}
void setView(newView === 'team' ? null : newView);
};
return (
<EnvelopeDropZoneWrapper type={EnvelopeType.TEMPLATE}>
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
@@ -106,26 +100,9 @@ export default function TemplatesPage() {
</h1>
</div>
{showOrgTab && (
<div className="mt-6">
<Tabs value={view} onValueChange={handleViewChange} data-testid="template-view-tabs">
<TabsList>
<TabsTrigger
className="min-w-[60px] hover:text-foreground"
value="team"
data-testid="template-tab-team"
>
<Trans>Team</Trans>
</TabsTrigger>
<TabsTrigger
className="min-w-[60px] hover:text-foreground"
value="organisation"
data-testid="template-tab-organisation"
>
<Trans>Organisation</Trans>
</TabsTrigger>
</TabsList>
</Tabs>
{showOrgFilter && (
<div className="mt-6 flex flex-wrap items-center gap-x-2 gap-y-4">
<TemplatesTableViewFilter />
</div>
)}
@@ -0,0 +1,19 @@
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { parseAsArrayOf, parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs';
export const DOCUMENTS_PERIOD_VALUES = ['7d', '14d', '30d'] as const;
/**
* Shared nuqs parsers for the documents page URL state.
*
* Used by the documents page and its filter components so every consumer
* parses and serialises the params identically.
*/
export const documentsSearchParams = {
status: parseAsStringLiteral(Object.values(ExtendedDocumentStatus)),
period: parseAsStringLiteral(DOCUMENTS_PERIOD_VALUES),
senderIds: parseAsArrayOf(parseAsInteger),
page: parseAsInteger,
perPage: parseAsInteger,
query: parseAsString,
};
@@ -0,0 +1,15 @@
import { parseAsInteger, parseAsStringLiteral } from 'nuqs';
export const TEMPLATES_VIEW_VALUES = ['team', 'organisation'] as const;
/**
* Shared nuqs parsers for the templates page URL state.
*
* Used by the templates page and its filter components so every consumer
* parses and serialises the params identically.
*/
export const templatesSearchParams = {
view: parseAsStringLiteral(TEMPLATES_VIEW_VALUES),
page: parseAsInteger,
perPage: parseAsInteger,
};
+10 -3
View File
@@ -22,6 +22,7 @@
"@prisma/extension-read-replicas": "^0.4.1",
"ai": "^5.0.104",
"cron-parser": "^5.5.0",
"fflate": "^0.8.3",
"luxon": "^3.7.2",
"patch-package": "^8.0.1",
"posthog-node": "4.18.0",
@@ -20080,9 +20081,9 @@
}
},
"node_modules/fflate": {
"version": "0.4.8",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
"integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==",
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-selector": {
@@ -26685,6 +26686,12 @@
"web-vitals": "^4.2.4"
}
},
"node_modules/posthog-js/node_modules/fflate": {
"version": "0.4.9",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz",
"integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==",
"license": "MIT"
},
"node_modules/posthog-node": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-4.18.0.tgz",
+1
View File
@@ -94,6 +94,7 @@
"@prisma/extension-read-replicas": "^0.4.1",
"ai": "^5.0.104",
"cron-parser": "^5.5.0",
"fflate": "^0.8.3",
"luxon": "^3.7.2",
"patch-package": "^8.0.1",
"posthog-node": "4.18.0",
@@ -0,0 +1,118 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
const downloadUrl = (envelopeId: string, envelopeItemId: string, version: 'original' | 'signed' | 'pending') =>
`${WEBAPP_BASE_URL}/api/files/envelope/${envelopeId}/envelopeItem/${envelopeItemId}/download/${version}`;
const seedOwnerWithDraft = async () => {
const owner = await seedUser();
const draft = await seedDraftDocument(owner.user, owner.team.id, [], {
createDocumentOptions: { title: 'File Download Auth Test' },
});
return { owner, draft, draftItem: draft.envelopeItems[0] };
};
test.describe('Envelope item file download endpoint authorization', () => {
test('rejects an unauthenticated download request', async ({ request }) => {
const { draft, draftItem } = await seedOwnerWithDraft();
const res = await request.get(downloadUrl(draft.id, draftItem.id, 'original'));
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a download request from a user outside the organisation', async ({ page }) => {
const { draft, draftItem } = await seedOwnerWithDraft();
const { user: outsider } = await seedUser();
await apiSignin({ page, email: outsider.email });
const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'original'));
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(403);
});
test('returns 404 for a nonexistent envelope', async ({ page }) => {
const { user } = await seedUser();
await apiSignin({ page, email: user.email });
const res = await page.request.get(
downloadUrl('envelope_does_not_exist', 'envelope_item_does_not_exist', 'original'),
);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('rejects a pending version download for a draft envelope', async ({ page }) => {
const { owner, draft, draftItem } = await seedOwnerWithDraft();
await apiSignin({ page, email: owner.user.email });
const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'pending'));
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('rejects a pending version download for a legacy envelope', async ({ page }) => {
const owner = await seedUser();
const { user: recipient } = await seedUser();
// Default internalVersion is 1 (legacy).
const pendingDocument = await seedPendingDocument(owner.user, owner.team.id, [recipient], {
createDocumentOptions: { title: 'Legacy Pending Download Test' },
});
const envelopeItem = pendingDocument.envelopeItems[0];
await apiSignin({ page, email: owner.user.email });
const res = await page.request.get(downloadUrl(pendingDocument.id, envelopeItem.id, 'pending'));
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('allows the owner to download their own document', async ({ page }) => {
const { owner, draft, draftItem } = await seedOwnerWithDraft();
await apiSignin({ page, email: owner.user.email });
const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'original'));
expect(res.ok()).toBeTruthy();
expect(res.headers()['content-type']).toContain('application/pdf');
const body = await res.body();
// %PDF magic bytes.
expect(Array.from(body.subarray(0, 4))).toEqual([0x25, 0x50, 0x44, 0x46]);
});
test('rejects a recipient-token download with an invalid token', async ({ request }) => {
const { draftItem } = await seedOwnerWithDraft();
const res = await request.get(
`${WEBAPP_BASE_URL}/api/files/token/invalid-token-12345/envelopeItem/${draftItem.id}/download/original`,
);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
});
@@ -1,3 +1,5 @@
import fs from 'node:fs';
import { createTeam } from '@documenso/lib/server-only/team/create-team';
import { prisma } from '@documenso/prisma';
import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
@@ -5,6 +7,7 @@ import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { unzipSync } from 'fflate';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { expectToastTextToBeVisible } from '../fixtures/generic';
@@ -50,10 +53,10 @@ test('[BULK_ACTIONS]: can select multiple documents with checkboxes', async ({ p
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
await expect(page.getByText('2 selected')).toBeVisible();
await expect(page.getByText(/2\s*selected/)).toBeVisible();
});
test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ page }) => {
@@ -67,7 +70,7 @@ test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ p
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(`${documents.length} selected`)).toBeVisible();
await expect(page.getByText(new RegExp(`${documents.length}\\s*selected`))).toBeVisible();
});
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
@@ -80,11 +83,11 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
});
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(/\d+ selected/)).toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).toBeVisible();
await page.getByLabel('Clear selection').click();
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page }) => {
@@ -98,13 +101,13 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page })
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('Move Documents to Folder')).toBeVisible();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
@@ -113,6 +116,122 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page })
await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).toBeVisible();
});
test('[BULK_ACTIONS]: selection does not leak between teams', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
const teamBUrl = `team-b-${Date.now()}`;
await createTeam({
userId: sender.user.id,
teamName: 'Team B',
teamUrl: teamBUrl,
organisationId: sender.organisation.id,
inheritMembers: true,
});
const teamB = await prisma.team.findFirstOrThrow({
where: { url: teamBUrl },
});
await seedDraftDocument(sender.user, teamB.id, [], {
createDocumentOptions: { title: 'Team B Doc' },
});
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
// The selection made in team A must not appear in team B.
await page.goto(`/t/${teamBUrl}/documents`);
await expect(page.getByRole('link', { name: 'Team B Doc' })).toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
// Returning to team A restores its selection.
await page.goto(`/t/${sender.team.url}/documents`);
await expect(page.getByText(/1\s*selected/)).toBeVisible();
});
test('[BULK_ACTIONS]: escape clears selection unless a dialog is open', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
// Escape while a dialog is open should close the dialog but keep the selection.
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
// Escape with no dialog open should clear the selection.
await page.keyboard.press('Escape');
await expect(page.getByText(/1\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can bulk download multiple documents as a zip', async ({ page }) => {
const { sender, documents } = await seedBulkActionsTestRequirements();
const [doc1, doc2] = documents;
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Download', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByText('Download Documents')).toBeVisible();
await expect(dialog.getByText('Bulk Test Doc 1')).toBeVisible();
await expect(dialog.getByText('Bulk Test Doc 2')).toBeVisible();
await expect(dialog.getByText('Draft').first()).toBeVisible();
const downloadPromise = page.waitForEvent('download', { timeout: 10_000 });
await dialog.getByRole('button', { name: 'Download' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^documenso-documents-\d{4}-\d{2}-\d{2}\.zip$/);
const downloadPath = await download.path();
const zipContents = unzipSync(new Uint8Array(fs.readFileSync(downloadPath)));
// Each envelope's files are nested inside an `envelopeId_title` folder.
expect(Object.keys(zipContents).sort()).toEqual(
[`${doc1.id}_Bulk Test Doc 1/Bulk Test Doc 1.pdf`, `${doc2.id}_Bulk Test Doc 2/Bulk Test Doc 2.pdf`].sort(),
);
// Each entry should be a valid non-empty PDF (%PDF magic bytes).
for (const entry of Object.values(zipContents)) {
expect(Array.from(entry.slice(0, 4))).toEqual([0x25, 0x50, 0x44, 0x46]);
}
await expectToastTextToBeVisible(page, 'Documents downloaded');
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can delete multiple draft documents', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
@@ -152,14 +271,14 @@ test('[BULK_ACTIONS]: selection clears after successful move', async ({ page })
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
@@ -172,13 +291,13 @@ test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
await expectToastTextToBeVisible(page, 'Documents deleted');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
@@ -199,7 +318,7 @@ test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) =
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
@@ -236,14 +355,14 @@ test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ p
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
@@ -7,7 +7,7 @@ import { expect, type Page, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
import { checkDocumentCounts, selectDocumentStatusFilter } from '../fixtures/documents';
import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
test.describe.configure({ mode: 'serial' });
@@ -61,13 +61,10 @@ test('[DOCUMENTS]: cancelling a pending document keeps it in the owner dashboard
await expectToastTextToBeVisible(page, 'Document cancelled');
// The document must remain in the dashboard, unlike deleting a pending document.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Cancelled', 1);
await checkDocumentTabCount(page, 'All', 1);
await checkDocumentCounts(page, { inbox: 0, pending: 0, cancelled: 1, all: 1 });
// The cancelled document is still listed.
await page.getByRole('tab', { name: 'Cancelled' }).click();
await selectDocumentStatusFilter(page, 'Cancelled');
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
// The envelope status is persisted as CANCELLED.
@@ -131,7 +128,7 @@ test('[DOCUMENTS]: a cancelled document can be deleted, hiding it from the owner
await expectToastTextToBeVisible(page, 'Document cancelled');
// Delete the now-cancelled document. Being terminal, it should soft delete (hide).
await page.getByRole('tab', { name: 'Cancelled' }).click();
await selectDocumentStatusFilter(page, 'Cancelled');
const documentActionBtn = page
.locator('tr', { hasText: 'Document 1 - Pending' })
@@ -3,7 +3,7 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
import { checkDocumentCounts } from '../fixtures/documents';
import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
test.describe.configure({ mode: 'serial' });
@@ -174,11 +174,7 @@ test('[DOCUMENTS]: deleting draft documents should permanently remove it', async
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 2);
await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 1, draft: 0, all: 2 });
});
test('[DOCUMENTS]: deleting pending documents should permanently remove it', async ({ page }) => {
@@ -207,11 +203,7 @@ test('[DOCUMENTS]: deleting pending documents should permanently remove it', asy
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 2);
await checkDocumentCounts(page, { inbox: 0, pending: 0, completed: 1, draft: 1, all: 2 });
});
test('[DOCUMENTS]: deleting completed documents as an owner should hide it from only the owner', async ({ page }) => {
@@ -239,11 +231,7 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from
// Check document counts.
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 2);
await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 0, draft: 1, all: 2 });
// Sign into the recipient account.
await apiSignout({ page });
@@ -255,11 +243,7 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from
// Check document counts.
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).toBeVisible();
await checkDocumentTabCount(page, 'Inbox', 1);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 2);
await checkDocumentCounts(page, { inbox: 1, pending: 0, completed: 1, draft: 0, all: 2 });
});
test('[DOCUMENTS]: deleting documents as a recipient should only hide it for them', async ({ page }) => {
@@ -300,11 +284,7 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
// Check document counts.
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 0);
await checkDocumentCounts(page, { inbox: 0, pending: 0, completed: 0, draft: 0, all: 0 });
// Sign into the sender account.
await apiSignout({ page });
@@ -315,11 +295,7 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
});
// Check document counts for sender.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 3);
await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 1, draft: 1, all: 3 });
// Sign into the other recipient account.
await apiSignout({ page });
@@ -330,9 +306,5 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
});
// Check document counts for other recipient.
await checkDocumentTabCount(page, 'Inbox', 1);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 2);
await checkDocumentCounts(page, { inbox: 1, pending: 0, completed: 1, draft: 0, all: 2 });
});
@@ -20,7 +20,7 @@ import {
} from '@prisma/client';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
import { checkDocumentCounts, checkDocumentTabCount, toggleDocumentSenderFilter } from '../fixtures/documents';
test.describe.configure({
mode: 'parallel',
@@ -61,10 +61,7 @@ test.describe('Find Documents UI - Personal Context', () => {
redirectPath: `/t/${team.url}/documents`,
});
await checkDocumentTabCount(page, 'All', 3);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentCounts(page, { draft: 1, pending: 1, completed: 1, all: 3 });
});
test('received documents from other teams should NOT appear in personal context', async ({ page }) => {
@@ -140,10 +137,9 @@ test.describe('Find Documents UI - Personal Context', () => {
redirectPath: `/t/${ownerTeam.url}/documents`,
});
// Inbox should be 0 since there's no team email and received docs are on sender's team
await checkDocumentTabCount(page, 'Inbox', 0);
// Owner's own doc should still show in All
await checkDocumentTabCount(page, 'All', 1);
// Inbox should be 0 since there's no team email and received docs are on sender's team.
// Owner's own doc should still show in All.
await checkDocumentCounts(page, { inbox: 0, all: 1 });
await expect(page.getByRole('link', { name: 'Owner Draft Control' })).toBeVisible();
});
@@ -707,9 +703,8 @@ test.describe('Find Documents UI - Team with Team Email', () => {
redirectPath: `/t/${team.url}/documents`,
});
await checkDocumentTabCount(page, 'Inbox', 0);
// But pending should still show
await checkDocumentTabCount(page, 'Pending', 1);
// Inbox should be 0, but pending should still show.
await checkDocumentCounts(page, { inbox: 0, pending: 1 });
});
test('documents sent BY team email user should appear in team context', async ({ page }) => {
@@ -810,12 +805,9 @@ test.describe('Find Documents UI - Data Isolation & No Leaking', () => {
});
// UserA should see only their own docs
await checkDocumentTabCount(page, 'All', 3);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentCounts(page, { draft: 1, completed: 1, all: 3 });
// Verify no B docs leaked
await page.getByRole('tab', { name: 'All' }).click();
await expect(page.getByRole('link', { name: 'A Own Draft' })).toBeVisible();
await expect(page.getByRole('link', { name: 'B Draft Private', exact: true })).not.toBeVisible();
await expect(page.getByRole('link', { name: 'B Pending Private', exact: true })).not.toBeVisible();
@@ -966,9 +958,9 @@ test.describe('Find Documents UI - Data Isolation & No Leaking', () => {
redirectPath: `/t/${outsideTeam.url}/documents`,
});
// Only the outside user's own draft should appear (cross-team docs are not visible)
await checkDocumentTabCount(page, 'Inbox', 0); // No team email → 0
await checkDocumentTabCount(page, 'All', 1); // Check All tab last so we can verify visible links
// Only the outside user's own draft should appear (cross-team docs are not visible).
// Inbox is 0 since there is no team email.
await checkDocumentCounts(page, { inbox: 0, all: 1 });
await expect(page.getByRole('link', { name: 'Outside Own Draft' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Team Doc For Outside User', exact: true })).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Team Doc For Other User Only', exact: true })).not.toBeVisible();
@@ -1013,12 +1005,10 @@ test.describe('Find Documents UI - Tab Counts Consistency', () => {
redirectPath: `/t/${ownerTeam.url}/documents`,
});
// Only owner's own docs appear (received docs are on sender's team)
await checkDocumentTabCount(page, 'Draft', 2);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Inbox', 0); // No team email → inbox returns null → 0
await checkDocumentTabCount(page, 'Completed', 1); // Only owned completed (received is on sender's team)
await checkDocumentTabCount(page, 'All', 4); // 2 drafts + 1 pending + 1 completed
// Only owner's own docs appear (received docs are on sender's team).
// Inbox is 0 since there is no team email, and only the owned completed
// doc counts (received is on sender's team). All = 2 drafts + 1 pending + 1 completed.
await checkDocumentCounts(page, { inbox: 0, draft: 2, pending: 1, completed: 1, all: 4 });
});
test('team context tab counts should be accurate with mixed documents', async ({ page }) => {
@@ -1070,10 +1060,7 @@ test.describe('Find Documents UI - Tab Counts Consistency', () => {
redirectPath: `/t/${team.url}/documents`,
});
await checkDocumentTabCount(page, 'Draft', 2);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'All', 4);
await checkDocumentCounts(page, { draft: 2, pending: 1, completed: 1, all: 4 });
});
test('team with team email tab counts should include received documents', async ({ page }) => {
@@ -1107,11 +1094,9 @@ test.describe('Find Documents UI - Tab Counts Consistency', () => {
redirectPath: `/t/${team.url}/documents`,
});
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'Inbox', 1); // One pending doc received by team email (NOT_SIGNED)
await checkDocumentTabCount(page, 'Pending', 1); // Own pending
await checkDocumentTabCount(page, 'Completed', 1); // Received completed via email
await checkDocumentTabCount(page, 'All', 4); // All of the above
// Inbox = one pending doc received by team email (NOT_SIGNED), pending = own
// pending, completed = received completed via email, all = all of the above.
await checkDocumentCounts(page, { inbox: 1, draft: 1, pending: 1, completed: 1, all: 4 });
});
});
@@ -1163,9 +1148,7 @@ test.describe('Find Documents UI - Sender Filter', () => {
await checkDocumentTabCount(page, 'All', 3);
// Filter by member1
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
await page.getByRole('option', { name: member1.name ?? '' }).click();
await page.waitForURL(/senderIds/);
await toggleDocumentSenderFilter(page, member1.name ?? '');
// Should only show member1's doc
await checkDocumentTabCount(page, 'All', 1);
+109 -4
View File
@@ -1,11 +1,116 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
await page.getByRole('tab', { name: tabName }).click();
type DocumentStatusCounts = {
inbox?: number;
pending?: number;
completed?: number;
draft?: number;
cancelled?: number;
rejected?: number;
expired?: number;
all?: number;
};
if (tabName !== 'All') {
await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
const STATUS_KEYS = {
inbox: 'INBOX',
pending: 'PENDING',
completed: 'COMPLETED',
draft: 'DRAFT',
cancelled: 'CANCELLED',
rejected: 'REJECTED',
expired: 'EXPIRED',
all: 'ALL',
} as const;
/**
* Check the counts for multiple document statuses in one go via the
* visually hidden stats rendered alongside the status filter.
*
* When `all` is provided the status filter is also cleared and the
* unfiltered table count (or empty state) is verified.
*/
export const checkDocumentCounts = async (page: Page, counts: DocumentStatusCounts) => {
for (const [key, status] of Object.entries(STATUS_KEYS)) {
const count = counts[key as keyof typeof STATUS_KEYS];
if (count === undefined) {
continue;
}
await expect(page.getByTestId(`documents-status-count-${status}`)).toHaveText(count.toString());
}
if (counts.all !== undefined) {
await clearDocumentStatusFilter(page);
if (counts.all === 0) {
await expect(page.getByTestId('empty-document-state')).toBeVisible();
return;
}
await expect(page.getByTestId('data-table-count')).toContainText(`Showing ${counts.all}`);
}
};
/**
* Select a status in the documents status filter pill.
*
* No-op if the status is already selected, since selecting the active
* option again would clear the filter.
*/
export const selectDocumentStatusFilter = async (page: Page, statusName: string) => {
const currentStatus = new URL(page.url()).searchParams.get('status');
if (currentStatus === statusName.toUpperCase()) {
return;
}
await page.getByTestId('documents-table-status-filter').click();
await page.getByRole('option', { name: statusName }).click();
};
/**
* Toggle a sender in the documents sender filter pill.
*
* The sender filter is a multi select, so the popover stays open after
* picking and is closed with Escape.
*/
export const toggleDocumentSenderFilter = async (page: Page, senderName: string) => {
await page.getByTestId('documents-table-sender-filter').click();
await page.getByRole('option', { name: senderName }).click();
await page.waitForURL(/senderIds/);
await page.keyboard.press('Escape');
};
/**
* Clear the documents status filter pill, returning to the "All" view.
*/
export const clearDocumentStatusFilter = async (page: Page) => {
const currentStatus = new URL(page.url()).searchParams.get('status');
if (!currentStatus) {
return;
}
await page.getByTestId('documents-table-status-filter').click();
await page.getByRole('option', { name: 'Clear' }).click();
};
/**
* Apply a status filter (or 'All' to clear it) and verify both the hidden
* stats count and the resulting table.
*
* The count is not asserted against the stats for 'All', since tests use it
* with search queries applied which only the table respects.
*/
export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
if (tabName === 'All') {
await clearDocumentStatusFilter(page);
} else {
await expect(page.getByTestId(`documents-status-count-${tabName.toUpperCase()}`)).toHaveText(count.toString());
await selectDocumentStatusFilter(page, tabName);
}
if (count === 0) {
@@ -5,7 +5,7 @@ import { expect, test } from '@playwright/test';
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
import { checkDocumentCounts, checkDocumentTabCount, toggleDocumentSenderFilter } from '../fixtures/documents';
import { expectTextToBeVisible, expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
test('[TEAMS]: check team documents count', async ({ page }) => {
@@ -20,23 +20,13 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
});
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 2);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 2);
await checkDocumentTabCount(page, 'All', 5);
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 1, draft: 2, all: 5 });
// Apply filter.
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
await page.waitForURL(/senderIds/);
await toggleDocumentSenderFilter(page, teamMember2.name ?? '');
// Check counts after filtering.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 2);
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 3);
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 1, all: 3 });
await apiSignout({ page });
}
@@ -115,23 +105,13 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa
});
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 2);
await checkDocumentTabCount(page, 'Pending', 3);
await checkDocumentTabCount(page, 'Completed', 3);
await checkDocumentTabCount(page, 'Draft', 3);
await checkDocumentTabCount(page, 'All', 11);
await checkDocumentCounts(page, { inbox: 2, pending: 3, completed: 3, draft: 3, all: 11 });
// Apply filter.
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
await page.waitForURL(/senderIds/);
await toggleDocumentSenderFilter(page, teamMember2.name ?? '');
// Check counts after filtering.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 2);
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 3);
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 1, all: 3 });
await apiSignout({ page });
}
@@ -202,23 +182,13 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
});
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 3);
await checkDocumentTabCount(page, 'Pending', 2);
await checkDocumentTabCount(page, 'Completed', 2);
await checkDocumentTabCount(page, 'Draft', 2);
await checkDocumentTabCount(page, 'All', 9);
await checkDocumentCounts(page, { inbox: 3, pending: 2, completed: 2, draft: 2, all: 9 });
// Apply filter.
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
await page.waitForURL(/senderIds/);
await toggleDocumentSenderFilter(page, teamMember2.name ?? '');
// Check counts after filtering.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 2);
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 3);
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 1, all: 3 });
});
test('[TEAMS]: resend pending team document', async ({ page }) => {
@@ -273,11 +243,7 @@ test('[TEAMS]: delete draft team document', async ({ page }) => {
});
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 2);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 4);
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 1, draft: 1, all: 4 });
await apiSignout({ page });
}
@@ -316,11 +282,7 @@ test('[TEAMS]: delete pending team document', async ({ page }) => {
});
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 2);
await checkDocumentTabCount(page, 'All', 4);
await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 1, draft: 2, all: 4 });
await apiSignout({ page });
}
@@ -359,11 +321,7 @@ test('[TEAMS]: delete completed team document', async ({ page }) => {
});
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 2);
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 2);
await checkDocumentTabCount(page, 'All', 4);
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 2, all: 4 });
await apiSignout({ page });
}
@@ -49,10 +49,10 @@ test('[BULK_ACTIONS]: can select multiple templates with checkboxes', async ({ p
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
await expect(page.getByText('2 selected')).toBeVisible();
await expect(page.getByText(/2\s*selected/)).toBeVisible();
});
test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ page }) => {
@@ -66,7 +66,7 @@ test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ p
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(`${templates.length} selected`)).toBeVisible();
await expect(page.getByText(new RegExp(`${templates.length}\\s*selected`))).toBeVisible();
});
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
@@ -79,11 +79,11 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
});
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(/\d+ selected/)).toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).toBeVisible();
await page.getByLabel('Clear selection').click();
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page }) => {
@@ -97,13 +97,13 @@ test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page })
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('Move Templates to Folder')).toBeVisible();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
@@ -151,14 +151,14 @@ test('[BULK_ACTIONS]: selection clears after successful move', async ({ page })
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
@@ -171,13 +171,13 @@ test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
await expectToastTextToBeVisible(page, 'Templates deleted');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
@@ -199,7 +199,7 @@ test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) =
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
@@ -236,14 +236,14 @@ test('[BULK_ACTIONS]: can move templates from folder to home (root)', async ({ p
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
@@ -98,10 +98,10 @@ const trpcMutation = async (page: Page, procedure: string, input: Record<string,
return { res, json: res.ok() ? await res.json() : null };
};
// ─── UI: Tab Visibility ──────────────────────────────────────────────────────
// ─── UI: View Filter Visibility ──────────────────────────────────────────────
test.describe('Organisation Templates - UI Tabs', () => {
test('should show Team/Organisation tabs for non-personal orgs', async ({ page }) => {
test.describe('Organisation Templates - UI View Filter', () => {
test('should show the view filter for non-personal orgs', async ({ page }) => {
const { ownerA, teamA } = await seedOrgTemplateScenario();
await apiSignin({
@@ -110,11 +110,10 @@ test.describe('Organisation Templates - UI Tabs', () => {
redirectPath: `/t/${teamA.url}/templates`,
});
await expect(page.getByTestId('template-tab-team')).toBeVisible();
await expect(page.getByTestId('template-tab-organisation')).toBeVisible();
await expect(page.getByTestId('templates-table-view-filter')).toBeVisible();
});
test('should not show tabs for personal organisations', async ({ page }) => {
test('should not show the view filter for personal organisations', async ({ page }) => {
const { user, team } = await seedUser({ isPersonalOrganisation: true });
await apiSignin({
@@ -123,15 +122,14 @@ test.describe('Organisation Templates - UI Tabs', () => {
redirectPath: `/t/${team.url}/templates`,
});
await expect(page.getByTestId('template-tab-team')).not.toBeVisible();
await expect(page.getByTestId('template-tab-organisation')).not.toBeVisible();
await expect(page.getByTestId('templates-table-view-filter')).not.toBeVisible();
});
});
// ─── UI: Listing Organisation Templates ──────────────────────────────────────
test.describe('Organisation Templates - Listing', () => {
test('should list org templates from other teams under the Organisation tab', async ({ page }) => {
test('should list org templates from other teams under the organisation view', async ({ page }) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({
@@ -140,17 +138,18 @@ test.describe('Organisation Templates - Listing', () => {
redirectPath: `/t/${teamB.url}/templates`,
});
// Team tab should show 0 (memberB has no templates on teamB).
await expect(page.getByTestId('template-tab-team')).toBeVisible();
// Team view is active by default (memberB has no templates on teamB).
await expect(page.getByTestId('templates-table-view-filter')).toBeVisible();
// Switch to Organisation tab.
await page.getByTestId('template-tab-organisation').click();
// Switch to the organisation view.
await page.getByTestId('templates-table-view-filter').click();
await page.getByRole('option', { name: 'Organisation' }).click();
// Should see the org template from teamA.
await expect(page.getByText(orgTemplate.title)).toBeVisible();
});
test('should not show private templates from other teams under Organisation tab', async ({ page }) => {
test('should not show private templates from other teams under the organisation view', async ({ page }) => {
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
// Create a private template on teamA — should NOT appear in org tab.
+2 -2
View File
@@ -18,9 +18,9 @@
"@playwright/test": "1.56.1",
"@types/node": "^20",
"@types/pngjs": "^6.0.5",
"tsx": "^4.23.1",
"pixelmatch": "^7.1.0",
"pngjs": "^7.0.0"
"pngjs": "^7.0.0",
"tsx": "^4.23.1"
},
"dependencies": {
"start-server-and-test": "^2.1.3"
@@ -0,0 +1,178 @@
import { Zip, ZipPassThrough } from 'fflate';
export type ZipFileEntry = {
/**
* The path of the file within the archive. Forward slashes create folders.
* Individual path segments should be sanitized with
* {@link sanitizeZipPathSegment} when derived from user-controlled values.
*/
filename: string;
data: Blob;
};
/**
* Sanitizes a single path segment (folder or file name) for use inside a zip
* archive, replacing characters that are path separators or invalid on
* Windows extraction.
*/
export const sanitizeZipPathSegment = (segment: string): string => {
const sanitized = segment
.replace(/[\\/:*?"<>|\p{Cc}]/gu, '-')
.trim()
// Windows cannot extract folders or files ending with a dot.
.replace(/\.+$/, '');
return sanitized || 'untitled';
};
export type ZipWriter = {
/**
* Adds a file to the zip stream. Files are written incrementally so the
* input blob can be garbage collected once this resolves.
*/
addFile: (entry: ZipFileEntry) => Promise<void>;
/**
* Finishes the zip stream and returns the archive as a blob.
*/
finalize: () => Blob;
/**
* Discards the zip stream and any buffered output.
*/
abort: () => void;
};
/**
* How many bytes of a blob to materialise into the JS heap per read. Blobs
* (e.g. fetch responses) can be disk-backed by the browser, it is only
* `arrayBuffer()` that forces them into memory, so we read in slices.
*/
const READ_SLICE_BYTES = 4 * 1024 * 1024;
/**
* Once this many bytes of zip output have accumulated in the JS heap they are
* coalesced into an intermediate blob. Browsers can page blob storage to disk
* under memory pressure, and the final `new Blob(parts)` composes parts by
* reference, so this keeps the heap bounded regardless of archive size.
*/
const OUTPUT_COALESCE_BYTES = 16 * 1024 * 1024;
/**
* Creates an incremental client-side zip writer.
*
* Files are stored without compression (PDFs are already internally
* compressed) and streamed through the archive as they are added, so peak JS
* heap usage is bounded by roughly one read slice plus one output buffer
* rather than the total size of the archive.
*/
export const createZipWriter = (): ZipWriter => {
const usedNames = new Set<string>();
const outputParts: Blob[] = [];
let pendingChunks: Uint8Array[] = [];
let pendingSize = 0;
let zipError: Error | null = null;
const flushPendingChunks = () => {
if (pendingChunks.length === 0) {
return;
}
outputParts.push(new Blob(pendingChunks));
pendingChunks = [];
pendingSize = 0;
};
// ZipPassThrough is synchronous (no workers), so output callbacks have
// always fired by the time `push`/`end` return.
const zipStream = new Zip((error, chunk, isFinal) => {
if (error) {
zipError = error;
return;
}
pendingChunks.push(chunk);
pendingSize += chunk.length;
if (pendingSize >= OUTPUT_COALESCE_BYTES || isFinal) {
flushPendingChunks();
}
});
/**
* Deduplicates filenames case-insensitively (Windows extraction is
* case-insensitive) by appending " (n)" before the extension.
*/
const deduplicateFilename = (filename: string) => {
const match = filename.match(/^(.*?)(\.[^./]+)?$/);
const baseName = match?.[1] ?? filename;
const extension = match?.[2] ?? '';
let candidate = filename;
let counter = 1;
while (usedNames.has(candidate.toLowerCase())) {
candidate = `${baseName} (${counter})${extension}`;
counter += 1;
}
usedNames.add(candidate.toLowerCase());
return candidate;
};
const addFile = async ({ filename, data }: ZipFileEntry) => {
if (zipError) {
throw zipError;
}
const file = new ZipPassThrough(deduplicateFilename(filename));
zipStream.add(file);
for (let offset = 0; offset < data.size; offset += READ_SLICE_BYTES) {
const slice = data.slice(offset, offset + READ_SLICE_BYTES);
file.push(new Uint8Array(await slice.arrayBuffer()));
if (zipError) {
throw zipError;
}
}
file.push(new Uint8Array(0), true);
if (zipError) {
throw zipError;
}
};
const finalize = () => {
zipStream.end();
if (zipError) {
throw zipError;
}
flushPendingChunks();
return new Blob(outputParts, { type: 'application/zip' });
};
const abort = () => {
zipStream.terminate();
pendingChunks = [];
pendingSize = 0;
outputParts.length = 0;
};
return {
addFile,
finalize,
abort,
};
};
+22 -3
View File
@@ -32,7 +32,11 @@ const versionToFilenameSuffix = (version: DocumentVersion): string => {
}
};
export const downloadPDF = async ({ envelopeItem, token, fileName, version = 'signed' }: DownloadPDFProps) => {
/**
* Fetches a PDF for an envelope item and returns it as a blob alongside the
* filename it should be saved as. Throws on non-OK responses.
*/
export const fetchPDF = async ({ envelopeItem, token, fileName, version = 'signed' }: DownloadPDFProps) => {
const downloadUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem: envelopeItem,
@@ -40,12 +44,27 @@ export const downloadPDF = async ({ envelopeItem, token, fileName, version = 'si
version,
});
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
const response = await fetch(downloadUrl);
if (!response.ok) {
throw new Error(`Failed to download PDF: ${response.status}`);
}
const blob = await response.blob();
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
downloadFile({
return {
filename: `${baseTitle}${versionToFilenameSuffix(version)}`,
blob,
};
};
export const downloadPDF = async (options: DownloadPDFProps) => {
const { filename, blob } = await fetchPDF(options);
downloadFile({
filename,
data: blob,
});
};
+5 -2
View File
@@ -310,6 +310,9 @@ export const seedDraftDocument = async (
const documentId = await incrementDocumentId();
const envelopeTitle =
typeof createDocumentOptions.title === 'string' ? createDocumentOptions.title : `[TEST] Document ${key} - Draft`;
const document = await prisma.envelope.create({
data: {
id: prefixedId('envelope'),
@@ -320,12 +323,12 @@ export const seedDraftDocument = async (
documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT,
teamId,
title: `[TEST] Document ${key} - Draft`,
title: envelopeTitle,
status: DocumentStatus.DRAFT,
envelopeItems: {
create: {
id: prefixedId('envelope_item'),
title: `[TEST] Document ${key} - Draft`,
title: envelopeTitle,
documentDataId: documentData.id,
order: 1,
},
@@ -8,14 +8,13 @@ export const cancelEnvelopeMeta: TrpcRouteMeta = {
method: 'POST',
path: '/envelope/cancel',
summary: 'Cancel envelope',
description: 'Cancel a pending envelope',
tags: ['Envelope'],
},
};
export const ZCancelEnvelopeRequestSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope to cancel.'),
reason: z.string().describe('The reason for cancelling the envelope.').optional(),
envelopeId: z.string(),
reason: z.string().optional(),
});
export const ZCancelEnvelopeResponseSchema = ZSuccessResponseSchema;
@@ -8,13 +8,12 @@ export const deleteEnvelopeMeta: TrpcRouteMeta = {
method: 'POST',
path: '/envelope/delete',
summary: 'Delete envelope',
description: 'Delete an envelope',
tags: ['Envelope'],
},
};
export const ZDeleteEnvelopeRequestSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope to delete.'),
envelopeId: z.string(),
});
export const ZDeleteEnvelopeResponseSchema = ZSuccessResponseSchema;
@@ -29,17 +29,7 @@ export const updateEnvelopeFieldsRoute = authenticatedProcedure
id: envelopeId,
},
type: null,
fields: fields.map((field) => ({
id: field.id,
type: field.type,
pageNumber: field.page,
pageX: field.positionX,
pageY: field.positionY,
width: field.width,
height: field.height,
fieldMeta: field.fieldMeta,
envelopeItemId: field.envelopeItemId,
})),
fields,
requestMetadata: ctx.metadata,
});
@@ -12,32 +12,24 @@ export const updateEnvelopeMeta: TrpcRouteMeta = {
method: 'POST',
path: '/envelope/update',
summary: 'Update envelope',
description: 'Update envelope properties and settings',
tags: ['Envelope'],
},
};
export const ZUpdateEnvelopeRequestSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope to update.'),
envelopeId: z.string(),
data: z
.object({
title: ZDocumentTitleSchema.optional(),
externalId: ZDocumentExternalIdSchema.nullish(),
visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: z
.array(ZDocumentAccessAuthTypesSchema)
.describe('The authentication methods required to access the envelope.')
.optional(),
globalActionAuth: z
.array(ZDocumentActionAuthTypesSchema)
.describe('The authentication methods required to sign the envelope.')
.optional(),
folderId: z.string().describe('The ID of the folder containing the envelope.').nullish(),
templateType: z.nativeEnum(TemplateType).describe('The template type.').optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
folderId: z.string().nullish(),
templateType: z.nativeEnum(TemplateType).optional(),
})
.describe('The envelope properties to update.')
.optional(),
meta: ZDocumentMetaUpdateSchema.describe('The email and signing settings to update.').optional(),
meta: ZDocumentMetaUpdateSchema.optional(),
});
export const ZUpdateEnvelopeResponseSchema = ZEnvelopeLiteSchema;
+40 -1
View File
@@ -35,4 +35,43 @@ const RadioGroupItem = React.forwardRef<
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };
/**
* A segmented-control style radio group where each item renders as a small
* toggle button rather than a radio circle.
*/
const RadioGroupSegmented = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn('inline-flex items-center gap-0.5 rounded-md bg-muted p-0.5', className)}
{...props}
ref={ref}
/>
);
});
RadioGroupSegmented.displayName = 'RadioGroupSegmented';
const RadioGroupSegmentedItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, children, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
'rounded-sm px-2 py-0.5 font-medium text-muted-foreground text-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-background data-[state=checked]:text-foreground data-[state=checked]:shadow-sm',
className,
)}
{...props}
>
{children}
</RadioGroupPrimitive.Item>
);
});
RadioGroupSegmentedItem.displayName = 'RadioGroupSegmentedItem';
export { RadioGroup, RadioGroupItem, RadioGroupSegmented, RadioGroupSegmentedItem };