Compare commits
24 Commits
v1.9.1
...
feat/allow
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e9c7f1b11 | ||
|
|
bc2ec9a2d7 | ||
|
|
763b7f82c9 | ||
|
|
c670f64b1f | ||
|
|
369e16afab | ||
|
|
4a5f565591 | ||
|
|
f544eae2a6 | ||
|
|
a2ffd75c17 | ||
|
|
8619eec67a | ||
|
|
f325a04cb5 | ||
|
|
6a47b3a6e5 | ||
|
|
a7adb77e47 | ||
|
|
bfcbaea3a9 | ||
|
|
64964f420a | ||
|
|
2896673a23 | ||
|
|
b684b9574d | ||
|
|
12803d1a5e | ||
|
|
c41002313a | ||
|
|
516435fa2a | ||
|
|
0216af4ae8 | ||
|
|
3cde3cb7b2 | ||
|
|
071f5c546d | ||
|
|
9f9f6701c8 | ||
|
|
b01eaceeb8 |
@@ -1,4 +1,4 @@
|
|||||||
> 🚨 We are live on Product Hunt 🎉 Check out our latest launch: <a href="https://documen.so/sign-everywhere">The Platform Plan</a>!
|
> 🚨 We are live on Product Hunt 🎉 Check out our latest launch: <a href="documen.so/sign-everywhere">The Platform Plan</a>!
|
||||||
|
|
||||||
<a href="https://www.producthunt.com/posts/documenso-platform-plan?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-documenso-platform-plan" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=670576&theme=light" alt="Documenso Platform Plan - Whitelabeled signing flows in your product | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
<a href="https://www.producthunt.com/posts/documenso-platform-plan?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-documenso-platform-plan" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=670576&theme=light" alt="Documenso Platform Plan - Whitelabeled signing flows in your product | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
|
||||||
|
|||||||
@@ -14,4 +14,4 @@
|
|||||||
"public-api": "Public API",
|
"public-api": "Public API",
|
||||||
"embedding": "Embedding",
|
"embedding": "Embedding",
|
||||||
"webhooks": "Webhooks"
|
"webhooks": "Webhooks"
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,5 @@
|
|||||||
"solid": "Solid Integration",
|
"solid": "Solid Integration",
|
||||||
"preact": "Preact Integration",
|
"preact": "Preact Integration",
|
||||||
"angular": "Angular Integration",
|
"angular": "Angular Integration",
|
||||||
"css-variables": "CSS Variables",
|
"css-variables": "CSS Variables"
|
||||||
"web-components": "Web Components"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ Platform customers have access to advanced styling options to customize the embe
|
|||||||
<EmbedDirectTemplate
|
<EmbedDirectTemplate
|
||||||
token={token}
|
token={token}
|
||||||
cssVars={{
|
cssVars={{
|
||||||
primary: '#0000FF',
|
colorPrimary: '#0000FF',
|
||||||
background: '#F5F5F5',
|
colorBackground: '#F5F5F5',
|
||||||
radius: '8px',
|
borderRadius: '8px',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
@@ -73,15 +73,14 @@ These customization options are available for both Direct Templates and Signing
|
|||||||
|
|
||||||
We support embedding across a range of popular JavaScript frameworks, including:
|
We support embedding across a range of popular JavaScript frameworks, including:
|
||||||
|
|
||||||
| Framework | Package |
|
| Framework | Package |
|
||||||
| --------- | ---------------------------------------------------------------------------------- |
|
| --------- | ---------------------------------------------------------------------------------- |
|
||||||
| React | [@documenso/embed-react](https://www.npmjs.com/package/@documenso/embed-react) |
|
| React | [@documenso/embed-react](https://www.npmjs.com/package/@documenso/embed-react) |
|
||||||
| Preact | [@documenso/embed-preact](https://www.npmjs.com/package/@documenso/embed-preact) |
|
| Preact | [@documenso/embed-preact](https://www.npmjs.com/package/@documenso/embed-preact) |
|
||||||
| Vue | [@documenso/embed-vue](https://www.npmjs.com/package/@documenso/embed-vue) |
|
| Vue | [@documenso/embed-vue](https://www.npmjs.com/package/@documenso/embed-vue) |
|
||||||
| Svelte | [@documenso/embed-svelte](https://www.npmjs.com/package/@documenso/embed-svelte) |
|
| Svelte | [@documenso/embed-svelte](https://www.npmjs.com/package/@documenso/embed-svelte) |
|
||||||
| Solid | [@documenso/embed-solid](https://www.npmjs.com/package/@documenso/embed-solid) |
|
| Solid | [@documenso/embed-solid](https://www.npmjs.com/package/@documenso/embed-solid) |
|
||||||
| Angular | [@documenso/embed-angular](https://www.npmjs.com/package/@documenso/embed-angular) |
|
| Angular | [@documenso/embed-angular](https://www.npmjs.com/package/@documenso/embed-angular) |
|
||||||
| Web Components | [@documenso/embed-webcomponent](https://www.npmjs.com/package/@documenso/embed-webcomponent) |
|
|
||||||
|
|
||||||
Additionally, we provide **web components** for more generalized use. However, please note that web components are still in their early stages and haven't been extensively tested.
|
Additionally, we provide **web components** for more generalized use. However, please note that web components are still in their early stages and haven't been extensively tested.
|
||||||
|
|
||||||
@@ -167,7 +166,6 @@ Once you've obtained the appropriate tokens, you can integrate the signing exper
|
|||||||
- [Svelte](/developers/embedding/svelte)
|
- [Svelte](/developers/embedding/svelte)
|
||||||
- [Solid](/developers/embedding/solid)
|
- [Solid](/developers/embedding/solid)
|
||||||
- [Angular](/developers/embedding/angular)
|
- [Angular](/developers/embedding/angular)
|
||||||
- [Web Components](/developers/embedding/web-components)
|
|
||||||
|
|
||||||
If you're using **web components**, the integration process is slightly different. Keep in mind that web components are currently less tested but can still provide flexibility for general use cases.
|
If you're using **web components**, the integration process is slightly different. Keep in mind that web components are currently less tested but can still provide flexibility for general use cases.
|
||||||
|
|
||||||
@@ -179,5 +177,4 @@ If you're using **web components**, the integration process is slightly differen
|
|||||||
- [Solid Integration](/developers/embedding/solid)
|
- [Solid Integration](/developers/embedding/solid)
|
||||||
- [Preact Integration](/developers/embedding/preact)
|
- [Preact Integration](/developers/embedding/preact)
|
||||||
- [Angular Integration](/developers/embedding/angular)
|
- [Angular Integration](/developers/embedding/angular)
|
||||||
- [Web Components](/developers/embedding/web-components)
|
|
||||||
- [CSS Variables](/developers/embedding/css-variables)
|
- [CSS Variables](/developers/embedding/css-variables)
|
||||||
|
|||||||
@@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
primary: '#0000FF',
|
colorPrimary: '#0000FF',
|
||||||
background: '#F5F5F5',
|
colorBackground: '#F5F5F5',
|
||||||
radius: '8px',
|
borderRadius: '8px',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -99,9 +99,9 @@ const MyEmbeddingComponent = () => {
|
|||||||
`}
|
`}
|
||||||
// CSS Variables
|
// CSS Variables
|
||||||
cssVars={{
|
cssVars={{
|
||||||
primary: '#0000FF',
|
colorPrimary: '#0000FF',
|
||||||
background: '#F5F5F5',
|
colorBackground: '#F5F5F5',
|
||||||
radius: '8px',
|
borderRadius: '8px',
|
||||||
}}
|
}}
|
||||||
// Dark Mode Control
|
// Dark Mode Control
|
||||||
darkModeDisabled={true}
|
darkModeDisabled={true}
|
||||||
|
|||||||
@@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
primary: '#0000FF',
|
colorPrimary: '#0000FF',
|
||||||
background: '#F5F5F5',
|
colorBackground: '#F5F5F5',
|
||||||
radius: '8px',
|
borderRadius: '8px',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
primary: '#0000FF',
|
colorPrimary: '#0000FF',
|
||||||
background: '#F5F5F5',
|
colorBackground: '#F5F5F5',
|
||||||
radius: '8px',
|
borderRadius: '8px',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
primary: '#0000FF',
|
colorPrimary: '#0000FF',
|
||||||
background: '#F5F5F5',
|
colorBackground: '#F5F5F5',
|
||||||
radius: '8px',
|
borderRadius: '8px',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
---
|
|
||||||
title: Web Components Integration
|
|
||||||
description: Learn how to use our embedding SDK via Web Components on a framework-less web application.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Web Components Integration
|
|
||||||
|
|
||||||
Our Web Components SDK provides a simple way to embed a signing experience within your framework-less web application. It supports both direct link templates and signing tokens.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
To install the SDK, run the following command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install @documenso/embed-webcomponent
|
|
||||||
```
|
|
||||||
|
|
||||||
Then in your html file, add the following to add the script, replacing the path with the proper path to the web component script.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<script src="YOUR_PATH_HERE"></script>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
To embed a signing experience, you'll need to provide the token for the document you want to embed. This can be done in a few different ways, depending on your use case.
|
|
||||||
|
|
||||||
### Direct Link Template
|
|
||||||
|
|
||||||
If you have a direct link template, you can simply provide the token for the template to the `documenso-embed-direct-template` tag.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<documenso-embed-direct-template
|
|
||||||
token="YOUR_TOKEN_HERE"
|
|
||||||
</documenso-embed-direct-template>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Attributes
|
|
||||||
|
|
||||||
| Attribute | Type | Description |
|
|
||||||
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
|
|
||||||
| token | string | The token for the document you want to embed |
|
|
||||||
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
|
|
||||||
| name | string (optional) | The name the signer that will be used by default for signing |
|
|
||||||
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
|
|
||||||
| email | string (optional) | The email the signer that will be used by default for signing |
|
|
||||||
| lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications |
|
|
||||||
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
|
|
||||||
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
|
|
||||||
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
|
|
||||||
| onFieldSigned | function (optional) | A callback function that will be called when a field is signed |
|
|
||||||
| onFieldUnsigned | function (optional) | A callback function that will be called when a field is unsigned |
|
|
||||||
|
|
||||||
### Signing Token
|
|
||||||
|
|
||||||
If you have a signing token, you can provide it to the `documenso-embed-sign-document` tag.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<documenso-embed-sign-document
|
|
||||||
token="YOUR_TOKEN_HERE"
|
|
||||||
</documenso-embed-sign-document>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Attributes
|
|
||||||
|
|
||||||
| Attribute | Type | Description |
|
|
||||||
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
|
|
||||||
| token | string | The token for the document you want to embed |
|
|
||||||
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
|
|
||||||
| name | string (optional) | The name the signer that will be used by default for signing |
|
|
||||||
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
|
|
||||||
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
|
|
||||||
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
|
|
||||||
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
|
|
||||||
|
|
||||||
### Creating via JavaScript
|
|
||||||
|
|
||||||
You can also create the tag element using javascript, for dynamic generation of either modes. For example, this would add the sign document embed to the DOM.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
document.getElementById('my-wrapper-here').innerHTML = '';
|
|
||||||
|
|
||||||
const tag = document.createElement('documenso-embed-sign-document');
|
|
||||||
tag.setAttribute('token', data.token);
|
|
||||||
tag.style.width = '100%';
|
|
||||||
tag.style.height = '100%';
|
|
||||||
|
|
||||||
document.getElementById('my-wrapper-here').appendChild(tag);
|
|
||||||
```
|
|
||||||
@@ -21,25 +21,14 @@ Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) f
|
|||||||
|
|
||||||
## API V2 - Beta
|
## API V2 - Beta
|
||||||
|
|
||||||
<Callout type="warning">API V2 is currently beta, and will be subject to breaking changes</Callout>
|
Our new API V2 is currently in Beta. The new API features typed SDKs for TypeScript, Python and Go and example code for many more.
|
||||||
|
|
||||||
Check out the [API V2 documentation](https://documen.so/api-v2-docs) for details about the API endpoints, request parameters, response formats, and authentication methods.
|
<Callout type="warning">
|
||||||
|
NOW IN BETA: [API V2 Documentation](https://documen.so/api-v2-docs)
|
||||||
Our new API V2 supports the following typed SDKs:
|
|
||||||
|
|
||||||
- [TypeScript](https://github.com/documenso/sdk-typescript)
|
|
||||||
- [Python](https://github.com/documenso/sdk-python)
|
|
||||||
- [Go](https://github.com/documenso/sdk-go)
|
|
||||||
|
|
||||||
<Callout type="info">
|
|
||||||
For the staging API, please use the following base URL:
|
|
||||||
`https://stg-app.documenso.dev/api/v2-beta/`
|
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
🚀 [V2 Announcement](https://documen.so/sdk-blog)
|
🚀 [V2 Announcement](https://documen.so/sdk-blog)
|
||||||
|
|
||||||
📖 [Documentation](https://documen.so/api-v2-docs)
|
|
||||||
|
|
||||||
💬 [Leave Feedback](https://documen.so/sdk-feedback)
|
💬 [Leave Feedback](https://documen.so/sdk-feedback)
|
||||||
|
|
||||||
🔔 [Breaking Changes](https://documen.so/sdk-breaking)
|
🔔 [Breaking Changes](https://documen.so/sdk-breaking)
|
||||||
|
|||||||
@@ -532,93 +532,3 @@ Replace the `text` value with the corresponding field type:
|
|||||||
- For the `SELECT` field it should be `select`. (check this before merge)
|
- For the `SELECT` field it should be `select`. (check this before merge)
|
||||||
|
|
||||||
You must pass this property at all times, even if you don't need to set any other properties. If you don't, the endpoint will throw an error.
|
You must pass this property at all times, even if you don't need to set any other properties. If you don't, the endpoint will throw an error.
|
||||||
|
|
||||||
## Pre-fill Fields On Document Creation
|
|
||||||
|
|
||||||
The API allows you to pre-fill fields on document creation. This is useful when you want to create a document from an existing template and pre-fill the fields with specific values.
|
|
||||||
|
|
||||||
To pre-fill a field, you need to make a `POST` request to the `/api/v1/templates/{templateId}/generate-document` endpoint with the field information. Here's an example:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"title": "my-document.pdf",
|
|
||||||
"recipients": [
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"name": "Example User",
|
|
||||||
"email": "example@documenso.com",
|
|
||||||
"signingOrder": 1,
|
|
||||||
"role": "SIGNER"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"prefillFields": [
|
|
||||||
{
|
|
||||||
"id": 21,
|
|
||||||
"type": "text",
|
|
||||||
"label": "my-label",
|
|
||||||
"placeholder": "my-placeholder",
|
|
||||||
"value": "my-value"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 22,
|
|
||||||
"type": "number",
|
|
||||||
"label": "my-label",
|
|
||||||
"placeholder": "my-placeholder",
|
|
||||||
"value": "123"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 23,
|
|
||||||
"type": "checkbox",
|
|
||||||
"label": "my-label",
|
|
||||||
"placeholder": "my-placeholder",
|
|
||||||
"value": ["option-1", "option-2"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Check out the endpoint in the [API V1 documentation](https://app.documenso.com/api/v1/openapi#:~:text=/%7BtemplateId%7D/-,generate,-%2Ddocument).
|
|
||||||
|
|
||||||
### API V2
|
|
||||||
|
|
||||||
For API V2, you need to make a `POST` request to the `/api/v2-beta/template/use` endpoint with the field(s) information. Here's an example:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"templateId": 111,
|
|
||||||
"recipients": [
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"name": "Example User",
|
|
||||||
"email": "example@documenso.com",
|
|
||||||
"signingOrder": 1,
|
|
||||||
"role": "SIGNER"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"prefillFields": [
|
|
||||||
{
|
|
||||||
"id": 21,
|
|
||||||
"type": "text",
|
|
||||||
"label": "my-label",
|
|
||||||
"placeholder": "my-placeholder",
|
|
||||||
"value": "my-value"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 22,
|
|
||||||
"type": "number",
|
|
||||||
"label": "my-label",
|
|
||||||
"placeholder": "my-placeholder",
|
|
||||||
"value": "123"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 23,
|
|
||||||
"type": "checkbox",
|
|
||||||
"label": "my-label",
|
|
||||||
"placeholder": "my-placeholder",
|
|
||||||
"value": ["option-1", "option-2"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Check out the endpoint in the [API V2 documentation](https://openapi.documenso.com/reference#tag/template/POST/template/use).
|
|
||||||
|
|||||||
@@ -85,13 +85,12 @@ You can also set the recipient's role, which determines their actions and permis
|
|||||||
|
|
||||||
Documenso has 4 roles for recipients with different permissions and actions.
|
Documenso has 4 roles for recipients with different permissions and actions.
|
||||||
|
|
||||||
| Role | Function | Action required | Signature |
|
| Role | Function | Action required | Signature |
|
||||||
| :-------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
|
| :------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
|
||||||
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
|
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
|
||||||
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
|
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
|
||||||
| Viewer | Needs to confirm they viewed the document. | Yes | No |
|
| Viewer | Needs to confirm they viewed the document. | Yes | No |
|
||||||
| Assistant | Can help prepare the document by filling in fields on behalf of other signers. | Yes | No |
|
| BCC | Receives a copy of the signed document after completion. No action is required. | No | No |
|
||||||
| CC | Receives a copy of the signed document after completion. No action is required. | No | No |
|
|
||||||
|
|
||||||
### Fields
|
### Fields
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/web",
|
"name": "@documenso/web",
|
||||||
"version": "1.9.1-rc.9",
|
"version": "1.9.1-rc.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -47,50 +47,22 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
|
|||||||
import type { Toast } from '@documenso/ui/primitives/use-toast';
|
import type { Toast } from '@documenso/ui/primitives/use-toast';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
const ZAddRecipientsForNewDocumentSchema = z
|
const ZAddRecipientsForNewDocumentSchema = z.object({
|
||||||
.object({
|
distributeDocument: z.boolean(),
|
||||||
distributeDocument: z.boolean(),
|
useCustomDocument: z.boolean().default(false),
|
||||||
useCustomDocument: z.boolean().default(false),
|
customDocumentData: z
|
||||||
customDocumentData: z
|
.any()
|
||||||
.any()
|
.refine((data) => data instanceof File || data === undefined)
|
||||||
.refine((data) => data instanceof File || data === undefined)
|
.optional(),
|
||||||
.optional(),
|
recipients: z.array(
|
||||||
recipients: z.array(
|
z.object({
|
||||||
z.object({
|
id: z.number(),
|
||||||
id: z.number(),
|
email: z.string().email(),
|
||||||
email: z.string().email(),
|
name: z.string(),
|
||||||
name: z.string(),
|
signingOrder: z.number().optional(),
|
||||||
signingOrder: z.number().optional(),
|
}),
|
||||||
}),
|
),
|
||||||
),
|
});
|
||||||
})
|
|
||||||
// Display exactly which rows are duplicates.
|
|
||||||
.superRefine((items, ctx) => {
|
|
||||||
const uniqueEmails = new Map<string, number>();
|
|
||||||
|
|
||||||
for (const [index, recipients] of items.recipients.entries()) {
|
|
||||||
const email = recipients.email.toLowerCase();
|
|
||||||
|
|
||||||
const firstFoundIndex = uniqueEmails.get(email);
|
|
||||||
|
|
||||||
if (firstFoundIndex === undefined) {
|
|
||||||
uniqueEmails.set(email, index);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'Emails must be unique',
|
|
||||||
path: ['recipients', index, 'email'],
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'Emails must be unique',
|
|
||||||
path: ['recipients', firstFoundIndex, 'email'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export const SignDirectTemplateForm = ({
|
|||||||
|
|
||||||
const tempField: DirectTemplateLocalField = {
|
const tempField: DirectTemplateLocalField = {
|
||||||
...field,
|
...field,
|
||||||
customText: value.value ?? '',
|
customText: value.value,
|
||||||
inserted: true,
|
inserted: true,
|
||||||
signedValue: value,
|
signedValue: value,
|
||||||
};
|
};
|
||||||
@@ -101,8 +101,8 @@ export const SignDirectTemplateForm = ({
|
|||||||
created: new Date(),
|
created: new Date(),
|
||||||
recipientId: 1,
|
recipientId: 1,
|
||||||
fieldId: 1,
|
fieldId: 1,
|
||||||
signatureImageAsBase64: value.value?.startsWith('data:') ? value.value : null,
|
signatureImageAsBase64: value.value.startsWith('data:') ? value.value : null,
|
||||||
typedSignature: value.value && !value.value.startsWith('data:') ? value.value : null,
|
typedSignature: value.value.startsWith('data:') ? null : value.value,
|
||||||
} satisfies Signature;
|
} satisfies Signature;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,12 +44,7 @@ export const CheckboxField = ({ field, onSignField, onUnsignField }: CheckboxFie
|
|||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
|
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
|
||||||
|
|
||||||
const parsedFieldMeta = ZCheckboxFieldMeta.parse(
|
const parsedFieldMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
|
||||||
field.fieldMeta ?? {
|
|
||||||
type: 'checkbox',
|
|
||||||
values: [{ id: 1, checked: false, value: '' }],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const values = parsedFieldMeta.values?.map((item) => ({
|
const values = parsedFieldMeta.values?.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
|
|||||||
@@ -311,11 +311,7 @@ export const SigningForm = ({
|
|||||||
<>
|
<>
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
{recipient.role === RecipientRole.APPROVER && !hasSignatureField ? (
|
<Trans>Please review the document before signing.</Trans>
|
||||||
<Trans>Please review the document before approving.</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>Please review the document before signing.</Trans>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<hr className="border-border mb-8 mt-4" />
|
<hr className="border-border mb-8 mt-4" />
|
||||||
@@ -339,40 +335,38 @@ export const SigningForm = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasSignatureField && (
|
<div>
|
||||||
<div>
|
<Label htmlFor="Signature">
|
||||||
<Label htmlFor="Signature">
|
<Trans>Signature</Trans>
|
||||||
<Trans>Signature</Trans>
|
</Label>
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Card className="mt-2" gradient degrees={-120}>
|
<Card className="mt-2" gradient degrees={-120}>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
className="h-44 w-full"
|
className="h-44 w-full"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
defaultValue={signature ?? undefined}
|
defaultValue={signature ?? undefined}
|
||||||
onValidityChange={(isValid) => {
|
onValidityChange={(isValid) => {
|
||||||
setSignatureValid(isValid);
|
setSignatureValid(isValid);
|
||||||
}}
|
}}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
if (signatureValid) {
|
if (signatureValid) {
|
||||||
setSignature(value);
|
setSignature(value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
allowTypedSignature={document.documentMeta?.typedSignatureEnabled}
|
allowTypedSignature={document.documentMeta?.typedSignatureEnabled}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{!signatureValid && (
|
{hasSignatureField && !signatureValid && (
|
||||||
<div className="text-destructive mt-2 text-sm">
|
<div className="text-destructive mt-2 text-sm">
|
||||||
<Trans>
|
<Trans>
|
||||||
Signature is too small. Please provide a more complete signature.
|
Signature is too small. Please provide a more complete signature.
|
||||||
</Trans>
|
</Trans>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
|
|||||||
@@ -43,10 +43,9 @@ type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
|
|||||||
export interface RejectDocumentDialogProps {
|
export interface RejectDocumentDialogProps {
|
||||||
document: Pick<Document, 'id'>;
|
document: Pick<Document, 'id'>;
|
||||||
token: string;
|
token: string;
|
||||||
onRejected?: (reason: string) => void | Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RejectDocumentDialog({ document, token, onRejected }: RejectDocumentDialogProps) {
|
export function RejectDocumentDialog({ document, token }: RejectDocumentDialogProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -80,11 +79,7 @@ export function RejectDocumentDialog({ document, token, onRejected }: RejectDocu
|
|||||||
|
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
|
||||||
if (onRejected) {
|
router.push(`/sign/${token}/rejected`);
|
||||||
await onRejected(reason);
|
|
||||||
} else {
|
|
||||||
router.push(`/sign/${token}/rejected`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/macro';
|
import { Trans } from '@lingui/macro';
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import type { Field } from '@documenso/prisma/client';
|
import type { Field } from '@documenso/prisma/client';
|
||||||
@@ -59,88 +58,62 @@ export const SignDialog = ({
|
|||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{match({ isComplete, role })
|
{isComplete ? <Trans>Complete</Trans> : <Trans>Next field</Trans>}
|
||||||
.with({ isComplete: false }, () => <Trans>Next field</Trans>)
|
|
||||||
.with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>)
|
|
||||||
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => (
|
|
||||||
<Trans>Mark as viewed</Trans>
|
|
||||||
))
|
|
||||||
.with({ isComplete: true }, () => <Trans>Complete</Trans>)
|
|
||||||
.exhaustive()}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<div className="text-foreground text-xl font-semibold">
|
<div className="text-foreground text-xl font-semibold">
|
||||||
{match(role)
|
{role === RecipientRole.VIEWER && <Trans>Complete Viewing</Trans>}
|
||||||
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
{role === RecipientRole.SIGNER && <Trans>Complete Signing</Trans>}
|
||||||
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
{role === RecipientRole.APPROVER && <Trans>Complete Approval</Trans>}
|
||||||
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
|
||||||
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
|
|
||||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
|
|
||||||
.exhaustive()}
|
|
||||||
</div>
|
</div>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<div className="text-muted-foreground max-w-[50ch]">
|
<div className="text-muted-foreground max-w-[50ch]">
|
||||||
{match(role)
|
{role === RecipientRole.VIEWER && (
|
||||||
.with(RecipientRole.VIEWER, () => (
|
<span>
|
||||||
<span>
|
<Trans>
|
||||||
<Trans>
|
<span className="inline-flex flex-wrap">
|
||||||
<span className="inline-flex flex-wrap">
|
You are about to complete viewing "
|
||||||
You are about to complete viewing "
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
{documentTitle}
|
||||||
{documentTitle}
|
|
||||||
</span>
|
|
||||||
".
|
|
||||||
</span>
|
</span>
|
||||||
<br /> Are you sure?
|
".
|
||||||
</Trans>
|
</span>
|
||||||
</span>
|
<br /> Are you sure?
|
||||||
))
|
</Trans>
|
||||||
.with(RecipientRole.SIGNER, () => (
|
</span>
|
||||||
<span>
|
)}
|
||||||
<Trans>
|
{role === RecipientRole.SIGNER && (
|
||||||
<span className="inline-flex flex-wrap">
|
<span>
|
||||||
You are about to complete signing "
|
<Trans>
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
<span className="inline-flex flex-wrap">
|
||||||
{documentTitle}
|
You are about to complete signing "
|
||||||
</span>
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
".
|
{documentTitle}
|
||||||
</span>
|
</span>
|
||||||
<br /> Are you sure?
|
".
|
||||||
</Trans>
|
</span>
|
||||||
</span>
|
<br /> Are you sure?
|
||||||
))
|
</Trans>
|
||||||
.with(RecipientRole.APPROVER, () => (
|
</span>
|
||||||
<span>
|
)}
|
||||||
<Trans>
|
{role === RecipientRole.APPROVER && (
|
||||||
<span className="inline-flex flex-wrap">
|
<span>
|
||||||
You are about to complete approving{' '}
|
<Trans>
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
<span className="inline-flex flex-wrap">
|
||||||
"{documentTitle}"
|
You are about to complete approving{' '}
|
||||||
</span>
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
.
|
"{documentTitle}"
|
||||||
</span>
|
</span>
|
||||||
<br /> Are you sure?
|
.
|
||||||
</Trans>
|
</span>
|
||||||
</span>
|
<br /> Are you sure?
|
||||||
))
|
</Trans>
|
||||||
.otherwise(() => (
|
</span>
|
||||||
<span>
|
)}
|
||||||
<Trans>
|
|
||||||
<span className="inline-flex flex-wrap">
|
|
||||||
You are about to complete viewing "
|
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
|
||||||
{documentTitle}
|
|
||||||
</span>
|
|
||||||
".
|
|
||||||
</span>
|
|
||||||
<br /> Are you sure?
|
|
||||||
</Trans>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SigningDisclosure className="mt-4" />
|
<SigningDisclosure className="mt-4" />
|
||||||
@@ -165,13 +138,9 @@ export const SignDialog = ({
|
|||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
onClick={onSignatureComplete}
|
onClick={onSignatureComplete}
|
||||||
>
|
>
|
||||||
{match(role)
|
{role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>}
|
||||||
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
|
{role === RecipientRole.SIGNER && <Trans>Sign</Trans>}
|
||||||
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
|
{role === RecipientRole.APPROVER && <Trans>Approve</Trans>}
|
||||||
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
|
|
||||||
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
|
|
||||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
|
||||||
.exhaustive()}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -182,23 +182,6 @@ export const SigningFieldContainer = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
|
|
||||||
field.fieldMeta?.label && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'absolute -top-16 left-0 right-0 rounded-md p-2 text-center text-xs text-gray-700',
|
|
||||||
{
|
|
||||||
'bg-foreground/5 border-border border': !field.inserted,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'bg-documenso-200 border-primary border': field.inserted,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{field.fieldMeta.label}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</FieldRootContainer>
|
</FieldRootContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,10 +13,6 @@ import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'
|
|||||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
import {
|
|
||||||
isFieldUnsignedAndRequired,
|
|
||||||
isRequiredField,
|
|
||||||
} from '@documenso/lib/utils/advanced-fields-helpers';
|
|
||||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@documenso/prisma/client';
|
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@documenso/prisma/client';
|
||||||
import { type DocumentData, type Field, FieldType } from '@documenso/prisma/client';
|
import { type DocumentData, type Field, FieldType } from '@documenso/prisma/client';
|
||||||
@@ -53,7 +49,7 @@ export type EmbedDirectTemplateClientPageProps = {
|
|||||||
fields: Field[];
|
fields: Field[];
|
||||||
metadata?: DocumentMeta | TemplateMeta | null;
|
metadata?: DocumentMeta | TemplateMeta | null;
|
||||||
hidePoweredBy?: boolean;
|
hidePoweredBy?: boolean;
|
||||||
allowWhiteLabelling?: boolean;
|
isPlatformOrEnterprise?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EmbedDirectTemplateClientPage = ({
|
export const EmbedDirectTemplateClientPage = ({
|
||||||
@@ -64,7 +60,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
fields,
|
fields,
|
||||||
metadata,
|
metadata,
|
||||||
hidePoweredBy = false,
|
hidePoweredBy = false,
|
||||||
allowWhiteLabelling = false,
|
isPlatformOrEnterprise = false,
|
||||||
}: EmbedDirectTemplateClientPageProps) => {
|
}: EmbedDirectTemplateClientPageProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -98,7 +94,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields);
|
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields);
|
||||||
|
|
||||||
const [pendingFields, _completedFields] = [
|
const [pendingFields, _completedFields] = [
|
||||||
localFields.filter((field) => isFieldUnsignedAndRequired(field)),
|
localFields.filter((field) => !field.inserted),
|
||||||
localFields.filter((field) => field.inserted),
|
localFields.filter((field) => field.inserted),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -116,7 +112,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
|
|
||||||
const newField: DirectTemplateLocalField = structuredClone({
|
const newField: DirectTemplateLocalField = structuredClone({
|
||||||
...field,
|
...field,
|
||||||
customText: payload.value ?? '',
|
customText: payload.value,
|
||||||
inserted: true,
|
inserted: true,
|
||||||
signedValue: payload,
|
signedValue: payload,
|
||||||
});
|
});
|
||||||
@@ -127,10 +123,8 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
created: new Date(),
|
created: new Date(),
|
||||||
recipientId: 1,
|
recipientId: 1,
|
||||||
fieldId: 1,
|
fieldId: 1,
|
||||||
signatureImageAsBase64:
|
signatureImageAsBase64: payload.value.startsWith('data:') ? payload.value : null,
|
||||||
payload.value && payload.value.startsWith('data:') ? payload.value : null,
|
typedSignature: payload.value.startsWith('data:') ? null : payload.value,
|
||||||
typedSignature:
|
|
||||||
payload.value && !payload.value.startsWith('data:') ? payload.value : null,
|
|
||||||
} satisfies Signature;
|
} satisfies Signature;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +182,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onNextFieldClick = () => {
|
const onNextFieldClick = () => {
|
||||||
validateFieldsInserted(pendingFields);
|
validateFieldsInserted(localFields);
|
||||||
|
|
||||||
setShowPendingFieldTooltip(true);
|
setShowPendingFieldTooltip(true);
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
@@ -200,7 +194,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = validateFieldsInserted(pendingFields);
|
const valid = validateFieldsInserted(localFields);
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
setShowPendingFieldTooltip(true);
|
setShowPendingFieldTooltip(true);
|
||||||
@@ -213,6 +207,12 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
|
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
localFields.forEach((field) => {
|
||||||
|
if (!field.signedValue) {
|
||||||
|
throw new Error('Invalid configuration');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
documentId,
|
documentId,
|
||||||
token: documentToken,
|
token: documentToken,
|
||||||
@@ -223,11 +223,13 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
directRecipientName: fullName,
|
directRecipientName: fullName,
|
||||||
directRecipientEmail: email,
|
directRecipientEmail: email,
|
||||||
templateUpdatedAt: updatedAt,
|
templateUpdatedAt: updatedAt,
|
||||||
signedFieldValues: localFields
|
signedFieldValues: localFields.map((field) => {
|
||||||
.filter((field) => {
|
if (!field.signedValue) {
|
||||||
return field.signedValue && (isRequiredField(field) || field.inserted);
|
throw new Error('Invalid configuration');
|
||||||
})
|
}
|
||||||
.map((field) => field.signedValue!),
|
|
||||||
|
return field.signedValue;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (window.parent) {
|
if (window.parent) {
|
||||||
@@ -286,7 +288,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
document.documentElement.classList.add('dark-mode-disabled');
|
document.documentElement.classList.add('dark-mode-disabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allowWhiteLabelling) {
|
if (isPlatformOrEnterprise) {
|
||||||
injectCss({
|
injectCss({
|
||||||
css: data.css,
|
css: data.css,
|
||||||
cssVars: data.cssVars,
|
cssVars: data.cssVars,
|
||||||
@@ -347,7 +349,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
{/* Widget */}
|
{/* Widget */}
|
||||||
<div
|
<div
|
||||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
key={isExpanded ? 'expanded' : 'collapsed'}
|
||||||
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||||
data-expanded={isExpanded || undefined}
|
data-expanded={isExpanded || undefined}
|
||||||
>
|
>
|
||||||
<div className="border-border bg-widget flex h-fit w-full flex-col rounded-xl border px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6">
|
<div className="border-border bg-widget flex h-fit w-full flex-col rounded-xl border px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6">
|
||||||
@@ -358,34 +360,19 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
<Trans>Sign document</Trans>
|
<Trans>Sign document</Trans>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{isExpanded ? (
|
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||||
<Button
|
{isExpanded ? (
|
||||||
variant="outline"
|
<LucideChevronDown
|
||||||
className="h-8 w-8 p-0 md:hidden"
|
className="text-muted-foreground h-5 w-5"
|
||||||
onClick={() => setIsExpanded(false)}
|
onClick={() => setIsExpanded(false)}
|
||||||
>
|
/>
|
||||||
<LucideChevronDown className="text-muted-foreground h-5 w-5" />
|
) : (
|
||||||
</Button>
|
<LucideChevronUp
|
||||||
) : pendingFields.length > 0 ? (
|
className="text-muted-foreground h-5 w-5"
|
||||||
<Button
|
onClick={() => setIsExpanded(true)}
|
||||||
variant="outline"
|
/>
|
||||||
className="h-8 w-8 p-0 md:hidden"
|
)}
|
||||||
onClick={() => setIsExpanded(true)}
|
</Button>
|
||||||
>
|
|
||||||
<LucideChevronUp className="text-muted-foreground h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
className="md:hidden"
|
|
||||||
disabled={isThrottled || (hasSignatureField && !signatureValid)}
|
|
||||||
loading={isSubmitting}
|
|
||||||
onClick={() => throttledOnCompleteClick()}
|
|
||||||
>
|
|
||||||
<Trans>Complete</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -430,42 +417,40 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasSignatureField && (
|
<div>
|
||||||
<div>
|
<Label htmlFor="Signature">
|
||||||
<Label htmlFor="Signature">
|
<Trans>Signature</Trans>
|
||||||
<Trans>Signature</Trans>
|
</Label>
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Card className="mt-2" gradient degrees={-120}>
|
<Card className="mt-2" gradient degrees={-120}>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
className="h-44 w-full"
|
className="h-44 w-full"
|
||||||
disabled={isThrottled || isSubmitting}
|
disabled={isThrottled || isSubmitting}
|
||||||
defaultValue={signature ?? undefined}
|
defaultValue={signature ?? undefined}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setSignature(value);
|
setSignature(value);
|
||||||
}}
|
}}
|
||||||
onValidityChange={(isValid) => {
|
onValidityChange={(isValid) => {
|
||||||
setSignatureValid(isValid);
|
setSignatureValid(isValid);
|
||||||
}}
|
}}
|
||||||
allowTypedSignature={Boolean(
|
allowTypedSignature={Boolean(
|
||||||
metadata &&
|
metadata &&
|
||||||
'typedSignatureEnabled' in metadata &&
|
'typedSignatureEnabled' in metadata &&
|
||||||
metadata.typedSignatureEnabled,
|
metadata.typedSignatureEnabled,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{hasSignatureField && !signatureValid && (
|
{hasSignatureField && !signatureValid && (
|
||||||
<div className="text-destructive mt-2 text-sm">
|
<div className="text-destructive mt-2 text-sm">
|
||||||
<Trans>
|
<Trans>
|
||||||
Signature is too small. Please provide a more complete signature.
|
Signature is too small. Please provide a more complete signature.
|
||||||
</Trans>
|
</Trans>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { notFound } from 'next/navigation';
|
|||||||
|
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { isCommunityPlan as isUserCommunityPlan } from '@documenso/ee/server-only/util/is-community-plan';
|
|
||||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||||
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
@@ -56,16 +55,12 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
|
|||||||
documentAuth: template.authOptions,
|
documentAuth: template.authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isPlatformDocument, isEnterpriseDocument, isCommunityPlan] = await Promise.all([
|
const [isPlatformDocument, isEnterpriseDocument] = await Promise.all([
|
||||||
isDocumentPlatform(template),
|
isDocumentPlatform(template),
|
||||||
isUserEnterprise({
|
isUserEnterprise({
|
||||||
userId: template.userId,
|
userId: template.userId,
|
||||||
teamId: template.teamId ?? undefined,
|
teamId: template.teamId ?? undefined,
|
||||||
}),
|
}),
|
||||||
isUserCommunityPlan({
|
|
||||||
userId: template.userId,
|
|
||||||
teamId: template.teamId ?? undefined,
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isAccessAuthValid = match(derivedRecipientAccessAuth)
|
const isAccessAuthValid = match(derivedRecipientAccessAuth)
|
||||||
@@ -110,10 +105,8 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
|
|||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
metadata={template.templateMeta}
|
metadata={template.templateMeta}
|
||||||
hidePoweredBy={
|
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
|
||||||
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
|
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
|
||||||
}
|
|
||||||
allowWhiteLabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
|
|
||||||
/>
|
/>
|
||||||
</RecipientProvider>
|
</RecipientProvider>
|
||||||
</DocumentAuthProvider>
|
</DocumentAuthProvider>
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { Trans } from '@lingui/macro';
|
|
||||||
import { XCircle } from 'lucide-react';
|
|
||||||
|
|
||||||
import type { Signature } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
export type EmbedDocumentRejectedPageProps = {
|
|
||||||
name?: string;
|
|
||||||
signature?: Signature;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EmbedDocumentRejected = ({ name }: EmbedDocumentRejectedPageProps) => {
|
|
||||||
return (
|
|
||||||
<div className="embed--DocumentRejected relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<div className="flex items-center gap-x-4">
|
|
||||||
<XCircle className="text-destructive h-10 w-10" />
|
|
||||||
|
|
||||||
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
|
||||||
<Trans>Document Rejected</Trans>
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-destructive mt-4 flex items-center text-center text-sm">
|
|
||||||
<Trans>You have rejected this document</Trans>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-6 max-w-[60ch] text-center text-sm">
|
|
||||||
<Trans>
|
|
||||||
The document owner has been notified of your decision. They may contact you with further
|
|
||||||
instructions if necessary.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 max-w-[60ch] text-center text-sm">
|
|
||||||
<Trans>No further action is required from you at this time.</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
|
import { useEffect, useId, useLayoutEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { Trans, msg } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
@@ -8,16 +8,9 @@ import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
|||||||
|
|
||||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
|
||||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import type { DocumentMeta, TemplateMeta } from '@documenso/prisma/client';
|
import type { DocumentMeta, TemplateMeta } from '@documenso/prisma/client';
|
||||||
import {
|
import { type DocumentData, type Field, FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||||
type DocumentData,
|
|
||||||
type Field,
|
|
||||||
FieldType,
|
|
||||||
RecipientRole,
|
|
||||||
SigningStatus,
|
|
||||||
} from '@documenso/prisma/client';
|
|
||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
@@ -33,13 +26,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
|
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
|
||||||
import { RecipientProvider } from '~/app/(signing)/sign/[token]/recipient-context';
|
import { RecipientProvider } from '~/app/(signing)/sign/[token]/recipient-context';
|
||||||
import { RejectDocumentDialog } from '~/app/(signing)/sign/[token]/reject-document-dialog';
|
|
||||||
import { Logo } from '~/components/branding/logo';
|
import { Logo } from '~/components/branding/logo';
|
||||||
|
|
||||||
import { EmbedClientLoading } from '../../client-loading';
|
import { EmbedClientLoading } from '../../client-loading';
|
||||||
import { EmbedDocumentCompleted } from '../../completed';
|
import { EmbedDocumentCompleted } from '../../completed';
|
||||||
import { EmbedDocumentFields } from '../../document-fields';
|
import { EmbedDocumentFields } from '../../document-fields';
|
||||||
import { EmbedDocumentRejected } from '../../rejected';
|
|
||||||
import { injectCss } from '../../util';
|
import { injectCss } from '../../util';
|
||||||
import { ZSignDocumentEmbedDataSchema } from './schema';
|
import { ZSignDocumentEmbedDataSchema } from './schema';
|
||||||
|
|
||||||
@@ -52,7 +43,7 @@ export type EmbedSignDocumentClientPageProps = {
|
|||||||
metadata?: DocumentMeta | TemplateMeta | null;
|
metadata?: DocumentMeta | TemplateMeta | null;
|
||||||
isCompleted?: boolean;
|
isCompleted?: boolean;
|
||||||
hidePoweredBy?: boolean;
|
hidePoweredBy?: boolean;
|
||||||
allowWhitelabelling?: boolean;
|
isPlatformOrEnterprise?: boolean;
|
||||||
allRecipients?: RecipientWithFields[];
|
allRecipients?: RecipientWithFields[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -65,7 +56,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
metadata,
|
metadata,
|
||||||
isCompleted,
|
isCompleted,
|
||||||
hidePoweredBy = false,
|
hidePoweredBy = false,
|
||||||
allowWhitelabelling = false,
|
isPlatformOrEnterprise = false,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
}: EmbedSignDocumentClientPageProps) => {
|
}: EmbedSignDocumentClientPageProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
@@ -84,9 +75,6 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||||
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||||
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
|
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
|
||||||
const [hasRejectedDocument, setHasRejectedDocument] = useState(
|
|
||||||
recipient.signingStatus === SigningStatus.REJECTED,
|
|
||||||
);
|
|
||||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
|
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
|
||||||
allRecipients.length > 0 ? allRecipients[0].id : null,
|
allRecipients.length > 0 ? allRecipients[0].id : null,
|
||||||
);
|
);
|
||||||
@@ -95,34 +83,25 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||||
|
|
||||||
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
|
||||||
|
|
||||||
const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId);
|
const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId);
|
||||||
const isAssistantMode = recipient.role === RecipientRole.ASSISTANT;
|
const isAssistantMode = recipient.role === RecipientRole.ASSISTANT;
|
||||||
|
|
||||||
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
|
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
|
||||||
|
|
||||||
const [pendingFields, _completedFields] = [
|
const [pendingFields, _completedFields] = [
|
||||||
fields.filter(
|
fields.filter((field) => field.recipientId === recipient.id && !field.inserted),
|
||||||
(field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field),
|
|
||||||
),
|
|
||||||
fields.filter((field) => field.inserted),
|
fields.filter((field) => field.inserted),
|
||||||
];
|
];
|
||||||
|
|
||||||
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
|
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
|
||||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||||
|
|
||||||
const fieldsRequiringValidation = useMemo(
|
|
||||||
() => fields.filter(isFieldUnsignedAndRequired),
|
|
||||||
[fields],
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
||||||
|
|
||||||
const assistantSignersId = useId();
|
const assistantSignersId = useId();
|
||||||
|
|
||||||
const onNextFieldClick = () => {
|
const onNextFieldClick = () => {
|
||||||
validateFieldsInserted(fieldsRequiringValidation);
|
validateFieldsInserted(fields);
|
||||||
|
|
||||||
setShowPendingFieldTooltip(true);
|
setShowPendingFieldTooltip(true);
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
@@ -134,7 +113,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = validateFieldsInserted(fieldsRequiringValidation);
|
const valid = validateFieldsInserted(fields);
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
setShowPendingFieldTooltip(true);
|
setShowPendingFieldTooltip(true);
|
||||||
@@ -182,25 +161,6 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDocumentRejected = (reason: string) => {
|
|
||||||
if (window.parent) {
|
|
||||||
window.parent.postMessage(
|
|
||||||
{
|
|
||||||
action: 'document-rejected',
|
|
||||||
data: {
|
|
||||||
token,
|
|
||||||
documentId,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
reason,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'*',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasRejectedDocument(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const hash = window.location.hash.slice(1);
|
const hash = window.location.hash.slice(1);
|
||||||
|
|
||||||
@@ -214,13 +174,12 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
// Since a recipient can be provided a name we can lock it without requiring
|
// Since a recipient can be provided a name we can lock it without requiring
|
||||||
// a to be provided by the parent application, unlike direct templates.
|
// a to be provided by the parent application, unlike direct templates.
|
||||||
setIsNameLocked(!!data.lockName);
|
setIsNameLocked(!!data.lockName);
|
||||||
setAllowDocumentRejection(!!data.allowDocumentRejection);
|
|
||||||
|
|
||||||
if (data.darkModeDisabled) {
|
if (data.darkModeDisabled) {
|
||||||
document.documentElement.classList.add('dark-mode-disabled');
|
document.documentElement.classList.add('dark-mode-disabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allowWhitelabelling) {
|
if (isPlatformOrEnterprise) {
|
||||||
injectCss({
|
injectCss({
|
||||||
css: data.css,
|
css: data.css,
|
||||||
cssVars: data.cssVars,
|
cssVars: data.cssVars,
|
||||||
@@ -249,10 +208,6 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
}
|
}
|
||||||
}, [hasFinishedInit, hasDocumentLoaded]);
|
}, [hasFinishedInit, hasDocumentLoaded]);
|
||||||
|
|
||||||
if (hasRejectedDocument) {
|
|
||||||
return <EmbedDocumentRejected name={fullName} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasCompletedDocument) {
|
if (hasCompletedDocument) {
|
||||||
return (
|
return (
|
||||||
<EmbedDocumentCompleted
|
<EmbedDocumentCompleted
|
||||||
@@ -274,16 +229,6 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
||||||
|
|
||||||
{allowDocumentRejection && (
|
|
||||||
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
|
||||||
<RejectDocumentDialog
|
|
||||||
document={{ id: documentId }}
|
|
||||||
token={token}
|
|
||||||
onRejected={onDocumentRejected}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||||
{/* Viewer */}
|
{/* Viewer */}
|
||||||
<div className="embed--DocumentViewer flex-1">
|
<div className="embed--DocumentViewer flex-1">
|
||||||
@@ -296,7 +241,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
{/* Widget */}
|
{/* Widget */}
|
||||||
<div
|
<div
|
||||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
key={isExpanded ? 'expanded' : 'collapsed'}
|
||||||
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||||
data-expanded={isExpanded || undefined}
|
data-expanded={isExpanded || undefined}
|
||||||
>
|
>
|
||||||
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
||||||
@@ -311,36 +256,19 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{isExpanded ? (
|
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||||
<Button
|
{isExpanded ? (
|
||||||
variant="outline"
|
<LucideChevronDown
|
||||||
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
className="text-muted-foreground h-5 w-5"
|
||||||
onClick={() => setIsExpanded(false)}
|
onClick={() => setIsExpanded(false)}
|
||||||
>
|
/>
|
||||||
<LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
|
) : (
|
||||||
</Button>
|
<LucideChevronUp
|
||||||
) : pendingFields.length > 0 ? (
|
className="text-muted-foreground h-5 w-5"
|
||||||
<Button
|
onClick={() => setIsExpanded(true)}
|
||||||
variant="outline"
|
/>
|
||||||
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
)}
|
||||||
onClick={() => setIsExpanded(true)}
|
</Button>
|
||||||
>
|
|
||||||
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
className="md:hidden"
|
|
||||||
disabled={
|
|
||||||
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
|
|
||||||
}
|
|
||||||
loading={isSubmitting}
|
|
||||||
onClick={() => throttledOnCompleteClick()}
|
|
||||||
>
|
|
||||||
<Trans>Complete</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -444,42 +372,40 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasSignatureField && (
|
<div>
|
||||||
<div>
|
<Label htmlFor="Signature">
|
||||||
<Label htmlFor="Signature">
|
<Trans>Signature</Trans>
|
||||||
<Trans>Signature</Trans>
|
</Label>
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Card className="mt-2" gradient degrees={-120}>
|
<Card className="mt-2" gradient degrees={-120}>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
className="h-44 w-full"
|
className="h-44 w-full"
|
||||||
disabled={isThrottled || isSubmitting}
|
disabled={isThrottled || isSubmitting}
|
||||||
defaultValue={signature ?? undefined}
|
defaultValue={signature ?? undefined}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setSignature(value);
|
setSignature(value);
|
||||||
}}
|
}}
|
||||||
onValidityChange={(isValid) => {
|
onValidityChange={(isValid) => {
|
||||||
setSignatureValid(isValid);
|
setSignatureValid(isValid);
|
||||||
}}
|
}}
|
||||||
allowTypedSignature={Boolean(
|
allowTypedSignature={Boolean(
|
||||||
metadata &&
|
metadata &&
|
||||||
'typedSignatureEnabled' in metadata &&
|
'typedSignatureEnabled' in metadata &&
|
||||||
metadata.typedSignatureEnabled,
|
metadata.typedSignatureEnabled,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{hasSignatureField && !signatureValid && (
|
{hasSignatureField && !signatureValid && (
|
||||||
<div className="text-destructive mt-2 text-sm">
|
<div className="text-destructive mt-2 text-sm">
|
||||||
<Trans>
|
<Trans>
|
||||||
Signature is too small. Please provide a more complete signature.
|
Signature is too small. Please provide a more complete signature.
|
||||||
</Trans>
|
</Trans>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -494,7 +420,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
|
className="col-start-2"
|
||||||
disabled={
|
disabled={
|
||||||
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
|
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { notFound } from 'next/navigation';
|
|||||||
|
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { isCommunityPlan as isUserCommunityPlan } from '@documenso/ee/server-only/util/is-community-plan';
|
|
||||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||||
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
@@ -63,16 +62,12 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
|
|||||||
return <EmbedPaywall />;
|
return <EmbedPaywall />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [isPlatformDocument, isEnterpriseDocument, isCommunityPlan] = await Promise.all([
|
const [isPlatformDocument, isEnterpriseDocument] = await Promise.all([
|
||||||
isDocumentPlatform(document),
|
isDocumentPlatform(document),
|
||||||
isUserEnterprise({
|
isUserEnterprise({
|
||||||
userId: document.userId,
|
userId: document.userId,
|
||||||
teamId: document.teamId ?? undefined,
|
teamId: document.teamId ?? undefined,
|
||||||
}),
|
}),
|
||||||
isUserCommunityPlan({
|
|
||||||
userId: document.userId,
|
|
||||||
teamId: document.teamId ?? undefined,
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
@@ -131,10 +126,8 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
metadata={document.documentMeta}
|
metadata={document.documentMeta}
|
||||||
isCompleted={document.status === DocumentStatus.COMPLETED}
|
isCompleted={document.status === DocumentStatus.COMPLETED}
|
||||||
hidePoweredBy={
|
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
|
||||||
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
|
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
|
||||||
}
|
|
||||||
allowWhitelabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
|
|
||||||
allRecipients={allRecipients}
|
allRecipients={allRecipients}
|
||||||
/>
|
/>
|
||||||
</DocumentAuthProvider>
|
</DocumentAuthProvider>
|
||||||
|
|||||||
@@ -13,5 +13,4 @@ export const ZSignDocumentEmbedDataSchema = ZBaseEmbedDataSchema.extend({
|
|||||||
.optional()
|
.optional()
|
||||||
.transform((value) => value || undefined),
|
.transform((value) => value || undefined),
|
||||||
lockName: z.boolean().optional().default(false),
|
lockName: z.boolean().optional().default(false),
|
||||||
allowDocumentRejection: z.boolean().optional(),
|
|
||||||
});
|
});
|
||||||
|
|||||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "1.9.1-rc.9",
|
"version": "1.9.1-rc.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "1.9.1-rc.9",
|
"version": "1.9.1-rc.1",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
},
|
},
|
||||||
"apps/web": {
|
"apps/web": {
|
||||||
"name": "@documenso/web",
|
"name": "@documenso/web",
|
||||||
"version": "1.9.1-rc.9",
|
"version": "1.9.1-rc.1",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/api": "*",
|
"@documenso/api": "*",
|
||||||
@@ -35722,6 +35722,21 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"packages/trpc/node_modules/@next/swc-win32-ia32-msvc": {
|
||||||
|
"version": "14.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.6.tgz",
|
||||||
|
"integrity": "sha512-hNukAxq7hu4o5/UjPp5jqoBEtrpCbOmnUqZSKNJG8GrUVzfq0ucdhQFVrHcLRMvQcwqqDh1a5AJN9ORnNDpgBQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.9.1-rc.9",
|
"version": "1.9.1-rc.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"build:web": "turbo run build --filter=@documenso/web",
|
"build:web": "turbo run build --filter=@documenso/web",
|
||||||
|
|||||||
@@ -586,7 +586,6 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
recipients: body.recipients,
|
recipients: body.recipients,
|
||||||
prefillFields: body.prefillFields,
|
|
||||||
override: {
|
override: {
|
||||||
title: body.title,
|
title: body.title,
|
||||||
...body.meta,
|
...body.meta,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
ZRecipientActionAuthTypesSchema,
|
ZRecipientActionAuthTypesSchema,
|
||||||
} from '@documenso/lib/types/document-auth';
|
} from '@documenso/lib/types/document-auth';
|
||||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||||
import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
import {
|
import {
|
||||||
DocumentDataType,
|
DocumentDataType,
|
||||||
DocumentDistributionMethod,
|
DocumentDistributionMethod,
|
||||||
@@ -270,12 +270,11 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
|||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(schema) => {
|
(schema) => {
|
||||||
const emails = schema.map((signer) => signer.email.toLowerCase());
|
|
||||||
const ids = schema.map((signer) => signer.id);
|
const ids = schema.map((signer) => signer.id);
|
||||||
|
|
||||||
return new Set(emails).size === emails.length && new Set(ids).size === ids.length;
|
return new Set(ids).size === ids.length;
|
||||||
},
|
},
|
||||||
{ message: 'Recipient IDs and emails must be unique' },
|
{ message: 'Recipient IDs must be unique' },
|
||||||
),
|
),
|
||||||
meta: z
|
meta: z
|
||||||
.object({
|
.object({
|
||||||
@@ -299,7 +298,6 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
|||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
|
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
|
||||||
prefillFields: z.array(ZFieldMetaPrefillFieldsSchema).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TGenerateDocumentFromTemplateMutationSchema = z.infer<
|
export type TGenerateDocumentFromTemplateMutationSchema = z.infer<
|
||||||
|
|||||||
@@ -1,612 +0,0 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
|
||||||
|
|
||||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
|
||||||
import type { TCheckboxFieldMeta, TRadioFieldMeta } from '@documenso/lib/types/field-meta';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
|
||||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
|
||||||
|
|
||||||
import { apiSignin } from '../../fixtures/authentication';
|
|
||||||
|
|
||||||
test.describe('Template Field Prefill API v1', () => {
|
|
||||||
test('should create a document from template with prefilled fields', async ({
|
|
||||||
page,
|
|
||||||
request,
|
|
||||||
}) => {
|
|
||||||
// 1. Create a user
|
|
||||||
const user = await seedUser();
|
|
||||||
|
|
||||||
// 2. Create an API token for the user
|
|
||||||
const { token } = await createApiToken({
|
|
||||||
userId: user.id,
|
|
||||||
tokenName: 'test-token',
|
|
||||||
expiresIn: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Create a template with seedBlankTemplate
|
|
||||||
const template = await seedBlankTemplate(user, {
|
|
||||||
createTemplateOptions: {
|
|
||||||
title: 'Template with Advanced Fields',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Create a recipient for the template
|
|
||||||
const recipient = await prisma.recipient.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
token: 'test-token',
|
|
||||||
readStatus: 'NOT_OPENED',
|
|
||||||
sendStatus: 'NOT_SENT',
|
|
||||||
signingStatus: 'NOT_SIGNED',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Add fields to the template
|
|
||||||
// Add TEXT field
|
|
||||||
const textField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.TEXT,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 5,
|
|
||||||
width: 10,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'text',
|
|
||||||
label: 'Text Field',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add NUMBER field
|
|
||||||
const numberField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.NUMBER,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 15,
|
|
||||||
width: 10,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'number',
|
|
||||||
label: 'Number Field',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add RADIO field
|
|
||||||
const radioField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.RADIO,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 25,
|
|
||||||
width: 10,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'radio',
|
|
||||||
label: 'Radio Field',
|
|
||||||
values: [
|
|
||||||
{ id: 1, value: 'Option A', checked: false },
|
|
||||||
{ id: 2, value: 'Option B', checked: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add CHECKBOX field
|
|
||||||
const checkboxField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.CHECKBOX,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 35,
|
|
||||||
width: 10,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'checkbox',
|
|
||||||
label: 'Checkbox Field',
|
|
||||||
values: [
|
|
||||||
{ id: 1, value: 'Check A', checked: false },
|
|
||||||
{ id: 2, value: 'Check B', checked: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add DROPDOWN field
|
|
||||||
const dropdownField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.DROPDOWN,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 45,
|
|
||||||
width: 10,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'dropdown',
|
|
||||||
label: 'Dropdown Field',
|
|
||||||
values: [{ value: 'Select A' }, { value: 'Select B' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 6. Sign in as the user
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: user.email,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 7. Navigate to the template
|
|
||||||
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
|
|
||||||
|
|
||||||
// 8. Create a document from the template with prefilled fields
|
|
||||||
const response = await request.post(
|
|
||||||
`${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
title: 'Document with Prefilled Fields',
|
|
||||||
recipients: [
|
|
||||||
{
|
|
||||||
id: recipient.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
role: 'SIGNER',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
prefillFields: [
|
|
||||||
{
|
|
||||||
id: textField.id,
|
|
||||||
type: 'text',
|
|
||||||
label: 'Prefilled Text',
|
|
||||||
value: 'This is prefilled text',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: numberField.id,
|
|
||||||
type: 'number',
|
|
||||||
label: 'Prefilled Number',
|
|
||||||
value: '42',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: radioField.id,
|
|
||||||
type: 'radio',
|
|
||||||
label: 'Prefilled Radio',
|
|
||||||
value: 'Option A',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: checkboxField.id,
|
|
||||||
type: 'checkbox',
|
|
||||||
label: 'Prefilled Checkbox',
|
|
||||||
value: ['Check A', 'Check B'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: dropdownField.id,
|
|
||||||
type: 'dropdown',
|
|
||||||
label: 'Prefilled Dropdown',
|
|
||||||
value: 'Select B',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const responseData = await response.json();
|
|
||||||
|
|
||||||
expect(response.ok()).toBeTruthy();
|
|
||||||
expect(response.status()).toBe(200);
|
|
||||||
|
|
||||||
expect(responseData.documentId).toBeDefined();
|
|
||||||
|
|
||||||
// 9. Verify the document was created with prefilled fields
|
|
||||||
const document = await prisma.document.findUnique({
|
|
||||||
where: {
|
|
||||||
id: responseData.documentId,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
fields: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(document).not.toBeNull();
|
|
||||||
|
|
||||||
// 10. Verify each field has the correct prefilled values
|
|
||||||
const documentTextField = document?.fields.find(
|
|
||||||
(field) => field.type === FieldType.TEXT && field.fieldMeta?.type === 'text',
|
|
||||||
);
|
|
||||||
expect(documentTextField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'text',
|
|
||||||
label: 'Prefilled Text',
|
|
||||||
text: 'This is prefilled text',
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentNumberField = document?.fields.find(
|
|
||||||
(field) => field.type === FieldType.NUMBER && field.fieldMeta?.type === 'number',
|
|
||||||
);
|
|
||||||
expect(documentNumberField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'number',
|
|
||||||
label: 'Prefilled Number',
|
|
||||||
value: '42',
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentRadioField = document?.fields.find(
|
|
||||||
(field) => field.type === FieldType.RADIO && field.fieldMeta?.type === 'radio',
|
|
||||||
);
|
|
||||||
expect(documentRadioField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'radio',
|
|
||||||
label: 'Prefilled Radio',
|
|
||||||
});
|
|
||||||
// Check that the correct radio option is selected
|
|
||||||
const radioValues = (documentRadioField?.fieldMeta as TRadioFieldMeta)?.values || [];
|
|
||||||
const selectedRadioOption = radioValues.find((option) => option.checked);
|
|
||||||
expect(selectedRadioOption?.value).toBe('Option A');
|
|
||||||
|
|
||||||
const documentCheckboxField = document?.fields.find(
|
|
||||||
(field) => field.type === FieldType.CHECKBOX && field.fieldMeta?.type === 'checkbox',
|
|
||||||
);
|
|
||||||
expect(documentCheckboxField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'checkbox',
|
|
||||||
label: 'Prefilled Checkbox',
|
|
||||||
});
|
|
||||||
// Check that the correct checkbox options are selected
|
|
||||||
const checkboxValues = (documentCheckboxField?.fieldMeta as TCheckboxFieldMeta)?.values || [];
|
|
||||||
const checkedOptions = checkboxValues.filter((option) => option.checked);
|
|
||||||
expect(checkedOptions.length).toBe(2);
|
|
||||||
expect(checkedOptions.map((option) => option.value)).toContain('Check A');
|
|
||||||
expect(checkedOptions.map((option) => option.value)).toContain('Check B');
|
|
||||||
|
|
||||||
const documentDropdownField = document?.fields.find(
|
|
||||||
(field) => field.type === FieldType.DROPDOWN && field.fieldMeta?.type === 'dropdown',
|
|
||||||
);
|
|
||||||
expect(documentDropdownField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'dropdown',
|
|
||||||
label: 'Prefilled Dropdown',
|
|
||||||
defaultValue: 'Select B',
|
|
||||||
});
|
|
||||||
|
|
||||||
// 11. Sign in as the recipient and verify the prefilled fields are visible
|
|
||||||
const documentRecipient = await prisma.recipient.findFirst({
|
|
||||||
where: {
|
|
||||||
documentId: document?.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send the document to the recipient
|
|
||||||
const sendResponse = await request.post(
|
|
||||||
`${WEBAPP_BASE_URL}/api/v1/documents/${document?.id}/send`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
sendEmail: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(sendResponse.ok()).toBeTruthy();
|
|
||||||
expect(sendResponse.status()).toBe(200);
|
|
||||||
|
|
||||||
expect(documentRecipient).not.toBeNull();
|
|
||||||
|
|
||||||
// Visit the signing page
|
|
||||||
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
|
|
||||||
|
|
||||||
// Verify the prefilled fields are visible with correct values
|
|
||||||
// Text field
|
|
||||||
await expect(page.getByText('This is prefilled')).toBeVisible();
|
|
||||||
|
|
||||||
// Number field
|
|
||||||
await expect(page.getByText('42')).toBeVisible();
|
|
||||||
|
|
||||||
// Radio field
|
|
||||||
await expect(page.getByText('Option A')).toBeVisible();
|
|
||||||
await expect(page.getByRole('radio', { name: 'Option A' })).toBeChecked();
|
|
||||||
|
|
||||||
// Checkbox field
|
|
||||||
await expect(page.getByText('Check A')).toBeVisible();
|
|
||||||
await expect(page.getByText('Check B')).toBeVisible();
|
|
||||||
await expect(page.getByRole('checkbox', { name: 'Check A' })).toBeChecked();
|
|
||||||
await expect(page.getByRole('checkbox', { name: 'Check B' })).toBeChecked();
|
|
||||||
|
|
||||||
// Dropdown field
|
|
||||||
await expect(page.getByText('Select B')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should create a document from template without prefilled fields', async ({
|
|
||||||
page,
|
|
||||||
request,
|
|
||||||
}) => {
|
|
||||||
// 1. Create a user
|
|
||||||
const user = await seedUser();
|
|
||||||
|
|
||||||
// 2. Create an API token for the user
|
|
||||||
const { token } = await createApiToken({
|
|
||||||
userId: user.id,
|
|
||||||
tokenName: 'test-token',
|
|
||||||
expiresIn: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Create a template with seedBlankTemplate
|
|
||||||
const template = await seedBlankTemplate(user, {
|
|
||||||
createTemplateOptions: {
|
|
||||||
title: 'Template with Default Fields',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Create a recipient for the template
|
|
||||||
const recipient = await prisma.recipient.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
token: 'test-token',
|
|
||||||
readStatus: 'NOT_OPENED',
|
|
||||||
sendStatus: 'NOT_SENT',
|
|
||||||
signingStatus: 'NOT_SIGNED',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Add fields to the template
|
|
||||||
// Add TEXT field
|
|
||||||
const textField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.TEXT,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 5,
|
|
||||||
width: 10,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'text',
|
|
||||||
label: 'Default Text Field',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add NUMBER field
|
|
||||||
const numberField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.NUMBER,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 15,
|
|
||||||
width: 10,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'number',
|
|
||||||
label: 'Default Number Field',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 6. Sign in as the user
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: user.email,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 7. Navigate to the template
|
|
||||||
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
|
|
||||||
|
|
||||||
// 8. Create a document from the template without prefilled fields
|
|
||||||
const response = await request.post(
|
|
||||||
`${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
title: 'Document with Default Fields',
|
|
||||||
recipients: [
|
|
||||||
{
|
|
||||||
id: recipient.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
role: 'SIGNER',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const responseData = await response.json();
|
|
||||||
|
|
||||||
expect(response.ok()).toBeTruthy();
|
|
||||||
expect(response.status()).toBe(200);
|
|
||||||
|
|
||||||
expect(responseData.documentId).toBeDefined();
|
|
||||||
|
|
||||||
// 9. Verify the document was created with default fields
|
|
||||||
const document = await prisma.document.findUnique({
|
|
||||||
where: {
|
|
||||||
id: responseData.documentId,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
fields: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(document).not.toBeNull();
|
|
||||||
|
|
||||||
// 10. Verify fields have their default values
|
|
||||||
const documentTextField = document?.fields.find((field) => field.type === FieldType.TEXT);
|
|
||||||
expect(documentTextField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'text',
|
|
||||||
label: 'Default Text Field',
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentNumberField = document?.fields.find((field) => field.type === FieldType.NUMBER);
|
|
||||||
expect(documentNumberField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'number',
|
|
||||||
label: 'Default Number Field',
|
|
||||||
});
|
|
||||||
|
|
||||||
// 11. Sign in as the recipient and verify the default fields are visible
|
|
||||||
const documentRecipient = await prisma.recipient.findFirst({
|
|
||||||
where: {
|
|
||||||
documentId: document?.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(documentRecipient).not.toBeNull();
|
|
||||||
|
|
||||||
const sendResponse = await request.post(
|
|
||||||
`${WEBAPP_BASE_URL}/api/v1/documents/${document?.id}/send`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
sendEmail: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(sendResponse.ok()).toBeTruthy();
|
|
||||||
expect(sendResponse.status()).toBe(200);
|
|
||||||
|
|
||||||
// Visit the signing page
|
|
||||||
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
|
|
||||||
|
|
||||||
// Verify the default fields are visible with correct labels
|
|
||||||
await expect(page.getByText('Default Text Field')).toBeVisible();
|
|
||||||
await expect(page.getByText('Default Number Field')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle invalid field prefill values', async ({ request }) => {
|
|
||||||
// 1. Create a user
|
|
||||||
const user = await seedUser();
|
|
||||||
|
|
||||||
// 2. Create an API token for the user
|
|
||||||
const { token } = await createApiToken({
|
|
||||||
userId: user.id,
|
|
||||||
tokenName: 'test-token',
|
|
||||||
expiresIn: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Create a template using seedBlankTemplate
|
|
||||||
const template = await seedBlankTemplate(user, {
|
|
||||||
createTemplateOptions: {
|
|
||||||
title: 'Template for Invalid Test',
|
|
||||||
visibility: 'EVERYONE',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Create a recipient for the template
|
|
||||||
const recipient = await prisma.recipient.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
token: 'test-token',
|
|
||||||
readStatus: 'NOT_OPENED',
|
|
||||||
sendStatus: 'NOT_SENT',
|
|
||||||
signingStatus: 'NOT_SIGNED',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Add a field to the template
|
|
||||||
const field = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.RADIO,
|
|
||||||
page: 1,
|
|
||||||
positionX: 100,
|
|
||||||
positionY: 100,
|
|
||||||
width: 100,
|
|
||||||
height: 50,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'radio',
|
|
||||||
label: 'Radio Field',
|
|
||||||
values: [
|
|
||||||
{ id: 1, value: 'Option A', checked: false },
|
|
||||||
{ id: 2, value: 'Option B', checked: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 6. Try to create a document with invalid prefill value
|
|
||||||
const response = await request.post(
|
|
||||||
`${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
title: 'Document with Invalid Prefill',
|
|
||||||
recipients: [
|
|
||||||
{
|
|
||||||
id: recipient.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
role: 'SIGNER',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
prefillFields: [
|
|
||||||
{
|
|
||||||
id: field.id,
|
|
||||||
type: 'radio',
|
|
||||||
label: 'Invalid Radio',
|
|
||||||
value: 'Non-existent Option', // This option doesn't exist
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// 7. Verify the request fails with appropriate error
|
|
||||||
expect(response.ok()).toBeFalsy();
|
|
||||||
expect(response.status()).toBe(400);
|
|
||||||
|
|
||||||
const errorData = await response.json();
|
|
||||||
expect(errorData.message).toContain('not found in options for RADIO field');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,600 +0,0 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
|
||||||
|
|
||||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
|
||||||
import type { TCheckboxFieldMeta, TRadioFieldMeta } from '@documenso/lib/types/field-meta';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
|
||||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
|
||||||
|
|
||||||
import { apiSignin } from '../../fixtures/authentication';
|
|
||||||
|
|
||||||
test.describe('Template Field Prefill API v2', () => {
|
|
||||||
test('should create a document from template with prefilled fields', async ({
|
|
||||||
page,
|
|
||||||
request,
|
|
||||||
}) => {
|
|
||||||
// 1. Create a user
|
|
||||||
const user = await seedUser();
|
|
||||||
|
|
||||||
// 2. Create an API token for the user
|
|
||||||
const { token } = await createApiToken({
|
|
||||||
userId: user.id,
|
|
||||||
tokenName: 'test-token',
|
|
||||||
expiresIn: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Create a template with seedBlankTemplate
|
|
||||||
const template = await seedBlankTemplate(user, {
|
|
||||||
createTemplateOptions: {
|
|
||||||
title: 'Template with Advanced Fields V2',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Create a recipient for the template
|
|
||||||
const recipient = await prisma.recipient.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
token: 'test-token',
|
|
||||||
readStatus: 'NOT_OPENED',
|
|
||||||
sendStatus: 'NOT_SENT',
|
|
||||||
signingStatus: 'NOT_SIGNED',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Add fields to the template
|
|
||||||
// Add TEXT field
|
|
||||||
const textField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.TEXT,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 5,
|
|
||||||
width: 20,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'text',
|
|
||||||
label: 'Text Field',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add NUMBER field
|
|
||||||
const numberField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.NUMBER,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 15,
|
|
||||||
width: 20,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'number',
|
|
||||||
label: 'Number Field',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add RADIO field
|
|
||||||
const radioField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.RADIO,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 25,
|
|
||||||
width: 20,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'radio',
|
|
||||||
label: 'Radio Field',
|
|
||||||
values: [
|
|
||||||
{ id: 1, value: 'Option A', checked: false },
|
|
||||||
{ id: 2, value: 'Option B', checked: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add CHECKBOX field
|
|
||||||
const checkboxField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.CHECKBOX,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 35,
|
|
||||||
width: 20,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'checkbox',
|
|
||||||
label: 'Checkbox Field',
|
|
||||||
values: [
|
|
||||||
{ id: 1, value: 'Check A', checked: false },
|
|
||||||
{ id: 2, value: 'Check B', checked: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add DROPDOWN field
|
|
||||||
const dropdownField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.DROPDOWN,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 45,
|
|
||||||
width: 20,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'dropdown',
|
|
||||||
label: 'Dropdown Field',
|
|
||||||
values: [{ value: 'Select A' }, { value: 'Select B' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 6. Sign in as the user
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: user.email,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 7. Navigate to the template
|
|
||||||
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
|
|
||||||
|
|
||||||
// 8. Create a document from the template with prefilled fields using v2 API
|
|
||||||
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipients: [
|
|
||||||
{
|
|
||||||
id: recipient.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
prefillFields: [
|
|
||||||
{
|
|
||||||
id: textField.id,
|
|
||||||
type: 'text',
|
|
||||||
label: 'Prefilled Text',
|
|
||||||
value: 'This is prefilled text',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: numberField.id,
|
|
||||||
type: 'number',
|
|
||||||
label: 'Prefilled Number',
|
|
||||||
value: '42',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: radioField.id,
|
|
||||||
type: 'radio',
|
|
||||||
label: 'Prefilled Radio',
|
|
||||||
value: 'Option A',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: checkboxField.id,
|
|
||||||
type: 'checkbox',
|
|
||||||
label: 'Prefilled Checkbox',
|
|
||||||
value: ['Check A', 'Check B'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: dropdownField.id,
|
|
||||||
type: 'dropdown',
|
|
||||||
label: 'Prefilled Dropdown',
|
|
||||||
value: 'Select B',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const responseData = await response.json();
|
|
||||||
|
|
||||||
expect(response.ok()).toBeTruthy();
|
|
||||||
expect(response.status()).toBe(200);
|
|
||||||
|
|
||||||
expect(responseData.id).toBeDefined();
|
|
||||||
|
|
||||||
// 9. Verify the document was created with prefilled fields
|
|
||||||
const document = await prisma.document.findUnique({
|
|
||||||
where: {
|
|
||||||
id: responseData.id,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
fields: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(document).not.toBeNull();
|
|
||||||
|
|
||||||
// 10. Verify each field has the correct prefilled values
|
|
||||||
const documentTextField = document?.fields.find(
|
|
||||||
(field) => field.type === FieldType.TEXT && field.fieldMeta?.type === 'text',
|
|
||||||
);
|
|
||||||
expect(documentTextField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'text',
|
|
||||||
label: 'Prefilled Text',
|
|
||||||
text: 'This is prefilled text',
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentNumberField = document?.fields.find(
|
|
||||||
(field) => field.type === FieldType.NUMBER && field.fieldMeta?.type === 'number',
|
|
||||||
);
|
|
||||||
expect(documentNumberField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'number',
|
|
||||||
label: 'Prefilled Number',
|
|
||||||
value: '42',
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentRadioField = document?.fields.find(
|
|
||||||
(field) => field.type === FieldType.RADIO && field.fieldMeta?.type === 'radio',
|
|
||||||
);
|
|
||||||
expect(documentRadioField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'radio',
|
|
||||||
label: 'Prefilled Radio',
|
|
||||||
});
|
|
||||||
// Check that the correct radio option is selected
|
|
||||||
const radioValues = (documentRadioField?.fieldMeta as TRadioFieldMeta)?.values || [];
|
|
||||||
const selectedRadioOption = radioValues.find((option) => option.checked);
|
|
||||||
expect(selectedRadioOption?.value).toBe('Option A');
|
|
||||||
|
|
||||||
const documentCheckboxField = document?.fields.find(
|
|
||||||
(field) => field.type === FieldType.CHECKBOX && field.fieldMeta?.type === 'checkbox',
|
|
||||||
);
|
|
||||||
expect(documentCheckboxField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'checkbox',
|
|
||||||
label: 'Prefilled Checkbox',
|
|
||||||
});
|
|
||||||
// Check that the correct checkbox options are selected
|
|
||||||
const checkboxValues = (documentCheckboxField?.fieldMeta as TCheckboxFieldMeta)?.values || [];
|
|
||||||
const checkedOptions = checkboxValues.filter((option) => option.checked);
|
|
||||||
expect(checkedOptions.length).toBe(2);
|
|
||||||
expect(checkedOptions.map((option) => option.value)).toContain('Check A');
|
|
||||||
expect(checkedOptions.map((option) => option.value)).toContain('Check B');
|
|
||||||
|
|
||||||
const documentDropdownField = document?.fields.find(
|
|
||||||
(field) => field.type === FieldType.DROPDOWN && field.fieldMeta?.type === 'dropdown',
|
|
||||||
);
|
|
||||||
expect(documentDropdownField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'dropdown',
|
|
||||||
label: 'Prefilled Dropdown',
|
|
||||||
defaultValue: 'Select B',
|
|
||||||
});
|
|
||||||
|
|
||||||
const sendResponse = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
documentId: document?.id,
|
|
||||||
meta: {
|
|
||||||
subject: 'Test Subject',
|
|
||||||
message: 'Test Message',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(sendResponse.ok()).toBeTruthy();
|
|
||||||
await expect(sendResponse.status()).toBe(200);
|
|
||||||
|
|
||||||
// 11. Sign in as the recipient and verify the prefilled fields are visible
|
|
||||||
const documentRecipient = await prisma.recipient.findFirst({
|
|
||||||
where: {
|
|
||||||
documentId: document?.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(documentRecipient).not.toBeNull();
|
|
||||||
|
|
||||||
// Visit the signing page
|
|
||||||
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
|
|
||||||
|
|
||||||
// Verify the prefilled fields are visible with correct values
|
|
||||||
// Text field
|
|
||||||
await expect(page.getByText('This is prefilled')).toBeVisible();
|
|
||||||
|
|
||||||
// Number field
|
|
||||||
await expect(page.getByText('42')).toBeVisible();
|
|
||||||
|
|
||||||
// Radio field
|
|
||||||
await expect(page.getByText('Option A')).toBeVisible();
|
|
||||||
await expect(page.getByRole('radio', { name: 'Option A' })).toBeChecked();
|
|
||||||
|
|
||||||
// Checkbox field
|
|
||||||
await expect(page.getByText('Check A')).toBeVisible();
|
|
||||||
await expect(page.getByText('Check B')).toBeVisible();
|
|
||||||
await expect(page.getByRole('checkbox', { name: 'Check A' })).toBeChecked();
|
|
||||||
await expect(page.getByRole('checkbox', { name: 'Check B' })).toBeChecked();
|
|
||||||
|
|
||||||
// Dropdown field
|
|
||||||
await expect(page.getByText('Select B')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should create a document from template without prefilled fields', async ({
|
|
||||||
page,
|
|
||||||
request,
|
|
||||||
}) => {
|
|
||||||
// 1. Create a user
|
|
||||||
const user = await seedUser();
|
|
||||||
|
|
||||||
// 2. Create an API token for the user
|
|
||||||
const { token } = await createApiToken({
|
|
||||||
userId: user.id,
|
|
||||||
tokenName: 'test-token',
|
|
||||||
expiresIn: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Create a template with seedBlankTemplate
|
|
||||||
const template = await seedBlankTemplate(user, {
|
|
||||||
createTemplateOptions: {
|
|
||||||
title: 'Template with Default Fields V2',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Create a recipient for the template
|
|
||||||
const recipient = await prisma.recipient.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
token: 'test-token',
|
|
||||||
readStatus: 'NOT_OPENED',
|
|
||||||
sendStatus: 'NOT_SENT',
|
|
||||||
signingStatus: 'NOT_SIGNED',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Add fields to the template
|
|
||||||
// Add TEXT field
|
|
||||||
const textField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.TEXT,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 5,
|
|
||||||
width: 20,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'text',
|
|
||||||
label: 'Default Text Field',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add NUMBER field
|
|
||||||
const numberField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.NUMBER,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 15,
|
|
||||||
width: 20,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'number',
|
|
||||||
label: 'Default Number Field',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 6. Sign in as the user
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: user.email,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 7. Navigate to the template
|
|
||||||
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
|
|
||||||
|
|
||||||
// 8. Create a document from the template without prefilled fields using v2 API
|
|
||||||
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipients: [
|
|
||||||
{
|
|
||||||
id: recipient.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const responseData = await response.json();
|
|
||||||
|
|
||||||
expect(response.ok()).toBeTruthy();
|
|
||||||
expect(response.status()).toBe(200);
|
|
||||||
|
|
||||||
expect(responseData.id).toBeDefined();
|
|
||||||
|
|
||||||
// 9. Verify the document was created with default fields
|
|
||||||
const document = await prisma.document.findUnique({
|
|
||||||
where: {
|
|
||||||
id: responseData.id,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
fields: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(document).not.toBeNull();
|
|
||||||
|
|
||||||
// 10. Verify fields have their default values
|
|
||||||
const documentTextField = document?.fields.find((field) => field.type === FieldType.TEXT);
|
|
||||||
expect(documentTextField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'text',
|
|
||||||
label: 'Default Text Field',
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentNumberField = document?.fields.find((field) => field.type === FieldType.NUMBER);
|
|
||||||
expect(documentNumberField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'number',
|
|
||||||
label: 'Default Number Field',
|
|
||||||
});
|
|
||||||
|
|
||||||
const sendResponse = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
documentId: document?.id,
|
|
||||||
meta: {
|
|
||||||
subject: 'Test Subject',
|
|
||||||
message: 'Test Message',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(sendResponse.ok()).toBeTruthy();
|
|
||||||
await expect(sendResponse.status()).toBe(200);
|
|
||||||
|
|
||||||
// 11. Sign in as the recipient and verify the default fields are visible
|
|
||||||
const documentRecipient = await prisma.recipient.findFirst({
|
|
||||||
where: {
|
|
||||||
documentId: document?.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(documentRecipient).not.toBeNull();
|
|
||||||
|
|
||||||
// Visit the signing page
|
|
||||||
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
|
|
||||||
|
|
||||||
await expect(page.getByText('This is prefilled')).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle invalid field prefill values', async ({ request }) => {
|
|
||||||
// 1. Create a user
|
|
||||||
const user = await seedUser();
|
|
||||||
|
|
||||||
// 2. Create an API token for the user
|
|
||||||
const { token } = await createApiToken({
|
|
||||||
userId: user.id,
|
|
||||||
tokenName: 'test-token',
|
|
||||||
expiresIn: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Create a template using seedBlankTemplate
|
|
||||||
const template = await seedBlankTemplate(user, {
|
|
||||||
createTemplateOptions: {
|
|
||||||
title: 'Template for Invalid Test V2',
|
|
||||||
visibility: 'EVERYONE',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Create a recipient for the template
|
|
||||||
const recipient = await prisma.recipient.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
token: 'test-token',
|
|
||||||
readStatus: 'NOT_OPENED',
|
|
||||||
sendStatus: 'NOT_SENT',
|
|
||||||
signingStatus: 'NOT_SIGNED',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Add a field to the template
|
|
||||||
const field = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.RADIO,
|
|
||||||
page: 1,
|
|
||||||
positionX: 100,
|
|
||||||
positionY: 100,
|
|
||||||
width: 100,
|
|
||||||
height: 50,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'radio',
|
|
||||||
label: 'Radio Field',
|
|
||||||
values: [
|
|
||||||
{ id: 1, value: 'Option A', checked: false },
|
|
||||||
{ id: 2, value: 'Option B', checked: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 7. Try to create a document with invalid prefill value
|
|
||||||
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipients: [
|
|
||||||
{
|
|
||||||
id: recipient.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
prefillFields: [
|
|
||||||
{
|
|
||||||
id: field.id,
|
|
||||||
type: 'radio',
|
|
||||||
label: 'Invalid Radio',
|
|
||||||
value: 'Non-existent Option', // This option doesn't exist
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 8. Verify the request fails with appropriate error
|
|
||||||
expect(response.ok()).toBeFalsy();
|
|
||||||
expect(response.status()).toBe(400);
|
|
||||||
|
|
||||||
const errorData = await response.json();
|
|
||||||
expect(errorData.message).toContain('not found in options for RADIO field');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
304
packages/app-tests/e2e/document-flow/fields-step.spec.ts
Normal file
304
packages/app-tests/e2e/document-flow/fields-step.spec.ts
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
test('[DOCUMENT_FLOW]: add signature fields for unique recipients', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const document = await seedBlankDocument(user);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/documents/${document.id}/edit`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add 2 signers.
|
||||||
|
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
|
await page.getByLabel('Email').nth(1).fill('recipient2@documenso.com');
|
||||||
|
await page.getByLabel('Name').nth(1).fill('Recipient 2');
|
||||||
|
|
||||||
|
// Advanced settings should not be visible for non EE users.
|
||||||
|
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 200,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Send' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('**/documents');
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENT_FLOW]: add signature fields for duplicate recipients', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const document = await seedBlankDocument(user);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/documents/${document.id}/edit`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').fill('Recipient');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
|
await page.getByLabel('Email').nth(1).fill('recipient@documenso.com');
|
||||||
|
await page.getByLabel('Name').nth(1).fill('Recipient');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
|
await page.getByLabel('Email').nth(2).fill('recipient@documenso.com');
|
||||||
|
await page.getByLabel('Name').nth(2).fill('Recipient');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add signature fields for each recipient
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByRole('option', { name: 'Recipient (recipient@documenso.com)' }).nth(1).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 200,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByRole('option', { name: 'Recipient (recipient@documenso.com)' }).nth(2).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 300,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Send' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('**/documents');
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENT_FLOW]: add signature fields for recipients with different roles', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const document = await seedBlankDocument(user);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/documents/${document.id}/edit`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add a signer
|
||||||
|
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').fill('Documenso Recipient');
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByLabel('Needs to sign').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
|
// Add an approver
|
||||||
|
await page.getByLabel('Email').nth(1).fill('recipient@documenso.com');
|
||||||
|
await page.getByLabel('Name').nth(1).fill('Documenso Recipient');
|
||||||
|
await page.getByRole('combobox').nth(1).click();
|
||||||
|
await page.getByLabel('Needs to approve').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
|
// Add a viewer
|
||||||
|
await page.getByLabel('Email').nth(2).fill('recipient@documenso.com');
|
||||||
|
await page.getByLabel('Name').nth(2).fill('Documenso Recipient');
|
||||||
|
await page.getByRole('combobox').nth(2).click();
|
||||||
|
await page.getByLabel('Needs to view').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
|
// Add a CC
|
||||||
|
await page.getByLabel('Email').nth(3).fill('recipient@documenso.com');
|
||||||
|
await page.getByLabel('Name').nth(3).fill('Documenso Recipient');
|
||||||
|
await page.getByRole('combobox').nth(3).click();
|
||||||
|
await page.getByLabel('Receives copy').click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add signature fields for signer and approver
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page
|
||||||
|
.getByRole('option', { name: 'Documenso Recipient (recipient@documenso.com)' })
|
||||||
|
.nth(1)
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 200,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Send' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('**/documents');
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENT_FLOW]: add signature fields for mixed recipients', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const document = await seedBlankDocument(user);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/documents/${document.id}/edit`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||||
|
|
||||||
|
// First recipient (unique)
|
||||||
|
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').fill('First Recipient');
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByLabel('Needs to approve').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
|
// Second recipient (duplicate of first)
|
||||||
|
await page.getByLabel('Email').nth(1).fill('recipient1@documenso.com');
|
||||||
|
await page.getByLabel('Name').nth(1).fill('First Recipient');
|
||||||
|
await page.getByRole('combobox').nth(1).click();
|
||||||
|
await page.getByLabel('Needs to view').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
|
// Third recipient (unique)
|
||||||
|
await page.getByLabel('Email').nth(2).fill('recipient2@documenso.com');
|
||||||
|
await page.getByLabel('Name').nth(2).fill('Second Recipient');
|
||||||
|
await page.getByRole('combobox').nth(2).click();
|
||||||
|
await page.getByLabel('Needs to sign').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
|
// Fourth recipient (duplicate of first)
|
||||||
|
await page.getByLabel('Email').nth(3).fill('recipient1@documenso.com');
|
||||||
|
await page.getByLabel('Name').nth(3).fill('First Recipient');
|
||||||
|
await page.getByRole('combobox').nth(3).click();
|
||||||
|
await page.getByLabel('Receives copy').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
|
// Fifth recipient (unique)
|
||||||
|
await page.getByLabel('Email').nth(4).fill('recipient3@documenso.com');
|
||||||
|
await page.getByLabel('Name').nth(4).fill('Third Recipient');
|
||||||
|
await page.getByRole('combobox').nth(4).click();
|
||||||
|
await page.getByLabel('Needs to sign').click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add signature fields for approver and signers
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByRole('option', { name: 'Second Recipient (recipient2@documenso.com)' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 200,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByRole('option', { name: 'Third Recipient (recipient3@documenso.com)' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 300,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Send' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('**/documents');
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -91,3 +91,191 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENT_FLOW]: add only recipients with the same email address', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const document = await seedBlankDocument(user);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/documents/${document.id}/edit`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').fill('Recipient');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
|
await page.getByLabel('Email').nth(1).fill('recipient@documenso.com');
|
||||||
|
await page.getByLabel('Name').nth(1).fill('Recipient');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
|
await page.getByLabel('Email').nth(2).fill('recipient@documenso.com');
|
||||||
|
await page.getByLabel('Name').nth(2).fill('Recipient');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENT_FLOW]: duplicate email recipients', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const document = await seedBlankDocument(user);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/documents/${document.id}/edit`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add a signer
|
||||||
|
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').fill('Documenso Recipient');
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByLabel('Needs to sign').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
|
// Add an approver
|
||||||
|
await page.getByLabel('Email').nth(1).fill('recipient@documenso.com');
|
||||||
|
await page.getByLabel('Name').nth(1).fill('Documenso Recipient');
|
||||||
|
await page.getByRole('combobox').nth(1).click();
|
||||||
|
await page.getByLabel('Needs to approve').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
|
// Add a viewer
|
||||||
|
await page.getByLabel('Email').nth(2).fill('recipient@documenso.com');
|
||||||
|
await page.getByLabel('Name').nth(2).fill('Documenso Recipient');
|
||||||
|
await page.getByRole('combobox').nth(2).click();
|
||||||
|
await page.getByLabel('Needs to view').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
|
// Add a CC
|
||||||
|
await page.getByLabel('Email').nth(3).fill('recipient@documenso.com');
|
||||||
|
await page.getByLabel('Name').nth(3).fill('Documenso Recipient');
|
||||||
|
await page.getByRole('combobox').nth(3).click();
|
||||||
|
await page.getByLabel('Receives copy').click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENT_FLOW]: same email with different roles', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const document = await seedBlankDocument(user);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/documents/${document.id}/edit`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||||
|
|
||||||
|
// First recipient (unique)
|
||||||
|
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').fill('First Recipient');
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByLabel('Needs to approve').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
|
// Second recipient (duplicate of first)
|
||||||
|
await page.getByLabel('Email').nth(1).fill('recipient1@documenso.com');
|
||||||
|
await page.getByLabel('Name').nth(1).fill('First Recipient');
|
||||||
|
await page.getByRole('combobox').nth(1).click();
|
||||||
|
await page.getByLabel('Needs to view').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
|
// Third recipient (unique)
|
||||||
|
await page.getByLabel('Email').nth(2).fill('recipient2@documenso.com');
|
||||||
|
await page.getByLabel('Name').nth(2).fill('Second Recipient');
|
||||||
|
await page.getByRole('combobox').nth(2).click();
|
||||||
|
await page.getByLabel('Needs to sign').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
|
// Fourth recipient (duplicate of first)
|
||||||
|
await page.getByLabel('Email').nth(3).fill('recipient1@documenso.com');
|
||||||
|
await page.getByLabel('Name').nth(3).fill('First Recipient');
|
||||||
|
await page.getByRole('combobox').nth(3).click();
|
||||||
|
await page.getByLabel('Receives copy').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
|
// Fifth recipient (unique)
|
||||||
|
await page.getByLabel('Email').nth(4).fill('recipient3@documenso.com');
|
||||||
|
await page.getByLabel('Name').nth(4).fill('Third Recipient');
|
||||||
|
await page.getByRole('combobox').nth(4).click();
|
||||||
|
await page.getByLabel('Needs to sign').click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENT_FLOW]: mixed unique and duplicate recipients', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const document = await seedBlankDocument(user);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/documents/${document.id}/edit`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||||
|
|
||||||
|
// First recipient (unique)
|
||||||
|
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').fill('First Recipient');
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByLabel('Needs to approve').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
|
// Second recipient (duplicate of first)
|
||||||
|
await page.getByLabel('Email').nth(1).fill('recipient1@documenso.com');
|
||||||
|
await page.getByLabel('Name').nth(1).fill('First Recipient');
|
||||||
|
await page.getByRole('combobox').nth(1).click();
|
||||||
|
await page.getByLabel('Needs to view').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
|
// Third recipient (unique)
|
||||||
|
await page.getByLabel('Email').nth(2).fill('recipient2@documenso.com');
|
||||||
|
await page.getByLabel('Name').nth(2).fill('Second Recipient');
|
||||||
|
await page.getByRole('combobox').nth(2).click();
|
||||||
|
await page.getByLabel('Needs to sign').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
|
// Fourth recipient (duplicate of first)
|
||||||
|
await page.getByLabel('Email').nth(3).fill('recipient1@documenso.com');
|
||||||
|
await page.getByLabel('Name').nth(3).fill('First Recipient');
|
||||||
|
await page.getByRole('combobox').nth(3).click();
|
||||||
|
await page.getByLabel('Receives copy').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
|
// Fifth recipient (unique)
|
||||||
|
await page.getByLabel('Email').nth(4).fill('recipient3@documenso.com');
|
||||||
|
await page.getByLabel('Name').nth(4).fill('Third Recipient');
|
||||||
|
await page.getByRole('combobox').nth(4).click();
|
||||||
|
await page.getByLabel('Needs to sign').click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -384,9 +384,7 @@ test('[DOCUMENT_FLOW]: should be able to approve a document', async ({ page }) =
|
|||||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
await page
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Complete' : 'Approve' })
|
|
||||||
.click();
|
|
||||||
await page
|
await page
|
||||||
.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Sign' : 'Approve' })
|
.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Sign' : 'Approve' })
|
||||||
.click();
|
.click();
|
||||||
@@ -456,7 +454,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
|
|||||||
const { status } = await getDocumentByToken(token);
|
const { status } = await getDocumentByToken(token);
|
||||||
expect(status).toBe(DocumentStatus.PENDING);
|
expect(status).toBe(DocumentStatus.PENDING);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Approve' }).click();
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
await expect(page.getByRole('dialog').getByText('Complete Approval').first()).toBeVisible();
|
await expect(page.getByRole('dialog').getByText('Complete Approval').first()).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'Approve' }).click();
|
await page.getByRole('button', { name: 'Approve' }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
test('[TEMPLATE_FLOW]: add signature fields for unique recipients', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const template = await seedBlankTemplate(user);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/templates/${template.id}/edit`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add 2 placeholder recipients.
|
||||||
|
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
|
||||||
|
|
||||||
|
// Advanced settings should not be visible for non EE users.
|
||||||
|
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 200,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('**/templates');
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEMPLATE_FLOW]: add signature fields for duplicate recipients', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const template = await seedBlankTemplate(user);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/templates/${template.id}/edit`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').fill('Recipient');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Email').nth(1).fill('recipient@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').nth(1).fill('Recipient');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Email').nth(2).fill('recipient@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').nth(2).fill('Recipient');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add signature fields for each recipient
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByRole('option', { name: 'Recipient (recipient@documenso.com)' }).nth(1).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 200,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByRole('option', { name: 'Recipient (recipient@documenso.com)' }).nth(2).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 300,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('**/templates');
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEMPLATE_FLOW]: add signature fields for recipients with different roles', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const template = await seedBlankTemplate(user);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/templates/${template.id}/edit`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add a placeholder recipient
|
||||||
|
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').fill('Documenso Recipient');
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByLabel('Needs to sign').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
|
||||||
|
// Add an approver
|
||||||
|
await page.getByPlaceholder('Email').nth(1).fill('recipient@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').nth(1).fill('Documenso Recipient');
|
||||||
|
await page.getByRole('combobox').nth(1).click();
|
||||||
|
await page.getByLabel('Needs to approve').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
|
||||||
|
// Add a viewer
|
||||||
|
await page.getByPlaceholder('Email').nth(2).fill('recipient@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').nth(2).fill('Documenso Recipient');
|
||||||
|
await page.getByRole('combobox').nth(2).click();
|
||||||
|
await page.getByLabel('Needs to view').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
|
||||||
|
// Add a CC
|
||||||
|
await page.getByPlaceholder('Email').nth(3).fill('recipient@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').nth(3).fill('Documenso Recipient');
|
||||||
|
await page.getByRole('combobox').nth(3).click();
|
||||||
|
await page.getByLabel('Receives copy').click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add signature fields for signer and approver
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page
|
||||||
|
.getByRole('option', { name: 'Documenso Recipient (recipient@documenso.com)' })
|
||||||
|
.nth(1)
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 200,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('**/templates');
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEMPLATE_FLOW]: add signature fields for mixed recipients', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const template = await seedBlankTemplate(user);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/templates/${template.id}/edit`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
|
||||||
|
|
||||||
|
// First placeholder recipient (unique)
|
||||||
|
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').fill('First Recipient');
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByLabel('Needs to approve').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
|
||||||
|
// Second placeholder recipient (duplicate of first)
|
||||||
|
await page.getByPlaceholder('Email').nth(1).fill('recipient1@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').nth(1).fill('First Recipient');
|
||||||
|
await page.getByRole('combobox').nth(1).click();
|
||||||
|
await page.getByLabel('Needs to view').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
|
||||||
|
// Third placeholder recipient (unique)
|
||||||
|
await page.getByPlaceholder('Email').nth(2).fill('recipient2@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').nth(2).fill('Second Recipient');
|
||||||
|
await page.getByRole('combobox').nth(2).click();
|
||||||
|
await page.getByLabel('Needs to sign').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
|
||||||
|
// Fourth placeholder recipient (duplicate of first)
|
||||||
|
await page.getByPlaceholder('Email').nth(3).fill('recipient1@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').nth(3).fill('First Recipient');
|
||||||
|
await page.getByRole('combobox').nth(3).click();
|
||||||
|
await page.getByLabel('Receives copy').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
|
||||||
|
// Fifth placeholder recipient (unique)
|
||||||
|
await page.getByPlaceholder('Email').nth(4).fill('recipient3@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').nth(4).fill('Third Recipient');
|
||||||
|
await page.getByRole('combobox').nth(4).click();
|
||||||
|
await page.getByLabel('Needs to sign').click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add signature fields for approver and signers
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByRole('option', { name: 'Second Recipient (recipient2@documenso.com)' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 200,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByRole('option', { name: 'Third Recipient (recipient3@documenso.com)' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 300,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('**/templates');
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -98,3 +98,135 @@ test('[TEMPLATE_FLOW]: add placeholder', async ({ page }) => {
|
|||||||
// Advanced settings should not be visible for non EE users.
|
// Advanced settings should not be visible for non EE users.
|
||||||
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
|
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[TEMPLATE_FLOW]: duplicate recipients', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const template = await seedBlankTemplate(user);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/templates/${template.id}/edit`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').fill('Recipient');
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Email').nth(1).fill('recipient@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').nth(1).fill('Recipient');
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Email').nth(2).fill('recipient@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').nth(2).fill('Recipient');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEMPLATE_FLOW]: same email different roles', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const template = await seedBlankTemplate(user);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/templates/${template.id}/edit`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add a signer
|
||||||
|
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').fill('Documenso Recipient');
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByLabel('Needs to sign').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
|
||||||
|
// Add an approver
|
||||||
|
await page.getByPlaceholder('Email').nth(1).fill('recipient@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').nth(1).fill('Documenso Recipient');
|
||||||
|
await page.getByRole('combobox').nth(1).click();
|
||||||
|
await page.getByLabel('Needs to approve').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
|
||||||
|
// Add a viewer
|
||||||
|
await page.getByPlaceholder('Email').nth(2).fill('recipient@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').nth(2).fill('Documenso Recipient');
|
||||||
|
await page.getByRole('combobox').nth(2).click();
|
||||||
|
await page.getByLabel('Needs to view').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
|
||||||
|
// Add a CC
|
||||||
|
await page.getByPlaceholder('Email').nth(3).fill('recipient@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').nth(3).fill('Documenso Recipient');
|
||||||
|
await page.getByRole('combobox').nth(3).click();
|
||||||
|
await page.getByLabel('Receives copy').click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEMPLATE_FLOW]: mixed recipients', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const template = await seedBlankTemplate(user);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/templates/${template.id}/edit`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||||
|
|
||||||
|
// First recipient (unique)
|
||||||
|
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').fill('First Recipient');
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByLabel('Needs to approve').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
|
||||||
|
// Second recipient (duplicate of first)
|
||||||
|
await page.getByPlaceholder('Email').nth(1).fill('recipient1@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').nth(1).fill('First Recipient');
|
||||||
|
await page.getByRole('combobox').nth(1).click();
|
||||||
|
await page.getByLabel('Needs to view').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
|
||||||
|
// Third recipient (unique)
|
||||||
|
await page.getByPlaceholder('Email').nth(2).fill('recipient2@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').nth(2).fill('Second Recipient');
|
||||||
|
await page.getByRole('combobox').nth(2).click();
|
||||||
|
await page.getByLabel('Needs to sign').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
|
||||||
|
// Fourth recipient (duplicate of first)
|
||||||
|
await page.getByPlaceholder('Email').nth(3).fill('recipient1@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').nth(3).fill('First Recipient');
|
||||||
|
await page.getByRole('combobox').nth(3).click();
|
||||||
|
await page.getByLabel('Receives copy').click();
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
|
||||||
|
// Fifth recipient (unique)
|
||||||
|
await page.getByPlaceholder('Email').nth(4).fill('recipient3@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').nth(4).fill('Third Recipient');
|
||||||
|
await page.getByRole('combobox').nth(4).click();
|
||||||
|
await page.getByLabel('Needs to sign').click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -110,14 +110,13 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
|
|||||||
await page.getByRole('button', { name: 'Save template' }).click();
|
await page.getByRole('button', { name: 'Save template' }).click();
|
||||||
|
|
||||||
// Use template
|
// Use template
|
||||||
await page.waitForURL('/templates');
|
await page.waitForURL('**/templates');
|
||||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||||
|
|
||||||
// Review that the document was created with the correct values.
|
// Review that the document was created with the correct values.
|
||||||
await page.waitForURL(/documents/);
|
await page.waitForURL(/\/documents\/\d+/);
|
||||||
|
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
|
||||||
const documentId = Number(page.url().split('/').pop());
|
|
||||||
|
|
||||||
const document = await prisma.document.findFirstOrThrow({
|
const document = await prisma.document.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
@@ -250,9 +249,8 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
|
|||||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||||
|
|
||||||
// Review that the document was created with the correct values.
|
// Review that the document was created with the correct values.
|
||||||
await page.waitForURL(/documents/);
|
await page.waitForURL(/\/documents\/\d+/);
|
||||||
|
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
|
||||||
const documentId = Number(page.url().split('/').pop());
|
|
||||||
|
|
||||||
const document = await prisma.document.findFirstOrThrow({
|
const document = await prisma.document.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
@@ -353,9 +351,8 @@ test('[TEMPLATE]: should create a document from a template with custom document'
|
|||||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||||
|
|
||||||
// Review that the document was created with the custom document data
|
// Review that the document was created with the custom document data
|
||||||
await page.waitForURL(/documents/);
|
await page.waitForURL(/\/documents\/\d+/);
|
||||||
|
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
|
||||||
const documentId = Number(page.url().split('/').pop());
|
|
||||||
|
|
||||||
const document = await prisma.document.findFirstOrThrow({
|
const document = await prisma.document.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
@@ -434,9 +431,8 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
|
|||||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||||
|
|
||||||
// Review that the document was created with the custom document data
|
// Review that the document was created with the custom document data
|
||||||
await page.waitForURL(/documents/);
|
await page.waitForURL(/\/documents\/\d+/);
|
||||||
|
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
|
||||||
const documentId = Number(page.url().split('/').pop());
|
|
||||||
|
|
||||||
const document = await prisma.document.findFirstOrThrow({
|
const document = await prisma.document.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
@@ -500,9 +496,8 @@ test('[TEMPLATE]: should create a document from a template using template docume
|
|||||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||||
|
|
||||||
// Review that the document was created with the template's document data
|
// Review that the document was created with the template's document data
|
||||||
await page.waitForURL(/documents/);
|
await page.waitForURL(/\/documents\/\d+/);
|
||||||
|
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
|
||||||
const documentId = Number(page.url().split('/').pop());
|
|
||||||
|
|
||||||
const document = await prisma.document.findFirstOrThrow({
|
const document = await prisma.document.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
@@ -591,9 +586,8 @@ test('[TEMPLATE]: should persist document visibility when creating from template
|
|||||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||||
|
|
||||||
// Review that the document was created with the correct visibility
|
// Review that the document was created with the correct visibility
|
||||||
await page.waitForURL(/documents/);
|
await page.waitForURL(/\/documents\/\d+/);
|
||||||
|
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
|
||||||
const documentId = Number(page.url().split('/').pop());
|
|
||||||
|
|
||||||
const document = await prisma.document.findFirstOrThrow({
|
const document = await prisma.document.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
@@ -616,3 +610,368 @@ test('[TEMPLATE]: should persist document visibility when creating from template
|
|||||||
// Template should not be visible to regular member
|
// Template should not be visible to regular member
|
||||||
await expect(page.getByRole('button', { name: 'Use Template' })).not.toBeVisible();
|
await expect(page.getByRole('button', { name: 'Use Template' })).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This test verifies that we can create a document from a template with duplicate recipients
|
||||||
|
**/
|
||||||
|
test('[TEMPLATE]: should create a document from a template with duplicate recipients', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const template = await seedBlankTemplate(user);
|
||||||
|
|
||||||
|
const isBillingEnabled =
|
||||||
|
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' && enterprisePriceId;
|
||||||
|
|
||||||
|
await seedUserSubscription({
|
||||||
|
userId: user.id,
|
||||||
|
priceId: enterprisePriceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/templates/${template.id}/edit`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set template title.
|
||||||
|
await page.getByLabel('Title').fill('TEMPLATE_TITLE');
|
||||||
|
|
||||||
|
// Set template document access.
|
||||||
|
await page.getByTestId('documentAccessSelectValue').click();
|
||||||
|
await page.getByLabel('Require account').getByText('Require account').click();
|
||||||
|
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||||
|
|
||||||
|
// Set EE action auth.
|
||||||
|
if (isBillingEnabled) {
|
||||||
|
await page.getByTestId('documentActionSelectValue').click();
|
||||||
|
await page.getByLabel('Require passkey').getByText('Require passkey').click();
|
||||||
|
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set email options.
|
||||||
|
await page.getByRole('button', { name: 'Email Options' }).click();
|
||||||
|
await page.getByLabel('Subject (Optional)').fill('SUBJECT');
|
||||||
|
await page.getByLabel('Message (Optional)').fill('MESSAGE');
|
||||||
|
|
||||||
|
// Set advanced options.
|
||||||
|
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||||
|
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
|
||||||
|
await page.getByLabel('DD/MM/YYYY').click();
|
||||||
|
|
||||||
|
await page.locator('.time-zone-field').click();
|
||||||
|
await page.getByRole('option', { name: 'Etc/UTC' }).click();
|
||||||
|
await page.getByLabel('Redirect URL').fill('https://documenso.com');
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add 2 signers.
|
||||||
|
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
await page.getByPlaceholder('Email').nth(1).fill('recipient1@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').nth(1).fill('Recipient 1');
|
||||||
|
|
||||||
|
// Apply require passkey for Recipient 1.
|
||||||
|
if (isBillingEnabled) {
|
||||||
|
await page.getByLabel('Show advanced settings').check();
|
||||||
|
await page.getByRole('combobox').first().click();
|
||||||
|
await page.getByLabel('Require passkey').click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add signature fields for each recipient
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).nth(1).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 200,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save template' }).click();
|
||||||
|
|
||||||
|
// Use template
|
||||||
|
await page.waitForURL('**/templates');
|
||||||
|
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||||
|
|
||||||
|
// Review that the document was created with the correct values.
|
||||||
|
await page.waitForURL(/\/documents\/\d+/);
|
||||||
|
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
|
||||||
|
|
||||||
|
const document = await prisma.document.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
recipients: true,
|
||||||
|
documentMeta: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentAuth = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.title).toEqual('TEMPLATE_TITLE');
|
||||||
|
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
|
||||||
|
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
|
||||||
|
isBillingEnabled ? 'PASSKEY' : null,
|
||||||
|
);
|
||||||
|
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
|
||||||
|
expect(document.documentMeta?.message).toEqual('MESSAGE');
|
||||||
|
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
|
||||||
|
expect(document.documentMeta?.subject).toEqual('SUBJECT');
|
||||||
|
expect(document.documentMeta?.timezone).toEqual('Etc/UTC');
|
||||||
|
|
||||||
|
const recipientOne = document.recipients[0];
|
||||||
|
const recipientTwo = document.recipients[1];
|
||||||
|
|
||||||
|
const recipientOneAuth = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
recipientAuth: recipientOne.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipientTwoAuth = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
recipientAuth: recipientTwo.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isBillingEnabled) {
|
||||||
|
expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
|
||||||
|
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: 'Edit' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.getByText('SignatureRE').first()).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByRole('option', { name: 'recipient1@documenso.com' }).nth(1).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('SignatureRE').nth(1)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This test verifies that we can create a document from a template with a mix of duplicate and unique recipients
|
||||||
|
**/
|
||||||
|
test('[TEMPLATE]: should create a document from a template with mixed duplicate and unique recipients', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const template = await seedBlankTemplate(user);
|
||||||
|
|
||||||
|
const isBillingEnabled =
|
||||||
|
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' && enterprisePriceId;
|
||||||
|
|
||||||
|
await seedUserSubscription({
|
||||||
|
userId: user.id,
|
||||||
|
priceId: enterprisePriceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/templates/${template.id}/edit`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set template title.
|
||||||
|
await page.getByLabel('Title').fill('TEMPLATE_MIXED_RECIPIENTS');
|
||||||
|
|
||||||
|
// Set template document access.
|
||||||
|
await page.getByTestId('documentAccessSelectValue').click();
|
||||||
|
await page.getByLabel('Require account').getByText('Require account').click();
|
||||||
|
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||||
|
|
||||||
|
// Set EE action auth.
|
||||||
|
if (isBillingEnabled) {
|
||||||
|
await page.getByTestId('documentActionSelectValue').click();
|
||||||
|
await page.getByLabel('Require passkey').getByText('Require passkey').click();
|
||||||
|
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set email options.
|
||||||
|
await page.getByRole('button', { name: 'Email Options' }).click();
|
||||||
|
await page.getByLabel('Subject (Optional)').fill('SUBJECT');
|
||||||
|
await page.getByLabel('Message (Optional)').fill('MESSAGE');
|
||||||
|
|
||||||
|
// Set advanced options.
|
||||||
|
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||||
|
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
|
||||||
|
await page.getByLabel('DD/MM/YYYY').click();
|
||||||
|
|
||||||
|
await page.locator('.time-zone-field').click();
|
||||||
|
await page.getByRole('option', { name: 'Etc/UTC' }).click();
|
||||||
|
await page.getByLabel('Redirect URL').fill('https://documenso.com');
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add 4 signers: 2 duplicates of recipient1 and 2 unique recipients
|
||||||
|
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Email').nth(2).fill('recipient1@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').nth(2).fill('Recipient 1');
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Email').nth(3).fill('recipient3@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').nth(3).fill('Recipient 3');
|
||||||
|
|
||||||
|
// Apply require passkey for first instance of Recipient 1
|
||||||
|
if (isBillingEnabled) {
|
||||||
|
await page.getByLabel('Show advanced settings').check();
|
||||||
|
await page.getByRole('combobox').first().click();
|
||||||
|
await page.getByLabel('Require passkey').click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add signature fields for each recipient
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 200,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).nth(1).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 300,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByRole('option', { name: 'Recipient 3 (recipient3@documenso.com)' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 400,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save template' }).click();
|
||||||
|
|
||||||
|
// Use template
|
||||||
|
await page.waitForURL('**/templates');
|
||||||
|
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||||
|
|
||||||
|
// Review that the document was created with the correct values.
|
||||||
|
await page.waitForURL(/\/documents\/\d+/);
|
||||||
|
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
|
||||||
|
|
||||||
|
const document = await prisma.document.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
recipients: {
|
||||||
|
orderBy: {
|
||||||
|
email: 'asc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
documentMeta: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentAuth = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.title).toEqual('TEMPLATE_MIXED_RECIPIENTS');
|
||||||
|
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
|
||||||
|
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
|
||||||
|
isBillingEnabled ? 'PASSKEY' : null,
|
||||||
|
);
|
||||||
|
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
|
||||||
|
expect(document.documentMeta?.message).toEqual('MESSAGE');
|
||||||
|
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
|
||||||
|
expect(document.documentMeta?.subject).toEqual('SUBJECT');
|
||||||
|
expect(document.documentMeta?.timezone).toEqual('Etc/UTC');
|
||||||
|
|
||||||
|
// Check auth settings for first instance of recipient1
|
||||||
|
const firstRecipientOne = document.recipients[0];
|
||||||
|
const firstRecipientOneAuth = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
recipientAuth: firstRecipientOne.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isBillingEnabled) {
|
||||||
|
expect(firstRecipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(firstRecipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: 'Edit' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.getByText('SignatureRE').first()).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByRole('option', { name: 'recipient2@documenso.com' }).click();
|
||||||
|
await expect(page.getByText('SignatureRE').nth(1)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByRole('option', { name: 'recipient1@documenso.com' }).nth(1).click();
|
||||||
|
await expect(page.getByText('SignatureRE').nth(2)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByRole('option', { name: 'recipient3@documenso.com' }).click();
|
||||||
|
await expect(page.getByText('SignatureRE').nth(3)).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ import { stripe } from '@documenso/lib/server-only/stripe';
|
|||||||
type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE];
|
type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE];
|
||||||
|
|
||||||
export const getPricesByPlan = async (plan: PlanType | PlanType[]) => {
|
export const getPricesByPlan = async (plan: PlanType | PlanType[]) => {
|
||||||
const planTypes: string[] = typeof plan === 'string' ? [plan] : plan;
|
const planTypes = typeof plan === 'string' ? [plan] : plan;
|
||||||
|
|
||||||
const prices = await stripe.prices.list({
|
const query = planTypes.map((planType) => `metadata['plan']:'${planType}'`).join(' OR ');
|
||||||
|
|
||||||
|
const { data: prices } = await stripe.prices.search({
|
||||||
|
query,
|
||||||
expand: ['data.product'],
|
expand: ['data.product'],
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
return prices.data.filter(
|
return prices.filter((price) => price.type === 'recurring');
|
||||||
(price) => price.type === 'recurring' && planTypes.includes(price.metadata.plan),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
import type { Subscription } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
import { getCommunityPlanPriceIds } from '../stripe/get-community-plan-prices';
|
|
||||||
|
|
||||||
export type IsCommunityPlanOptions = {
|
|
||||||
userId: number;
|
|
||||||
teamId?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the user or team is on the community plan.
|
|
||||||
*/
|
|
||||||
export const isCommunityPlan = async ({
|
|
||||||
userId,
|
|
||||||
teamId,
|
|
||||||
}: IsCommunityPlanOptions): Promise<boolean> => {
|
|
||||||
let subscriptions: Subscription[] = [];
|
|
||||||
|
|
||||||
if (teamId) {
|
|
||||||
subscriptions = await prisma.team
|
|
||||||
.findFirstOrThrow({
|
|
||||||
where: {
|
|
||||||
id: teamId,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
owner: {
|
|
||||||
include: {
|
|
||||||
subscriptions: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then((team) => team.owner.subscriptions);
|
|
||||||
} else {
|
|
||||||
subscriptions = await prisma.user
|
|
||||||
.findFirstOrThrow({
|
|
||||||
where: {
|
|
||||||
id: userId,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
subscriptions: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then((user) => user.subscriptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subscriptions.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const communityPlanPriceIds = await getCommunityPlanPriceIds();
|
|
||||||
|
|
||||||
return subscriptionsContainsActivePlan(subscriptions, communityPlanPriceIds);
|
|
||||||
};
|
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
WebhookTriggerEvents,
|
WebhookTriggerEvents,
|
||||||
} from '@documenso/prisma/client';
|
} from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
|
||||||
import { jobs } from '../../jobs/client';
|
import { jobs } from '../../jobs/client';
|
||||||
import type { TRecipientActionAuth } from '../../types/document-auth';
|
import type { TRecipientActionAuth } from '../../types/document-auth';
|
||||||
import {
|
import {
|
||||||
@@ -73,13 +72,6 @@ export const completeDocumentWithToken = async ({
|
|||||||
throw new Error(`Recipient ${recipient.id} has already signed`);
|
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (recipient.signingStatus === SigningStatus.REJECTED) {
|
|
||||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
|
||||||
message: 'Recipient has already rejected the document',
|
|
||||||
statusCode: 400,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
if (document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||||
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });
|
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ export const findDocuments = async ({
|
|||||||
const searchFilter: Prisma.DocumentWhereInput = {
|
const searchFilter: Prisma.DocumentWhereInput = {
|
||||||
OR: [
|
OR: [
|
||||||
{ title: { contains: query, mode: 'insensitive' } },
|
{ title: { contains: query, mode: 'insensitive' } },
|
||||||
{ externalId: { contains: query, mode: 'insensitive' } },
|
|
||||||
{ recipients: { some: { name: { contains: query, mode: 'insensitive' } } } },
|
{ recipients: { some: { name: { contains: query, mode: 'insensitive' } } } },
|
||||||
{ recipients: { some: { email: { contains: query, mode: 'insensitive' } } } },
|
{ recipients: { some: { email: { contains: query, mode: 'insensitive' } } } },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -34,14 +34,6 @@ export const searchDocumentsWithKeyword = async ({
|
|||||||
userId: userId,
|
userId: userId,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
externalId: {
|
|
||||||
contains: query,
|
|
||||||
mode: 'insensitive',
|
|
||||||
},
|
|
||||||
userId: userId,
|
|
||||||
deletedAt: null,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
recipients: {
|
recipients: {
|
||||||
some: {
|
some: {
|
||||||
@@ -96,23 +88,6 @@ export const searchDocumentsWithKeyword = async ({
|
|||||||
},
|
},
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
externalId: {
|
|
||||||
contains: query,
|
|
||||||
mode: 'insensitive',
|
|
||||||
},
|
|
||||||
teamId: {
|
|
||||||
not: null,
|
|
||||||
},
|
|
||||||
team: {
|
|
||||||
members: {
|
|
||||||
some: {
|
|
||||||
userId: userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
deletedAt: null,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
|
|||||||
@@ -72,6 +72,22 @@ export const setFieldsForDocument = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check that every signer has a signature field
|
||||||
|
const signers = document.recipients.filter((recipient) => recipient.role === 'SIGNER');
|
||||||
|
const hasEverySignerSignature = signers.every((signer) =>
|
||||||
|
fields.some(
|
||||||
|
(field) =>
|
||||||
|
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
|
||||||
|
field.recipientId === signer.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasEverySignerSignature) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Every signer must have at least one signature field',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (document.completedAt) {
|
if (document.completedAt) {
|
||||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
message: 'Document already complete',
|
message: 'Document already complete',
|
||||||
@@ -94,9 +110,7 @@ export const setFieldsForDocument = async ({
|
|||||||
const linkedFields = fields.map((field) => {
|
const linkedFields = fields.map((field) => {
|
||||||
const existing = existingFields.find((existingField) => existingField.id === field.id);
|
const existing = existingFields.find((existingField) => existingField.id === field.id);
|
||||||
|
|
||||||
const recipient = document.recipients.find(
|
const recipient = document.recipients.find((recipient) => recipient.id === field.recipientId);
|
||||||
(recipient) => recipient.email.toLowerCase() === field.signerEmail.toLowerCase(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Each field MUST have a recipient associated with it.
|
// Each field MUST have a recipient associated with it.
|
||||||
if (!recipient) {
|
if (!recipient) {
|
||||||
@@ -236,10 +250,8 @@ export const setFieldsForDocument = async ({
|
|||||||
},
|
},
|
||||||
recipient: {
|
recipient: {
|
||||||
connect: {
|
connect: {
|
||||||
documentId_email: {
|
documentId,
|
||||||
documentId,
|
id: field.recipientId,
|
||||||
email: fieldSignerEmail,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -340,6 +352,7 @@ type FieldData = {
|
|||||||
id?: number | null;
|
id?: number | null;
|
||||||
type: FieldType;
|
type: FieldType;
|
||||||
signerEmail: string;
|
signerEmail: string;
|
||||||
|
recipientId: number;
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
pageX: number;
|
pageX: number;
|
||||||
pageY: number;
|
pageY: number;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type SetFieldsForTemplateOptions = {
|
|||||||
fields: {
|
fields: {
|
||||||
id?: number | null;
|
id?: number | null;
|
||||||
type: FieldType;
|
type: FieldType;
|
||||||
|
signerId: number;
|
||||||
signerEmail: string;
|
signerEmail: string;
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
pageX: number;
|
pageX: number;
|
||||||
@@ -57,12 +58,29 @@ export const setFieldsForTemplate = async ({
|
|||||||
teamId: null,
|
teamId: null,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
recipients: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!template) {
|
if (!template) {
|
||||||
throw new Error('Template not found');
|
throw new Error('Template not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check that every signer has a signature field
|
||||||
|
const signers = template.recipients.filter((recipient) => recipient.role === 'SIGNER');
|
||||||
|
const hasEverySignerSignature = signers.every((signer) =>
|
||||||
|
fields.some(
|
||||||
|
(field) =>
|
||||||
|
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
|
||||||
|
field.signerId === signer.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasEverySignerSignature) {
|
||||||
|
throw new Error('Every signer must have at least one signature field');
|
||||||
|
}
|
||||||
|
|
||||||
const existingFields = await prisma.field.findMany({
|
const existingFields = await prisma.field.findMany({
|
||||||
where: {
|
where: {
|
||||||
templateId,
|
templateId,
|
||||||
@@ -180,10 +198,8 @@ export const setFieldsForTemplate = async ({
|
|||||||
},
|
},
|
||||||
recipient: {
|
recipient: {
|
||||||
connect: {
|
connect: {
|
||||||
templateId_email: {
|
templateId,
|
||||||
templateId,
|
id: field.signerId,
|
||||||
email: field.signerEmail.toLowerCase(),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -125,16 +125,12 @@ export const setDocumentRecipients = async ({
|
|||||||
|
|
||||||
const removedRecipients = existingRecipients.filter(
|
const removedRecipients = existingRecipients.filter(
|
||||||
(existingRecipient) =>
|
(existingRecipient) =>
|
||||||
!normalizedRecipients.find(
|
!normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
|
||||||
(recipient) =>
|
|
||||||
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const linkedRecipients = normalizedRecipients.map((recipient) => {
|
const linkedRecipients = normalizedRecipients.map((recipient) => {
|
||||||
const existing = existingRecipients.find(
|
const existing = existingRecipients.find(
|
||||||
(existingRecipient) =>
|
(existingRecipient) => existingRecipient.id === recipient.id,
|
||||||
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -103,10 +103,7 @@ export const setTemplateRecipients = async ({
|
|||||||
|
|
||||||
const removedRecipients = existingRecipients.filter(
|
const removedRecipients = existingRecipients.filter(
|
||||||
(existingRecipient) =>
|
(existingRecipient) =>
|
||||||
!normalizedRecipients.find(
|
!normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
|
||||||
(recipient) =>
|
|
||||||
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (template.directLink !== null) {
|
if (template.directLink !== null) {
|
||||||
@@ -133,14 +130,10 @@ export const setTemplateRecipients = async ({
|
|||||||
|
|
||||||
const linkedRecipients = normalizedRecipients.map((recipient) => {
|
const linkedRecipients = normalizedRecipients.map((recipient) => {
|
||||||
const existing = existingRecipients.find(
|
const existing = existingRecipients.find(
|
||||||
(existingRecipient) =>
|
(existingRecipient) => existingRecipient.id === recipient.id,
|
||||||
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return { ...recipient, _persisted: existing };
|
||||||
...recipient,
|
|
||||||
_persisted: existing,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const persistedRecipients = await prisma.$transaction(async (tx) => {
|
const persistedRecipients = await prisma.$transaction(async (tx) => {
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ import {
|
|||||||
mapDocumentToWebhookDocumentPayload,
|
mapDocumentToWebhookDocumentPayload,
|
||||||
} from '../../types/webhook-payload';
|
} from '../../types/webhook-payload';
|
||||||
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
import { isRequiredField } from '../../utils/advanced-fields-helpers';
|
|
||||||
import type { CreateDocumentAuditLogDataResponse } from '../../utils/document-audit-logs';
|
import type { CreateDocumentAuditLogDataResponse } from '../../utils/document-audit-logs';
|
||||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||||
import {
|
import {
|
||||||
@@ -176,28 +175,20 @@ export const createDocumentFromDirectTemplate = async ({
|
|||||||
const metaSigningOrder = template.templateMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
|
const metaSigningOrder = template.templateMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
|
||||||
|
|
||||||
// Associate, validate and map to a query every direct template recipient field with the provided fields.
|
// Associate, validate and map to a query every direct template recipient field with the provided fields.
|
||||||
// Only process fields that are either required or have been signed by the user
|
|
||||||
const fieldsToProcess = directTemplateRecipient.fields.filter((templateField) => {
|
|
||||||
const signedFieldValue = signedFieldValues.find((value) => value.fieldId === templateField.id);
|
|
||||||
|
|
||||||
// Include if it's required or has a signed value
|
|
||||||
return isRequiredField(templateField) || signedFieldValue !== undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
const createDirectRecipientFieldArgs = await Promise.all(
|
const createDirectRecipientFieldArgs = await Promise.all(
|
||||||
fieldsToProcess.map(async (templateField) => {
|
directTemplateRecipient.fields.map(async (templateField) => {
|
||||||
const signedFieldValue = signedFieldValues.find(
|
const signedFieldValue = signedFieldValues.find(
|
||||||
(value) => value.fieldId === templateField.id,
|
(value) => value.fieldId === templateField.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isRequiredField(templateField) && !signedFieldValue) {
|
if (!signedFieldValue) {
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
message: 'Invalid, missing or changed fields',
|
message: 'Invalid, missing or changed fields',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (templateField.type === FieldType.NAME && directRecipientName === undefined) {
|
if (templateField.type === FieldType.NAME && directRecipientName === undefined) {
|
||||||
directRecipientName === signedFieldValue?.value;
|
directRecipientName === signedFieldValue.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const derivedRecipientActionAuth = await validateFieldAuth({
|
const derivedRecipientActionAuth = await validateFieldAuth({
|
||||||
@@ -208,18 +199,9 @@ export const createDocumentFromDirectTemplate = async ({
|
|||||||
},
|
},
|
||||||
field: templateField,
|
field: templateField,
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
authOptions: signedFieldValue?.authOptions,
|
authOptions: signedFieldValue.authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!signedFieldValue) {
|
|
||||||
return {
|
|
||||||
templateField,
|
|
||||||
customText: '',
|
|
||||||
derivedRecipientActionAuth,
|
|
||||||
signature: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const { value, isBase64 } = signedFieldValue;
|
const { value, isBase64 } = signedFieldValue;
|
||||||
|
|
||||||
const isSignatureField =
|
const isSignatureField =
|
||||||
@@ -397,7 +379,7 @@ export const createDocumentFromDirectTemplate = async ({
|
|||||||
positionY: templateField.positionY,
|
positionY: templateField.positionY,
|
||||||
width: templateField.width,
|
width: templateField.width,
|
||||||
height: templateField.height,
|
height: templateField.height,
|
||||||
customText: customText ?? '',
|
customText,
|
||||||
inserted: true,
|
inserted: true,
|
||||||
fieldMeta: templateField.fieldMeta || Prisma.JsonNull,
|
fieldMeta: templateField.fieldMeta || Prisma.JsonNull,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -141,10 +141,8 @@ export const createDocumentFromTemplateLegacy = async ({
|
|||||||
|
|
||||||
return await prisma.recipient.upsert({
|
return await prisma.recipient.upsert({
|
||||||
where: {
|
where: {
|
||||||
documentId_email: {
|
documentId: document.id,
|
||||||
documentId: document.id,
|
id: existingRecipient?.id,
|
||||||
email: existingRecipient?.email ?? recipient.email,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
name: recipient.name,
|
name: recipient.name,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { DocumentDistributionMethod } from '@documenso/prisma/client';
|
import type { DocumentDistributionMethod } from '@documenso/prisma/client';
|
||||||
@@ -19,20 +17,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
|
|||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||||
import { ZRecipientAuthOptionsSchema } from '../../types/document-auth';
|
import { ZRecipientAuthOptionsSchema } from '../../types/document-auth';
|
||||||
import type { TDocumentEmailSettings } from '../../types/document-email';
|
import type { TDocumentEmailSettings } from '../../types/document-email';
|
||||||
import type {
|
import { ZFieldMetaSchema } from '../../types/field-meta';
|
||||||
TCheckboxFieldMeta,
|
|
||||||
TDropdownFieldMeta,
|
|
||||||
TFieldMetaPrefillFieldsSchema,
|
|
||||||
TNumberFieldMeta,
|
|
||||||
TRadioFieldMeta,
|
|
||||||
TTextFieldMeta,
|
|
||||||
} from '../../types/field-meta';
|
|
||||||
import {
|
|
||||||
ZCheckboxFieldMeta,
|
|
||||||
ZDropdownFieldMeta,
|
|
||||||
ZFieldMetaSchema,
|
|
||||||
ZRadioFieldMeta,
|
|
||||||
} from '../../types/field-meta';
|
|
||||||
import {
|
import {
|
||||||
ZWebhookDocumentSchema,
|
ZWebhookDocumentSchema,
|
||||||
mapDocumentToWebhookDocumentPayload,
|
mapDocumentToWebhookDocumentPayload,
|
||||||
@@ -65,7 +50,6 @@ export type CreateDocumentFromTemplateOptions = {
|
|||||||
email: string;
|
email: string;
|
||||||
signingOrder?: number | null;
|
signingOrder?: number | null;
|
||||||
}[];
|
}[];
|
||||||
prefillFields?: TFieldMetaPrefillFieldsSchema[];
|
|
||||||
customDocumentDataId?: string;
|
customDocumentDataId?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,165 +72,6 @@ export type CreateDocumentFromTemplateOptions = {
|
|||||||
requestMetadata: ApiRequestMetadata;
|
requestMetadata: ApiRequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUpdatedFieldMeta = (field: Field, prefillField?: TFieldMetaPrefillFieldsSchema) => {
|
|
||||||
if (!prefillField) {
|
|
||||||
return field.fieldMeta;
|
|
||||||
}
|
|
||||||
|
|
||||||
const advancedField = ['NUMBER', 'RADIO', 'CHECKBOX', 'DROPDOWN', 'TEXT'].includes(field.type);
|
|
||||||
|
|
||||||
if (!advancedField) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: `Field ${field.id} is not an advanced field and cannot have field meta information. Allowed types: NUMBER, RADIO, CHECKBOX, DROPDOWN, TEXT.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// We've already validated that the field types match at a higher level
|
|
||||||
// Start with the existing field meta or an empty object
|
|
||||||
const existingMeta = field.fieldMeta || {};
|
|
||||||
|
|
||||||
// Apply type-specific updates based on the prefill field type using ts-pattern
|
|
||||||
return match(prefillField)
|
|
||||||
.with({ type: 'text' }, (field) => {
|
|
||||||
if (typeof field.value !== 'string') {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: `Invalid value for TEXT field ${field.id}: expected string, got ${typeof field.value}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta: TTextFieldMeta = {
|
|
||||||
...existingMeta,
|
|
||||||
type: 'text',
|
|
||||||
label: field.label,
|
|
||||||
text: field.value,
|
|
||||||
};
|
|
||||||
|
|
||||||
return meta;
|
|
||||||
})
|
|
||||||
.with({ type: 'number' }, (field) => {
|
|
||||||
if (typeof field.value !== 'string') {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: `Invalid value for NUMBER field ${field.id}: expected string, got ${typeof field.value}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta: TNumberFieldMeta = {
|
|
||||||
...existingMeta,
|
|
||||||
type: 'number',
|
|
||||||
label: field.label,
|
|
||||||
value: field.value,
|
|
||||||
};
|
|
||||||
|
|
||||||
return meta;
|
|
||||||
})
|
|
||||||
.with({ type: 'radio' }, (field) => {
|
|
||||||
if (typeof field.value !== 'string') {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: `Invalid value for RADIO field ${field.id}: expected string, got ${typeof field.value}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = ZRadioFieldMeta.safeParse(existingMeta);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: `Invalid field meta for RADIO field ${field.id}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const radioMeta = result.data;
|
|
||||||
|
|
||||||
// Validate that the value exists in the options
|
|
||||||
const valueExists = radioMeta.values?.some((option) => option.value === field.value);
|
|
||||||
|
|
||||||
if (!valueExists) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: `Value "${field.value}" not found in options for RADIO field ${field.id}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const newValues = radioMeta.values?.map((option) => ({
|
|
||||||
...option,
|
|
||||||
checked: option.value === field.value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const meta: TRadioFieldMeta = {
|
|
||||||
...existingMeta,
|
|
||||||
type: 'radio',
|
|
||||||
label: field.label,
|
|
||||||
values: newValues,
|
|
||||||
};
|
|
||||||
|
|
||||||
return meta;
|
|
||||||
})
|
|
||||||
.with({ type: 'checkbox' }, (field) => {
|
|
||||||
const result = ZCheckboxFieldMeta.safeParse(existingMeta);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: `Invalid field meta for CHECKBOX field ${field.id}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkboxMeta = result.data;
|
|
||||||
|
|
||||||
// Validate that all values exist in the options
|
|
||||||
for (const value of field.value) {
|
|
||||||
const valueExists = checkboxMeta.values?.some((option) => option.value === value);
|
|
||||||
|
|
||||||
if (!valueExists) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: `Value "${value}" not found in options for CHECKBOX field ${field.id}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newValues = checkboxMeta.values?.map((option) => ({
|
|
||||||
...option,
|
|
||||||
checked: field.value.includes(option.value),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const meta: TCheckboxFieldMeta = {
|
|
||||||
...existingMeta,
|
|
||||||
type: 'checkbox',
|
|
||||||
label: field.label,
|
|
||||||
values: newValues,
|
|
||||||
};
|
|
||||||
|
|
||||||
return meta;
|
|
||||||
})
|
|
||||||
.with({ type: 'dropdown' }, (field) => {
|
|
||||||
const result = ZDropdownFieldMeta.safeParse(existingMeta);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: `Invalid field meta for DROPDOWN field ${field.id}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const dropdownMeta = result.data;
|
|
||||||
|
|
||||||
// Validate that the value exists in the options if values are defined
|
|
||||||
const valueExists = dropdownMeta.values?.some((option) => option.value === field.value);
|
|
||||||
|
|
||||||
if (!valueExists) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: `Value "${field.value}" not found in options for DROPDOWN field ${field.id}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta: TDropdownFieldMeta = {
|
|
||||||
...existingMeta,
|
|
||||||
type: 'dropdown',
|
|
||||||
label: field.label,
|
|
||||||
defaultValue: field.value,
|
|
||||||
};
|
|
||||||
|
|
||||||
return meta;
|
|
||||||
})
|
|
||||||
.otherwise(() => field.fieldMeta);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createDocumentFromTemplate = async ({
|
export const createDocumentFromTemplate = async ({
|
||||||
templateId,
|
templateId,
|
||||||
externalId,
|
externalId,
|
||||||
@@ -256,7 +81,6 @@ export const createDocumentFromTemplate = async ({
|
|||||||
customDocumentDataId,
|
customDocumentDataId,
|
||||||
override,
|
override,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
prefillFields,
|
|
||||||
}: CreateDocumentFromTemplateOptions) => {
|
}: CreateDocumentFromTemplateOptions) => {
|
||||||
const template = await prisma.template.findUnique({
|
const template = await prisma.template.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@@ -432,76 +256,40 @@ export const createDocumentFromTemplate = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const recipientMapping = new Map<number, number>();
|
||||||
|
|
||||||
|
template.recipients.forEach((templateRecipient, index) => {
|
||||||
|
const documentRecipient = document.recipients[index];
|
||||||
|
|
||||||
|
if (documentRecipient) {
|
||||||
|
recipientMapping.set(templateRecipient.id, documentRecipient.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let fieldsToCreate: Omit<Field, 'id' | 'secondaryId' | 'templateId'>[] = [];
|
let fieldsToCreate: Omit<Field, 'id' | 'secondaryId' | 'templateId'>[] = [];
|
||||||
|
|
||||||
// Get all template field IDs first so we can validate later
|
finalRecipients.forEach(({ templateRecipientId, fields }) => {
|
||||||
const allTemplateFieldIds = finalRecipients.flatMap((recipient) =>
|
const documentRecipientId = recipientMapping.get(templateRecipientId);
|
||||||
recipient.fields.map((field) => field.id),
|
const recipient = document.recipients.find((r) => r.id === documentRecipientId);
|
||||||
);
|
|
||||||
|
|
||||||
if (prefillFields?.length) {
|
|
||||||
// Validate that all prefill field IDs exist in the template
|
|
||||||
const invalidFieldIds = prefillFields
|
|
||||||
.map((prefillField) => prefillField.id)
|
|
||||||
.filter((id) => !allTemplateFieldIds.includes(id));
|
|
||||||
|
|
||||||
if (invalidFieldIds.length > 0) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: `The following field IDs do not exist in the template: ${invalidFieldIds.join(', ')}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that all prefill fields have the correct type
|
|
||||||
for (const prefillField of prefillFields) {
|
|
||||||
const templateField = finalRecipients
|
|
||||||
.flatMap((recipient) => recipient.fields)
|
|
||||||
.find((field) => field.id === prefillField.id);
|
|
||||||
|
|
||||||
if (!templateField) {
|
|
||||||
// This should never happen due to the previous validation, but just in case
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: `Field with ID ${prefillField.id} not found in the template`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const expectedType = templateField.type.toLowerCase();
|
|
||||||
const actualType = prefillField.type;
|
|
||||||
|
|
||||||
if (expectedType !== actualType) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: `Field type mismatch for field ${prefillField.id}: expected ${expectedType}, got ${actualType}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.values(finalRecipients).forEach(({ email, fields }) => {
|
|
||||||
const recipient = document.recipients.find((recipient) => recipient.email === email);
|
|
||||||
|
|
||||||
if (!recipient) {
|
if (!recipient) {
|
||||||
throw new Error('Recipient not found.');
|
throw new Error('Recipient not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldsToCreate = fieldsToCreate.concat(
|
fieldsToCreate = fieldsToCreate.concat(
|
||||||
fields.map((field) => {
|
fields.map((field) => ({
|
||||||
const prefillField = prefillFields?.find((value) => value.id === field.id);
|
documentId: document.id,
|
||||||
// Use type assertion to help TypeScript understand the structure
|
recipientId: recipient.id,
|
||||||
const updatedFieldMeta = getUpdatedFieldMeta(field, prefillField);
|
type: field.type,
|
||||||
|
page: field.page,
|
||||||
return {
|
positionX: field.positionX,
|
||||||
documentId: document.id,
|
positionY: field.positionY,
|
||||||
recipientId: recipient.id,
|
width: field.width,
|
||||||
type: field.type,
|
height: field.height,
|
||||||
page: field.page,
|
customText: '',
|
||||||
positionX: field.positionX,
|
inserted: false,
|
||||||
positionY: field.positionY,
|
fieldMeta: field.fieldMeta,
|
||||||
width: field.width,
|
})),
|
||||||
height: field.height,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: updatedFieldMeta,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -123,42 +123,6 @@ export const ZFieldMetaNotOptionalSchema = z.discriminatedUnion('type', [
|
|||||||
|
|
||||||
export type TFieldMetaNotOptionalSchema = z.infer<typeof ZFieldMetaNotOptionalSchema>;
|
export type TFieldMetaNotOptionalSchema = z.infer<typeof ZFieldMetaNotOptionalSchema>;
|
||||||
|
|
||||||
export const ZFieldMetaPrefillFieldsSchema = z
|
|
||||||
.object({
|
|
||||||
id: z.number(),
|
|
||||||
})
|
|
||||||
.and(
|
|
||||||
z.discriminatedUnion('type', [
|
|
||||||
z.object({
|
|
||||||
type: z.literal('text'),
|
|
||||||
label: z.string(),
|
|
||||||
value: z.string(),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
type: z.literal('number'),
|
|
||||||
label: z.string(),
|
|
||||||
value: z.string(),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
type: z.literal('radio'),
|
|
||||||
label: z.string(),
|
|
||||||
value: z.string(),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
type: z.literal('checkbox'),
|
|
||||||
label: z.string(),
|
|
||||||
value: z.array(z.string()),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
type: z.literal('dropdown'),
|
|
||||||
label: z.string(),
|
|
||||||
value: z.string(),
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
export type TFieldMetaPrefillFieldsSchema = z.infer<typeof ZFieldMetaPrefillFieldsSchema>;
|
|
||||||
|
|
||||||
export const ZFieldMetaSchema = z
|
export const ZFieldMetaSchema = z
|
||||||
.union([
|
.union([
|
||||||
// Handles an empty object being provided as fieldMeta.
|
// Handles an empty object being provided as fieldMeta.
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Recipient_documentId_email_key";
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Recipient_templateId_email_key";
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Recipient_email_idx" ON "Recipient"("email");
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- You are about to drop the column `expires` on the `Session` table. All the data in the column will be lost.
|
|
||||||
- Added the required column `expiresAt` to the `Session` table without a default value. This is not possible if the table is not empty.
|
|
||||||
- Added the required column `updatedAt` to the `Session` table without a default value. This is not possible if the table is not empty.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Account" ADD COLUMN "password" TEXT;
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Session" DROP COLUMN "expires",
|
|
||||||
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
ADD COLUMN "expiresAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
ADD COLUMN "ipAddress" TEXT,
|
|
||||||
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
ADD COLUMN "userAgent" TEXT;
|
|
||||||
@@ -270,25 +270,18 @@ model Account {
|
|||||||
scope String?
|
scope String?
|
||||||
id_token String? @db.Text
|
id_token String? @db.Text
|
||||||
session_state String?
|
session_state String?
|
||||||
password String?
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([provider, providerAccountId])
|
@@unique([provider, providerAccountId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
sessionToken String @unique
|
sessionToken String @unique
|
||||||
userId Int
|
userId Int
|
||||||
|
expires DateTime
|
||||||
ipAddress String?
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
userAgent String?
|
|
||||||
expiresAt DateTime
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum DocumentStatus {
|
enum DocumentStatus {
|
||||||
@@ -450,11 +443,10 @@ model Recipient {
|
|||||||
fields Field[]
|
fields Field[]
|
||||||
signatures Signature[]
|
signatures Signature[]
|
||||||
|
|
||||||
@@unique([documentId, email])
|
|
||||||
@@unique([templateId, email])
|
|
||||||
@@index([documentId])
|
@@index([documentId])
|
||||||
@@index([templateId])
|
@@index([templateId])
|
||||||
@@index([token])
|
@@index([token])
|
||||||
|
@@index([email])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum FieldType {
|
enum FieldType {
|
||||||
|
|||||||
@@ -192,14 +192,6 @@ export const ZCreateDocumentV2RequestSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.refine(
|
|
||||||
(recipients) => {
|
|
||||||
const emails = recipients.map((recipient) => recipient.email);
|
|
||||||
|
|
||||||
return new Set(emails).size === emails.length;
|
|
||||||
},
|
|
||||||
{ message: 'Recipients must have unique emails' },
|
|
||||||
)
|
|
||||||
.optional(),
|
.optional(),
|
||||||
meta: z
|
meta: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -232,8 +232,9 @@ export const fieldRouter = router({
|
|||||||
teamId,
|
teamId,
|
||||||
fields: fields.map((field) => ({
|
fields: fields.map((field) => ({
|
||||||
id: field.nativeId,
|
id: field.nativeId,
|
||||||
signerEmail: field.signerEmail,
|
recipientId: field.recipientId,
|
||||||
type: field.type,
|
type: field.type,
|
||||||
|
signerEmail: field.signerEmail,
|
||||||
pageNumber: field.pageNumber,
|
pageNumber: field.pageNumber,
|
||||||
pageX: field.pageX,
|
pageX: field.pageX,
|
||||||
pageY: field.pageY,
|
pageY: field.pageY,
|
||||||
@@ -429,6 +430,7 @@ export const fieldRouter = router({
|
|||||||
teamId,
|
teamId,
|
||||||
fields: fields.map((field) => ({
|
fields: fields.map((field) => ({
|
||||||
id: field.nativeId,
|
id: field.nativeId,
|
||||||
|
signerId: field.recipientId,
|
||||||
signerEmail: field.signerEmail,
|
signerEmail: field.signerEmail,
|
||||||
type: field.type,
|
type: field.type,
|
||||||
pageNumber: field.pageNumber,
|
pageNumber: field.pageNumber,
|
||||||
@@ -452,7 +454,7 @@ export const fieldRouter = router({
|
|||||||
return await signFieldWithToken({
|
return await signFieldWithToken({
|
||||||
token,
|
token,
|
||||||
fieldId,
|
fieldId,
|
||||||
value: value ?? '',
|
value,
|
||||||
isBase64,
|
isBase64,
|
||||||
userId: ctx.user?.id,
|
userId: ctx.user?.id,
|
||||||
authOptions,
|
authOptions,
|
||||||
|
|||||||
@@ -112,13 +112,15 @@ export const ZSetDocumentFieldsRequestSchema = z.object({
|
|||||||
z.object({
|
z.object({
|
||||||
formId: z.string().min(1),
|
formId: z.string().min(1),
|
||||||
nativeId: z.number().optional(),
|
nativeId: z.number().optional(),
|
||||||
|
id: z.number().optional(),
|
||||||
type: z.nativeEnum(FieldType),
|
type: z.nativeEnum(FieldType),
|
||||||
|
recipientId: z.number(),
|
||||||
signerEmail: z.string().min(1),
|
signerEmail: z.string().min(1),
|
||||||
pageNumber: z.number().min(1),
|
pageNumber: ZFieldPageNumberSchema,
|
||||||
pageX: z.number().min(0),
|
pageX: ZFieldPageXSchema,
|
||||||
pageY: z.number().min(0),
|
pageY: ZFieldPageYSchema,
|
||||||
pageWidth: z.number().min(0),
|
pageWidth: ZFieldWidthSchema,
|
||||||
pageHeight: z.number().min(0),
|
pageHeight: ZFieldHeightSchema,
|
||||||
fieldMeta: ZFieldMetaSchema,
|
fieldMeta: ZFieldMetaSchema,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -136,6 +138,8 @@ export const ZSetFieldsForTemplateRequestSchema = z.object({
|
|||||||
nativeId: z.number().optional(),
|
nativeId: z.number().optional(),
|
||||||
type: z.nativeEnum(FieldType),
|
type: z.nativeEnum(FieldType),
|
||||||
signerEmail: z.string().min(1),
|
signerEmail: z.string().min(1),
|
||||||
|
recipientId: z.number().min(1),
|
||||||
|
signerId: z.number().min(1),
|
||||||
pageNumber: z.number().min(1),
|
pageNumber: z.number().min(1),
|
||||||
pageX: z.number().min(0),
|
pageX: z.number().min(0),
|
||||||
pageY: z.number().min(0),
|
pageY: z.number().min(0),
|
||||||
@@ -153,7 +157,7 @@ export const ZSetFieldsForTemplateResponseSchema = z.object({
|
|||||||
export const ZSignFieldWithTokenMutationSchema = z.object({
|
export const ZSignFieldWithTokenMutationSchema = z.object({
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
fieldId: z.number(),
|
fieldId: z.number(),
|
||||||
value: z.string().trim().optional(),
|
value: z.string().trim(),
|
||||||
isBase64: z.boolean().optional(),
|
isBase64: z.boolean().optional(),
|
||||||
authOptions: ZRecipientActionAuthSchema.optional(),
|
authOptions: ZRecipientActionAuthSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,16 +49,7 @@ export const ZCreateDocumentRecipientResponseSchema = ZRecipientLiteSchema;
|
|||||||
|
|
||||||
export const ZCreateDocumentRecipientsRequestSchema = z.object({
|
export const ZCreateDocumentRecipientsRequestSchema = z.object({
|
||||||
documentId: z.number(),
|
documentId: z.number(),
|
||||||
recipients: z.array(ZCreateRecipientSchema).refine(
|
recipients: z.array(ZCreateRecipientSchema),
|
||||||
(recipients) => {
|
|
||||||
const emails = recipients.map((recipient) => recipient.email.toLowerCase());
|
|
||||||
|
|
||||||
return new Set(emails).size === emails.length;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: 'Recipients must have unique emails',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZCreateDocumentRecipientsResponseSchema = z.object({
|
export const ZCreateDocumentRecipientsResponseSchema = z.object({
|
||||||
@@ -74,18 +65,7 @@ export const ZUpdateDocumentRecipientResponseSchema = ZRecipientSchema;
|
|||||||
|
|
||||||
export const ZUpdateDocumentRecipientsRequestSchema = z.object({
|
export const ZUpdateDocumentRecipientsRequestSchema = z.object({
|
||||||
documentId: z.number(),
|
documentId: z.number(),
|
||||||
recipients: z.array(ZUpdateRecipientSchema).refine(
|
recipients: z.array(ZUpdateRecipientSchema),
|
||||||
(recipients) => {
|
|
||||||
const emails = recipients
|
|
||||||
.filter((recipient) => recipient.email !== undefined)
|
|
||||||
.map((recipient) => recipient.email?.toLowerCase());
|
|
||||||
|
|
||||||
return new Set(emails).size === emails.length;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: 'Recipients must have unique emails',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZUpdateDocumentRecipientsResponseSchema = z.object({
|
export const ZUpdateDocumentRecipientsResponseSchema = z.object({
|
||||||
@@ -96,29 +76,19 @@ export const ZDeleteDocumentRecipientRequestSchema = z.object({
|
|||||||
recipientId: z.number(),
|
recipientId: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZSetDocumentRecipientsRequestSchema = z
|
export const ZSetDocumentRecipientsRequestSchema = z.object({
|
||||||
.object({
|
documentId: z.number(),
|
||||||
documentId: z.number(),
|
recipients: z.array(
|
||||||
recipients: z.array(
|
z.object({
|
||||||
z.object({
|
nativeId: z.number().optional(),
|
||||||
nativeId: z.number().optional(),
|
email: z.string().toLowerCase().email().min(1),
|
||||||
email: z.string().toLowerCase().email().min(1),
|
name: z.string(),
|
||||||
name: z.string(),
|
role: z.nativeEnum(RecipientRole),
|
||||||
role: z.nativeEnum(RecipientRole),
|
signingOrder: z.number().optional(),
|
||||||
signingOrder: z.number().optional(),
|
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
|
||||||
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
|
}),
|
||||||
}),
|
),
|
||||||
),
|
});
|
||||||
})
|
|
||||||
.refine(
|
|
||||||
(schema) => {
|
|
||||||
const emails = schema.recipients.map((recipient) => recipient.email.toLowerCase());
|
|
||||||
|
|
||||||
return new Set(emails).size === emails.length;
|
|
||||||
},
|
|
||||||
// Dirty hack to handle errors when .root is populated for an array type
|
|
||||||
{ message: 'Recipients must have unique emails', path: ['recipients__root'] },
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ZSetDocumentRecipientsResponseSchema = z.object({
|
export const ZSetDocumentRecipientsResponseSchema = z.object({
|
||||||
recipients: ZRecipientLiteSchema.array(),
|
recipients: ZRecipientLiteSchema.array(),
|
||||||
@@ -133,16 +103,7 @@ export const ZCreateTemplateRecipientResponseSchema = ZRecipientLiteSchema;
|
|||||||
|
|
||||||
export const ZCreateTemplateRecipientsRequestSchema = z.object({
|
export const ZCreateTemplateRecipientsRequestSchema = z.object({
|
||||||
templateId: z.number(),
|
templateId: z.number(),
|
||||||
recipients: z.array(ZCreateRecipientSchema).refine(
|
recipients: z.array(ZCreateRecipientSchema),
|
||||||
(recipients) => {
|
|
||||||
const emails = recipients.map((recipient) => recipient.email);
|
|
||||||
|
|
||||||
return new Set(emails).size === emails.length;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: 'Recipients must have unique emails',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZCreateTemplateRecipientsResponseSchema = z.object({
|
export const ZCreateTemplateRecipientsResponseSchema = z.object({
|
||||||
@@ -158,18 +119,7 @@ export const ZUpdateTemplateRecipientResponseSchema = ZRecipientSchema;
|
|||||||
|
|
||||||
export const ZUpdateTemplateRecipientsRequestSchema = z.object({
|
export const ZUpdateTemplateRecipientsRequestSchema = z.object({
|
||||||
templateId: z.number(),
|
templateId: z.number(),
|
||||||
recipients: z.array(ZUpdateRecipientSchema).refine(
|
recipients: z.array(ZUpdateRecipientSchema),
|
||||||
(recipients) => {
|
|
||||||
const emails = recipients
|
|
||||||
.filter((recipient) => recipient.email !== undefined)
|
|
||||||
.map((recipient) => recipient.email);
|
|
||||||
|
|
||||||
return new Set(emails).size === emails.length;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: 'Recipients must have unique emails',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZUpdateTemplateRecipientsResponseSchema = z.object({
|
export const ZUpdateTemplateRecipientsResponseSchema = z.object({
|
||||||
@@ -180,29 +130,19 @@ export const ZDeleteTemplateRecipientRequestSchema = z.object({
|
|||||||
recipientId: z.number(),
|
recipientId: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZSetTemplateRecipientsRequestSchema = z
|
export const ZSetTemplateRecipientsRequestSchema = z.object({
|
||||||
.object({
|
templateId: z.number(),
|
||||||
templateId: z.number(),
|
recipients: z.array(
|
||||||
recipients: z.array(
|
z.object({
|
||||||
z.object({
|
nativeId: z.number().optional(),
|
||||||
nativeId: z.number().optional(),
|
email: z.string().toLowerCase().email().min(1),
|
||||||
email: z.string().toLowerCase().email().min(1),
|
name: z.string(),
|
||||||
name: z.string(),
|
role: z.nativeEnum(RecipientRole),
|
||||||
role: z.nativeEnum(RecipientRole),
|
signingOrder: z.number().optional(),
|
||||||
signingOrder: z.number().optional(),
|
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
|
||||||
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
|
}),
|
||||||
}),
|
),
|
||||||
),
|
});
|
||||||
})
|
|
||||||
.refine(
|
|
||||||
(schema) => {
|
|
||||||
const emails = schema.recipients.map((recipient) => recipient.email);
|
|
||||||
|
|
||||||
return new Set(emails).size === emails.length;
|
|
||||||
},
|
|
||||||
// Dirty hack to handle errors when .root is populated for an array type
|
|
||||||
{ message: 'Recipients must have unique emails', path: ['recipients__root'] },
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ZSetTemplateRecipientsResponseSchema = z.object({
|
export const ZSetTemplateRecipientsResponseSchema = z.object({
|
||||||
recipients: ZRecipientLiteSchema.array(),
|
recipients: ZRecipientLiteSchema.array(),
|
||||||
|
|||||||
@@ -227,8 +227,7 @@ export const templateRouter = router({
|
|||||||
.output(ZCreateDocumentFromTemplateResponseSchema)
|
.output(ZCreateDocumentFromTemplateResponseSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { teamId } = ctx;
|
const { teamId } = ctx;
|
||||||
const { templateId, recipients, distributeDocument, customDocumentDataId, prefillFields } =
|
const { templateId, recipients, distributeDocument, customDocumentDataId } = input;
|
||||||
input;
|
|
||||||
|
|
||||||
const limits = await getServerLimits({ email: ctx.user.email, teamId });
|
const limits = await getServerLimits({ email: ctx.user.email, teamId });
|
||||||
|
|
||||||
@@ -243,7 +242,6 @@ export const templateRouter = router({
|
|||||||
recipients,
|
recipients,
|
||||||
customDocumentDataId,
|
customDocumentDataId,
|
||||||
requestMetadata: ctx.metadata,
|
requestMetadata: ctx.metadata,
|
||||||
prefillFields,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (distributeDocument) {
|
if (distributeDocument) {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
ZDocumentActionAuthTypesSchema,
|
ZDocumentActionAuthTypesSchema,
|
||||||
} from '@documenso/lib/types/document-auth';
|
} from '@documenso/lib/types/document-auth';
|
||||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||||
import { ZFieldMetaPrefillFieldsSchema } from '@documenso/lib/types/field-meta';
|
|
||||||
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
|
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
import {
|
import {
|
||||||
ZTemplateLiteSchema,
|
ZTemplateLiteSchema,
|
||||||
@@ -52,12 +51,7 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
|
|||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.describe('The information of the recipients to create the document with.')
|
.describe('The information of the recipients to create the document with.'),
|
||||||
.refine((recipients) => {
|
|
||||||
const emails = recipients.map((signer) => signer.email);
|
|
||||||
|
|
||||||
return new Set(emails).size === emails.length;
|
|
||||||
}, 'Recipients must have unique emails'),
|
|
||||||
distributeDocument: z
|
distributeDocument: z
|
||||||
.boolean()
|
.boolean()
|
||||||
.describe('Whether to create the document as pending and distribute it to recipients.')
|
.describe('Whether to create the document as pending and distribute it to recipients.')
|
||||||
@@ -68,7 +62,6 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
|
|||||||
'The data ID of an alternative PDF to use when creating the document. If not provided, the PDF attached to the template will be used.',
|
'The data ID of an alternative PDF to use when creating the document. If not provided, the PDF attached to the template will be used.',
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
prefillFields: z.array(ZFieldMetaPrefillFieldsSchema).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZCreateDocumentFromTemplateResponseSchema = ZDocumentSchema;
|
export const ZCreateDocumentFromTemplateResponseSchema = ZDocumentSchema;
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export type FieldFormType = {
|
|||||||
pageY: number;
|
pageY: number;
|
||||||
pageWidth: number;
|
pageWidth: number;
|
||||||
pageHeight: number;
|
pageHeight: number;
|
||||||
|
recipientId: number;
|
||||||
signerEmail: string;
|
signerEmail: string;
|
||||||
fieldMeta?: FieldMeta;
|
fieldMeta?: FieldMeta;
|
||||||
};
|
};
|
||||||
@@ -143,6 +144,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
pageY: Number(field.positionY),
|
pageY: Number(field.positionY),
|
||||||
pageWidth: Number(field.width),
|
pageWidth: Number(field.width),
|
||||||
pageHeight: Number(field.height),
|
pageHeight: Number(field.height),
|
||||||
|
recipientId: field.recipientId,
|
||||||
signerEmail:
|
signerEmail:
|
||||||
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
||||||
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
|
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
|
||||||
@@ -348,6 +350,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
pageY,
|
pageY,
|
||||||
pageWidth: fieldPageWidth,
|
pageWidth: fieldPageWidth,
|
||||||
pageHeight: fieldPageHeight,
|
pageHeight: fieldPageHeight,
|
||||||
|
recipientId: selectedSigner.id,
|
||||||
signerEmail: selectedSigner.email,
|
signerEmail: selectedSigner.email,
|
||||||
fieldMeta: undefined,
|
fieldMeta: undefined,
|
||||||
};
|
};
|
||||||
@@ -441,6 +444,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
const newField: TAddFieldsFormSchema['fields'][0] = {
|
const newField: TAddFieldsFormSchema['fields'][0] = {
|
||||||
...structuredClone(lastActiveField),
|
...structuredClone(lastActiveField),
|
||||||
formId: nanoid(12),
|
formId: nanoid(12),
|
||||||
|
recipientId: selectedSigner?.id ?? lastActiveField.recipientId,
|
||||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||||
pageX: lastActiveField.pageX + 3,
|
pageX: lastActiveField.pageX + 3,
|
||||||
pageY: lastActiveField.pageY + 3,
|
pageY: lastActiveField.pageY + 3,
|
||||||
@@ -449,7 +453,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
append(newField);
|
append(newField);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[append, lastActiveField, selectedSigner?.email, toast],
|
[append, lastActiveField, selectedSigner?.id, selectedSigner?.email, toast],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onFieldPaste = useCallback(
|
const onFieldPaste = useCallback(
|
||||||
@@ -462,13 +466,15 @@ export const AddFieldsFormPartial = ({
|
|||||||
append({
|
append({
|
||||||
...copiedField,
|
...copiedField,
|
||||||
formId: nanoid(12),
|
formId: nanoid(12),
|
||||||
|
recipientId: selectedSigner?.id ?? copiedField.recipientId,
|
||||||
signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
|
signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
|
||||||
|
|
||||||
pageX: copiedField.pageX + 3,
|
pageX: copiedField.pageX + 3,
|
||||||
pageY: copiedField.pageY + 3,
|
pageY: copiedField.pageY + 3,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[append, fieldClipboard, selectedSigner?.email],
|
[append, fieldClipboard, selectedSigner?.id, selectedSigner?.email],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -567,7 +573,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
localFields.some(
|
localFields.some(
|
||||||
(field) =>
|
(field) =>
|
||||||
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
|
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
|
||||||
field.signerEmail === signer.email,
|
field.recipientId === signer.id,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -637,7 +643,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
|
|
||||||
{isDocumentPdfLoaded &&
|
{isDocumentPdfLoaded &&
|
||||||
localFields.map((field, index) => {
|
localFields.map((field, index) => {
|
||||||
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail);
|
const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
|
||||||
const hasFieldError =
|
const hasFieldError =
|
||||||
emptyCheckboxFields.find((f) => f.formId === field.formId) ||
|
emptyCheckboxFields.find((f) => f.formId === field.formId) ||
|
||||||
emptyRadioFields.find((f) => f.formId === field.formId) ||
|
emptyRadioFields.find((f) => f.formId === field.formId) ||
|
||||||
@@ -649,7 +655,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
recipientIndex={recipientIndex === -1 ? 0 : recipientIndex}
|
recipientIndex={recipientIndex === -1 ? 0 : recipientIndex}
|
||||||
field={field}
|
field={field}
|
||||||
disabled={
|
disabled={
|
||||||
selectedSigner?.email !== field.signerEmail ||
|
selectedSigner?.id !== field.recipientId ||
|
||||||
!canRecipientBeModified(selectedSigner, fields)
|
!canRecipientBeModified(selectedSigner, fields)
|
||||||
}
|
}
|
||||||
minHeight={MIN_HEIGHT_PX}
|
minHeight={MIN_HEIGHT_PX}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const ZAddFieldsFormSchema = z.object({
|
|||||||
nativeId: z.number().optional(),
|
nativeId: z.number().optional(),
|
||||||
type: z.nativeEnum(FieldType),
|
type: z.nativeEnum(FieldType),
|
||||||
signerEmail: z.string().min(1),
|
signerEmail: z.string().min(1),
|
||||||
|
recipientId: z.number(),
|
||||||
pageNumber: z.number().min(1),
|
pageNumber: z.number().min(1),
|
||||||
pageX: z.number().min(0),
|
pageX: z.number().min(0),
|
||||||
pageY: z.number().min(0),
|
pageY: z.number().min(0),
|
||||||
|
|||||||
@@ -6,34 +6,22 @@ import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-a
|
|||||||
import { ZMapNegativeOneToUndefinedSchema } from './add-settings.types';
|
import { ZMapNegativeOneToUndefinedSchema } from './add-settings.types';
|
||||||
import { DocumentSigningOrder, RecipientRole } from '.prisma/client';
|
import { DocumentSigningOrder, RecipientRole } from '.prisma/client';
|
||||||
|
|
||||||
export const ZAddSignersFormSchema = z
|
export const ZAddSignersFormSchema = z.object({
|
||||||
.object({
|
signers: z.array(
|
||||||
signers: z.array(
|
z.object({
|
||||||
z.object({
|
formId: z.string().min(1),
|
||||||
formId: z.string().min(1),
|
nativeId: z.number().optional(),
|
||||||
nativeId: z.number().optional(),
|
email: z
|
||||||
email: z
|
.string()
|
||||||
.string()
|
.email({ message: msg`Invalid email`.id })
|
||||||
.email({ message: msg`Invalid email`.id })
|
.min(1),
|
||||||
.min(1),
|
name: z.string(),
|
||||||
name: z.string(),
|
role: z.nativeEnum(RecipientRole),
|
||||||
role: z.nativeEnum(RecipientRole),
|
signingOrder: z.number().optional(),
|
||||||
signingOrder: z.number().optional(),
|
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(ZRecipientActionAuthTypesSchema.optional()),
|
||||||
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
|
}),
|
||||||
ZRecipientActionAuthTypesSchema.optional(),
|
),
|
||||||
),
|
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||||
}),
|
});
|
||||||
),
|
|
||||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
|
||||||
})
|
|
||||||
.refine(
|
|
||||||
(schema) => {
|
|
||||||
const emails = schema.signers.map((signer) => signer.email.toLowerCase());
|
|
||||||
|
|
||||||
return new Set(emails).size === emails.length;
|
|
||||||
},
|
|
||||||
// Dirty hack to handle errors when .root is populated for an array type
|
|
||||||
{ message: msg`Signers must have unique emails`.id, path: ['signers__root'] },
|
|
||||||
);
|
|
||||||
|
|
||||||
export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;
|
export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { match } from 'ts-pattern';
|
|||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '@documenso/lib/types/field-meta';
|
import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '@documenso/lib/types/field-meta';
|
||||||
import { FieldType } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
import { useSignerColors } from '../../lib/signer-colors';
|
import { useSignerColors } from '../../lib/signer-colors';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
@@ -186,35 +185,11 @@ export const FieldItem = ({
|
|||||||
() => hasFieldMetaValues('CHECKBOX', field.fieldMeta, ZCheckboxFieldMeta),
|
() => hasFieldMetaValues('CHECKBOX', field.fieldMeta, ZCheckboxFieldMeta),
|
||||||
[field.fieldMeta],
|
[field.fieldMeta],
|
||||||
);
|
);
|
||||||
|
|
||||||
const radioHasValues = useMemo(
|
const radioHasValues = useMemo(
|
||||||
() => hasFieldMetaValues('RADIO', field.fieldMeta, ZRadioFieldMeta),
|
() => hasFieldMetaValues('RADIO', field.fieldMeta, ZRadioFieldMeta),
|
||||||
[field.fieldMeta],
|
[field.fieldMeta],
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasCheckedValues = (fieldMeta: TFieldMetaSchema, type: FieldType) => {
|
|
||||||
if (!fieldMeta || (type !== FieldType.RADIO && type !== FieldType.CHECKBOX)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === FieldType.RADIO) {
|
|
||||||
const parsed = ZRadioFieldMeta.parse(fieldMeta);
|
|
||||||
return parsed.values?.some((value) => value.checked) ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === FieldType.CHECKBOX) {
|
|
||||||
const parsed = ZCheckboxFieldMeta.parse(fieldMeta);
|
|
||||||
return parsed.values?.some((value) => value.checked) ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fieldHasCheckedValues = useMemo(
|
|
||||||
() => hasCheckedValues(field.fieldMeta, field.type),
|
|
||||||
[field.fieldMeta, field.type],
|
|
||||||
);
|
|
||||||
|
|
||||||
const fixedSize = checkBoxHasValues || radioHasValues;
|
const fixedSize = checkBoxHasValues || radioHasValues;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
@@ -254,21 +229,6 @@ export const FieldItem = ({
|
|||||||
onMove?.(d.node);
|
onMove?.(d.node);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
|
|
||||||
field.fieldMeta?.label && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'absolute -top-16 left-0 right-0 rounded-md p-2 text-center text-xs text-gray-700',
|
|
||||||
{
|
|
||||||
'bg-foreground/5 border-primary border': !fieldHasCheckedValues,
|
|
||||||
'bg-documenso-200 border-primary border': fieldHasCheckedValues,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{field.fieldMeta.label}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex h-full w-full items-center justify-center bg-white',
|
'relative flex h-full w-full items-center justify-center bg-white',
|
||||||
|
|||||||
@@ -126,18 +126,6 @@ export const CheckboxFieldAdvancedSettings = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="mb-2">
|
|
||||||
<Label>
|
|
||||||
<Trans>Label</Trans>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="label"
|
|
||||||
className="bg-background mt-2"
|
|
||||||
placeholder={_(msg`Field label`)}
|
|
||||||
value={fieldState.label}
|
|
||||||
onChange={(e) => handleFieldChange('label', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row items-center gap-x-4">
|
<div className="flex flex-row items-center gap-x-4">
|
||||||
<div className="flex w-2/3 flex-col">
|
<div className="flex w-2/3 flex-col">
|
||||||
<Label>
|
<Label>
|
||||||
|
|||||||
@@ -105,12 +105,8 @@ export const DropdownFieldAdvancedSettings = ({
|
|||||||
<Trans>Select default option</Trans>
|
<Trans>Select default option</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={defaultValue}
|
defaultValue={defaultValue}
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
if (!val) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDefaultValue(val);
|
setDefaultValue(val);
|
||||||
handleFieldChange('defaultValue', val);
|
handleFieldChange('defaultValue', val);
|
||||||
}}
|
}}
|
||||||
@@ -176,7 +172,7 @@ export const DropdownFieldAdvancedSettings = ({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
onClick={() => removeValue(index)}
|
onClick={() => removeValue(index)}
|
||||||
>
|
>
|
||||||
<Trash className="h-5 w-5" />
|
<Trash className="h-5 w-5" />
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { Trans } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { ChevronDown, ChevronUp, Trash } from 'lucide-react';
|
import { ChevronDown, ChevronUp, Trash } from 'lucide-react';
|
||||||
|
|
||||||
import { validateRadioField } from '@documenso/lib/advanced-fields-validation/validate-radio';
|
import { validateRadioField } from '@documenso/lib/advanced-fields-validation/validate-radio';
|
||||||
@@ -28,8 +27,6 @@ export const RadioFieldAdvancedSettings = ({
|
|||||||
handleFieldChange,
|
handleFieldChange,
|
||||||
handleErrors,
|
handleErrors,
|
||||||
}: RadioFieldAdvancedSettingsProps) => {
|
}: RadioFieldAdvancedSettingsProps) => {
|
||||||
const { _ } = useLingui();
|
|
||||||
|
|
||||||
const [showValidation, setShowValidation] = useState(false);
|
const [showValidation, setShowValidation] = useState(false);
|
||||||
const [values, setValues] = useState(
|
const [values, setValues] = useState(
|
||||||
fieldState.values ?? [{ id: 1, checked: false, value: 'Default value' }],
|
fieldState.values ?? [{ id: 1, checked: false, value: 'Default value' }],
|
||||||
@@ -105,18 +102,6 @@ export const RadioFieldAdvancedSettings = ({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div>
|
|
||||||
<Label>
|
|
||||||
<Trans>Label</Trans>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="label"
|
|
||||||
className="bg-background mt-2"
|
|
||||||
placeholder={_(msg`Field label`)}
|
|
||||||
value={fieldState.label}
|
|
||||||
onChange={(e) => handleFieldChange('label', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
className="bg-background"
|
className="bg-background"
|
||||||
|
|||||||
@@ -126,13 +126,7 @@ export const TextFieldAdvancedSettings = ({
|
|||||||
|
|
||||||
<Select
|
<Select
|
||||||
value={fieldState.textAlign}
|
value={fieldState.textAlign}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => handleInput('textAlign', value)}
|
||||||
if (!value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInput('textAlign', value);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-background mt-2">
|
<SelectTrigger className="bg-background mt-2">
|
||||||
<SelectValue placeholder="Select text align" />
|
<SelectValue placeholder="Select text align" />
|
||||||
|
|||||||
@@ -8,19 +8,14 @@ import { FieldType } from '@documenso/prisma/client';
|
|||||||
export const ZDocumentFlowFormSchema = z.object({
|
export const ZDocumentFlowFormSchema = z.object({
|
||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
|
|
||||||
signers: z
|
signers: z.array(
|
||||||
.array(
|
z.object({
|
||||||
z.object({
|
formId: z.string().min(1),
|
||||||
formId: z.string().min(1),
|
nativeId: z.number().optional(),
|
||||||
nativeId: z.number().optional(),
|
email: z.string().min(1).email(),
|
||||||
email: z.string().min(1).email(),
|
name: z.string(),
|
||||||
name: z.string(),
|
}),
|
||||||
}),
|
),
|
||||||
)
|
|
||||||
.refine((signers) => {
|
|
||||||
const emails = signers.map((signer) => signer.email);
|
|
||||||
return new Set(emails).size === emails.length;
|
|
||||||
}, 'Signers must have unique emails'),
|
|
||||||
|
|
||||||
fields: z.array(
|
fields: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import { getSignerColorStyles, useSignerColors } from '../../lib/signer-colors';
|
|||||||
import { Checkbox } from '../checkbox';
|
import { Checkbox } from '../checkbox';
|
||||||
import type { FieldFormType } from '../document-flow/add-fields';
|
import type { FieldFormType } from '../document-flow/add-fields';
|
||||||
import { FieldAdvancedSettings } from '../document-flow/field-item-advanced-settings';
|
import { FieldAdvancedSettings } from '../document-flow/field-item-advanced-settings';
|
||||||
|
import { MissingSignatureFieldDialog } from '../document-flow/missing-signature-field-dialog';
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel } from '../form/form';
|
import { Form, FormControl, FormField, FormItem, FormLabel } from '../form/form';
|
||||||
import { useStep } from '../stepper';
|
import { useStep } from '../stepper';
|
||||||
import type { TAddTemplateFieldsFormSchema } from './add-template-fields.types';
|
import type { TAddTemplateFieldsFormSchema } from './add-template-fields.types';
|
||||||
@@ -110,6 +111,7 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
const [fieldClipboard, setFieldClipboard] = useState<
|
const [fieldClipboard, setFieldClipboard] = useState<
|
||||||
TAddTemplateFieldsFormSchema['fields'][0] | null
|
TAddTemplateFieldsFormSchema['fields'][0] | null
|
||||||
>(null);
|
>(null);
|
||||||
|
const [isMissingSignatureDialogVisible, setIsMissingSignatureDialogVisible] = useState(false);
|
||||||
|
|
||||||
const form = useForm<TAddTemplateFieldsFormSchema>({
|
const form = useForm<TAddTemplateFieldsFormSchema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -122,6 +124,7 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
pageY: Number(field.positionY),
|
pageY: Number(field.positionY),
|
||||||
pageWidth: Number(field.width),
|
pageWidth: Number(field.width),
|
||||||
pageHeight: Number(field.height),
|
pageHeight: Number(field.height),
|
||||||
|
recipientId: field.recipientId ?? -1,
|
||||||
signerId: field.recipientId ?? -1,
|
signerId: field.recipientId ?? -1,
|
||||||
signerEmail:
|
signerEmail:
|
||||||
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
||||||
@@ -177,6 +180,8 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
formId: nanoid(12),
|
formId: nanoid(12),
|
||||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||||
signerId: selectedSigner?.id ?? lastActiveField.signerId,
|
signerId: selectedSigner?.id ?? lastActiveField.signerId,
|
||||||
|
recipientId:
|
||||||
|
selectedSigner?.id || lastActiveField.recipientId || lastActiveField.signerId || 0,
|
||||||
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
|
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
|
||||||
pageX: lastActiveField.pageX + 3,
|
pageX: lastActiveField.pageX + 3,
|
||||||
pageY: lastActiveField.pageY + 3,
|
pageY: lastActiveField.pageY + 3,
|
||||||
@@ -201,19 +206,29 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const copiedField = structuredClone(fieldClipboard);
|
const copiedField = structuredClone(fieldClipboard);
|
||||||
|
const signerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id);
|
||||||
|
|
||||||
append({
|
append({
|
||||||
...copiedField,
|
...copiedField,
|
||||||
formId: nanoid(12),
|
formId: nanoid(12),
|
||||||
signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
|
signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
|
||||||
signerId: selectedSigner?.id ?? copiedField.signerId,
|
signerId: selectedSigner?.id ?? copiedField.signerId,
|
||||||
|
recipientId: selectedSigner?.id || copiedField.recipientId || copiedField.signerId || 0,
|
||||||
signerToken: selectedSigner?.token ?? copiedField.signerToken,
|
signerToken: selectedSigner?.token ?? copiedField.signerToken,
|
||||||
|
signerIndex: signerIndex >= 0 ? signerIndex : 0,
|
||||||
pageX: copiedField.pageX + 3,
|
pageX: copiedField.pageX + 3,
|
||||||
pageY: copiedField.pageY + 3,
|
pageY: copiedField.pageY + 3,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[append, fieldClipboard, selectedSigner?.email, selectedSigner?.id, selectedSigner?.token],
|
[
|
||||||
|
append,
|
||||||
|
fieldClipboard,
|
||||||
|
selectedSigner?.email,
|
||||||
|
selectedSigner?.id,
|
||||||
|
selectedSigner?.token,
|
||||||
|
recipients,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt));
|
useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt));
|
||||||
@@ -319,6 +334,8 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
pageX -= fieldPageWidth / 2;
|
pageX -= fieldPageWidth / 2;
|
||||||
pageY -= fieldPageHeight / 2;
|
pageY -= fieldPageHeight / 2;
|
||||||
|
|
||||||
|
const signerIndex = recipients.findIndex((r) => r.id === selectedSigner.id);
|
||||||
|
|
||||||
append({
|
append({
|
||||||
formId: nanoid(12),
|
formId: nanoid(12),
|
||||||
type: selectedField,
|
type: selectedField,
|
||||||
@@ -329,14 +346,17 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
pageHeight: fieldPageHeight,
|
pageHeight: fieldPageHeight,
|
||||||
signerEmail: selectedSigner.email,
|
signerEmail: selectedSigner.email,
|
||||||
signerId: selectedSigner.id,
|
signerId: selectedSigner.id,
|
||||||
|
recipientId:
|
||||||
|
selectedSigner.id || lastActiveField?.recipientId || lastActiveField?.signerId || 0,
|
||||||
signerToken: selectedSigner.token ?? '',
|
signerToken: selectedSigner.token ?? '',
|
||||||
|
signerIndex: signerIndex >= 0 ? signerIndex : 0,
|
||||||
fieldMeta: undefined,
|
fieldMeta: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsFieldWithinBounds(false);
|
setIsFieldWithinBounds(false);
|
||||||
setSelectedField(null);
|
setSelectedField(null);
|
||||||
},
|
},
|
||||||
[append, isWithinPageBounds, selectedField, selectedSigner, getPage],
|
[append, isWithinPageBounds, selectedField, selectedSigner, getPage, recipients],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onFieldResize = useCallback(
|
const onFieldResize = useCallback(
|
||||||
@@ -499,6 +519,23 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
form.setValue('typedSignatureEnabled', value, { shouldDirty: true });
|
form.setValue('typedSignatureEnabled', value, { shouldDirty: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGoNextClick = () => {
|
||||||
|
const everySignerHasSignature = recipientsByRole.SIGNER.every((signer) =>
|
||||||
|
localFields.some(
|
||||||
|
(field) =>
|
||||||
|
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
|
||||||
|
field.recipientId === signer.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!everySignerHasSignature) {
|
||||||
|
setIsMissingSignatureDialogVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void onFormSubmit();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showAdvancedSettings && currentField ? (
|
{showAdvancedSettings && currentField ? (
|
||||||
@@ -546,14 +583,15 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{localFields.map((field, index) => {
|
{localFields.map((field, index) => {
|
||||||
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail);
|
const recipientIndex =
|
||||||
|
field.signerIndex ?? recipients.findIndex((r) => r.id === field.signerId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FieldItem
|
<FieldItem
|
||||||
key={index}
|
key={index}
|
||||||
recipientIndex={recipientIndex === -1 ? 0 : recipientIndex}
|
recipientIndex={recipientIndex >= 0 ? recipientIndex : 0}
|
||||||
field={field}
|
field={field}
|
||||||
disabled={selectedSigner?.email !== field.signerEmail}
|
disabled={selectedSigner?.id !== field.signerId}
|
||||||
minHeight={MIN_HEIGHT_PX}
|
minHeight={MIN_HEIGHT_PX}
|
||||||
minWidth={MIN_WIDTH_PX}
|
minWidth={MIN_WIDTH_PX}
|
||||||
defaultHeight={DEFAULT_HEIGHT_PX}
|
defaultHeight={DEFAULT_HEIGHT_PX}
|
||||||
@@ -993,9 +1031,14 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
previousStep();
|
previousStep();
|
||||||
remove();
|
remove();
|
||||||
}}
|
}}
|
||||||
onGoNextClick={() => void onFormSubmit()}
|
onGoNextClick={handleGoNextClick}
|
||||||
/>
|
/>
|
||||||
</DocumentFlowFormContainerFooter>
|
</DocumentFlowFormContainerFooter>
|
||||||
|
|
||||||
|
<MissingSignatureFieldDialog
|
||||||
|
isOpen={isMissingSignatureDialogVisible}
|
||||||
|
onOpenChange={(value) => setIsMissingSignatureDialogVisible(value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DocumentFlowFormContainerContent>
|
</DocumentFlowFormContainerContent>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ export const ZAddTemplateFieldsFormSchema = z.object({
|
|||||||
type: z.nativeEnum(FieldType),
|
type: z.nativeEnum(FieldType),
|
||||||
signerEmail: z.string().min(1),
|
signerEmail: z.string().min(1),
|
||||||
signerToken: z.string(),
|
signerToken: z.string(),
|
||||||
signerId: z.number().optional(),
|
signerId: z.number(),
|
||||||
|
recipientId: z.number(),
|
||||||
pageNumber: z.number().min(1),
|
pageNumber: z.number().min(1),
|
||||||
pageX: z.number().min(0),
|
pageX: z.number().min(0),
|
||||||
pageY: z.number().min(0),
|
pageY: z.number().min(0),
|
||||||
pageWidth: z.number().min(0),
|
pageWidth: z.number().min(0),
|
||||||
pageHeight: z.number().min(0),
|
pageHeight: z.number().min(0),
|
||||||
|
signerIndex: z.number().min(0),
|
||||||
fieldMeta: ZFieldMetaSchema,
|
fieldMeta: ZFieldMetaSchema,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
actionAuth: undefined,
|
actionAuth: undefined,
|
||||||
...generateRecipientPlaceholder(1),
|
...generateRecipientPlaceholder(1),
|
||||||
signingOrder: 1,
|
signingOrder: 1,
|
||||||
|
signerIndex: 0,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -104,6 +105,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
role: recipient.role,
|
role: recipient.role,
|
||||||
actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
|
actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
|
||||||
signingOrder: recipient.signingOrder ?? index + 1,
|
signingOrder: recipient.signingOrder ?? index + 1,
|
||||||
|
signerIndex: index,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||||
@@ -174,21 +176,35 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onAddPlaceholderSelfRecipient = () => {
|
const onAddPlaceholderSelfRecipient = () => {
|
||||||
|
const currentSigners = form.getValues('signers');
|
||||||
|
const nextSignerIndex = currentSigners.length;
|
||||||
|
|
||||||
appendSigner({
|
appendSigner({
|
||||||
formId: nanoid(12),
|
formId: nanoid(12),
|
||||||
name: user?.name ?? '',
|
name: user?.name ?? '',
|
||||||
email: user?.email ?? '',
|
email: user?.email ?? '',
|
||||||
role: RecipientRole.SIGNER,
|
role: RecipientRole.SIGNER,
|
||||||
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
|
signingOrder:
|
||||||
|
currentSigners.length > 0
|
||||||
|
? (currentSigners[currentSigners.length - 1]?.signingOrder ?? 0) + 1
|
||||||
|
: 1,
|
||||||
|
signerIndex: nextSignerIndex,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAddPlaceholderRecipient = () => {
|
const onAddPlaceholderRecipient = () => {
|
||||||
|
const currentSigners = form.getValues('signers');
|
||||||
|
const nextSignerIndex = currentSigners.length;
|
||||||
|
|
||||||
appendSigner({
|
appendSigner({
|
||||||
formId: nanoid(12),
|
formId: nanoid(12),
|
||||||
role: RecipientRole.SIGNER,
|
role: RecipientRole.SIGNER,
|
||||||
...generateRecipientPlaceholder(placeholderRecipientCount),
|
...generateRecipientPlaceholder(placeholderRecipientCount),
|
||||||
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
|
signingOrder:
|
||||||
|
currentSigners.length > 0
|
||||||
|
? (currentSigners[currentSigners.length - 1]?.signingOrder ?? 0) + 1
|
||||||
|
: 1,
|
||||||
|
signerIndex: nextSignerIndex,
|
||||||
});
|
});
|
||||||
|
|
||||||
setPlaceholderRecipientCount((count) => count + 1);
|
setPlaceholderRecipientCount((count) => count + 1);
|
||||||
|
|||||||
@@ -5,32 +5,21 @@ import { DocumentSigningOrder, RecipientRole } from '@documenso/prisma/client';
|
|||||||
|
|
||||||
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
|
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
|
||||||
|
|
||||||
export const ZAddTemplatePlacholderRecipientsFormSchema = z
|
export const ZAddTemplatePlacholderRecipientsFormSchema = z.object({
|
||||||
.object({
|
signers: z.array(
|
||||||
signers: z.array(
|
z.object({
|
||||||
z.object({
|
formId: z.string().min(1),
|
||||||
formId: z.string().min(1),
|
nativeId: z.number().optional(),
|
||||||
nativeId: z.number().optional(),
|
email: z.string().min(1).email(),
|
||||||
email: z.string().min(1).email(),
|
name: z.string(),
|
||||||
name: z.string(),
|
role: z.nativeEnum(RecipientRole),
|
||||||
role: z.nativeEnum(RecipientRole),
|
signingOrder: z.number().optional(),
|
||||||
signingOrder: z.number().optional(),
|
signerIndex: z.number().min(0),
|
||||||
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
|
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(ZRecipientActionAuthTypesSchema.optional()),
|
||||||
ZRecipientActionAuthTypesSchema.optional(),
|
}),
|
||||||
),
|
),
|
||||||
}),
|
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||||
),
|
});
|
||||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
|
||||||
})
|
|
||||||
.refine(
|
|
||||||
(schema) => {
|
|
||||||
const emails = schema.signers.map((signer) => signer.email.toLowerCase());
|
|
||||||
|
|
||||||
return new Set(emails).size === emails.length;
|
|
||||||
},
|
|
||||||
// Dirty hack to handle errors when .root is populated for an array type
|
|
||||||
{ message: 'Signers must have unique emails', path: ['signers__root'] },
|
|
||||||
);
|
|
||||||
|
|
||||||
export type TAddTemplatePlacholderRecipientsFormSchema = z.infer<
|
export type TAddTemplatePlacholderRecipientsFormSchema = z.infer<
|
||||||
typeof ZAddTemplatePlacholderRecipientsFormSchema
|
typeof ZAddTemplatePlacholderRecipientsFormSchema
|
||||||
|
|||||||
Reference in New Issue
Block a user