mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
Compare commits
5 Commits
v1.9.1-rc.
...
feat/dicta
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a6942f9da | |||
| 8b82d22f9f | |||
| 00e402f4cb | |||
| 1e90ca45a6 | |||
| 4189a34de0 |
@ -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": {
|
||||||
|
|||||||
@ -73,7 +73,7 @@ export const EditDocumentForm = ({
|
|||||||
|
|
||||||
const { recipients, fields } = document;
|
const { recipients, fields } = document;
|
||||||
|
|
||||||
const { mutateAsync: updateDocument } = trpc.document.setSettingsForDocument.useMutation({
|
const { mutateAsync: updateDocument } = trpc.document.updateDocument.useMutation({
|
||||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
onSuccess: (newData) => {
|
onSuccess: (newData) => {
|
||||||
utils.document.getDocumentWithDetailsById.setData(
|
utils.document.getDocumentWithDetailsById.setData(
|
||||||
@ -85,19 +85,6 @@ export const EditDocumentForm = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: setSigningOrderForDocument } =
|
|
||||||
trpc.document.setSigningOrderForDocument.useMutation({
|
|
||||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
|
||||||
onSuccess: (newData) => {
|
|
||||||
utils.document.getDocumentWithDetailsById.setData(
|
|
||||||
{
|
|
||||||
documentId: initialDocument.id,
|
|
||||||
},
|
|
||||||
(oldData) => ({ ...(oldData || initialDocument), ...newData, id: Number(newData.id) }),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: addFields } = trpc.field.addFields.useMutation({
|
const { mutateAsync: addFields } = trpc.field.addFields.useMutation({
|
||||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
onSuccess: ({ fields: newFields }) => {
|
onSuccess: ({ fields: newFields }) => {
|
||||||
@ -216,9 +203,12 @@ export const EditDocumentForm = ({
|
|||||||
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
setSigningOrderForDocument({
|
updateDocument({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
signingOrder: data.signingOrder,
|
meta: {
|
||||||
|
signingOrder: data.signingOrder,
|
||||||
|
modifyNextSigner: data.modifyNextSigner,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setRecipients({
|
setRecipients({
|
||||||
@ -391,6 +381,7 @@ export const EditDocumentForm = ({
|
|||||||
documentFlow={documentFlow.signers}
|
documentFlow={documentFlow.signers}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
signingOrder={document.documentMeta?.signingOrder}
|
signingOrder={document.documentMeta?.signingOrder}
|
||||||
|
modifyNextSigner={document.documentMeta?.modifyNextSigner}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
isDocumentEnterprise={isDocumentEnterprise}
|
isDocumentEnterprise={isDocumentEnterprise}
|
||||||
onSubmit={onAddSignersFormSubmit}
|
onSubmit={onAddSignersFormSubmit}
|
||||||
|
|||||||
@ -76,6 +76,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
|||||||
? {
|
? {
|
||||||
...templateMeta,
|
...templateMeta,
|
||||||
signingOrder: templateMeta.signingOrder || DocumentSigningOrder.SEQUENTIAL,
|
signingOrder: templateMeta.signingOrder || DocumentSigningOrder.SEQUENTIAL,
|
||||||
|
modifyNextSigner: templateMeta.modifyNextSigner ?? false,
|
||||||
documentId: 0,
|
documentId: 0,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -15,7 +15,12 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
|||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import { type Field, FieldType, RecipientRole } from '@documenso/prisma/client';
|
import {
|
||||||
|
DocumentSigningOrder,
|
||||||
|
type Field,
|
||||||
|
FieldType,
|
||||||
|
RecipientRole,
|
||||||
|
} 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';
|
||||||
@ -40,6 +45,12 @@ export type SigningFormProps = {
|
|||||||
isRecipientsTurn: boolean;
|
isRecipientsTurn: boolean;
|
||||||
allRecipients?: RecipientWithFields[];
|
allRecipients?: RecipientWithFields[];
|
||||||
setSelectedSignerId?: (id: number | null) => void;
|
setSelectedSignerId?: (id: number | null) => void;
|
||||||
|
isLastRecipient: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SigningFormData = {
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SigningForm = ({
|
export const SigningForm = ({
|
||||||
@ -50,6 +61,7 @@ export const SigningForm = ({
|
|||||||
isRecipientsTurn,
|
isRecipientsTurn,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
setSelectedSignerId,
|
setSelectedSignerId,
|
||||||
|
isLastRecipient,
|
||||||
}: SigningFormProps) => {
|
}: SigningFormProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@ -77,7 +89,7 @@ export const SigningForm = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { handleSubmit, formState } = useForm();
|
const { handleSubmit, formState } = useForm<SigningFormData>();
|
||||||
|
|
||||||
// Keep the loading state going if successful since the redirect may take some time.
|
// Keep the loading state going if successful since the redirect may take some time.
|
||||||
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
|
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
|
||||||
@ -102,20 +114,58 @@ export const SigningForm = ({
|
|||||||
validateFieldsInserted(fieldsRequiringValidation);
|
validateFieldsInserted(fieldsRequiringValidation);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFormSubmit = async () => {
|
const completeDocument = async (
|
||||||
setValidateUninsertedFields(true);
|
authOptions?: TRecipientActionAuth,
|
||||||
|
nextSigner?: { email: string; name: string },
|
||||||
|
) => {
|
||||||
|
const payload = {
|
||||||
|
token: recipient.token,
|
||||||
|
documentId: document.id,
|
||||||
|
authOptions,
|
||||||
|
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
|
await completeDocumentWithToken(payload);
|
||||||
|
|
||||||
if (hasSignatureField && !signatureValid) {
|
analytics.capture('App: Recipient has completed signing', {
|
||||||
return;
|
signerId: recipient.id,
|
||||||
|
documentId: document.id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${recipient.token}/complete`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFormSubmit = async (data: SigningFormData) => {
|
||||||
|
try {
|
||||||
|
setValidateUninsertedFields(true);
|
||||||
|
|
||||||
|
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
|
||||||
|
|
||||||
|
if (hasSignatureField && !signatureValid) {
|
||||||
|
throw new Error('Please provide a valid signature');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFieldsValid) {
|
||||||
|
throw new Error('Please complete all required fields');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSigner =
|
||||||
|
data.email && data.name
|
||||||
|
? {
|
||||||
|
email: data.email,
|
||||||
|
name: data.name,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await completeDocument(undefined, nextSigner);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while completing the document. Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isFieldsValid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await completeDocument();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAssistantFormSubmit = () => {
|
const onAssistantFormSubmit = () => {
|
||||||
@ -143,22 +193,6 @@ export const SigningForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
|
|
||||||
await completeDocumentWithToken({
|
|
||||||
token: recipient.token,
|
|
||||||
documentId: document.id,
|
|
||||||
authOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
analytics.capture('App: Recipient has completed signing', {
|
|
||||||
signerId: recipient.id,
|
|
||||||
documentId: document.id,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${recipient.token}/complete`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -208,12 +242,21 @@ export const SigningForm = ({
|
|||||||
|
|
||||||
<SignDialog
|
<SignDialog
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
onSignatureComplete={async (nextSigner) => {
|
||||||
|
await handleSubmit(async (formData) =>
|
||||||
|
onFormSubmit({ ...formData, ...nextSigner }),
|
||||||
|
)();
|
||||||
|
}}
|
||||||
documentTitle={document.title}
|
documentTitle={document.title}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
role={recipient.role}
|
role={recipient.role}
|
||||||
disabled={!isRecipientsTurn}
|
disabled={!isRecipientsTurn}
|
||||||
|
canModifyNextSigner={
|
||||||
|
document.documentMeta?.modifyNextSigner &&
|
||||||
|
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL &&
|
||||||
|
!isLastRecipient
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -311,11 +354,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 +378,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">
|
||||||
@ -389,12 +426,21 @@ export const SigningForm = ({
|
|||||||
|
|
||||||
<SignDialog
|
<SignDialog
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
onSignatureComplete={async (nextSigner) => {
|
||||||
|
await handleSubmit(async (formData) =>
|
||||||
|
onFormSubmit({ ...formData, ...nextSigner }),
|
||||||
|
)();
|
||||||
|
}}
|
||||||
documentTitle={document.title}
|
documentTitle={document.title}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
role={recipient.role}
|
role={recipient.role}
|
||||||
disabled={!isRecipientsTurn}
|
disabled={!isRecipientsTurn}
|
||||||
|
canModifyNextSigner={
|
||||||
|
document.documentMeta?.modifyNextSigner &&
|
||||||
|
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL &&
|
||||||
|
!isLastRecipient
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-re
|
|||||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||||
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
|
import { getIsLastRecipient } from '@documenso/lib/server-only/recipient/get-is-last-recipient';
|
||||||
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||||
@ -44,7 +45,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
|
|
||||||
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
||||||
|
|
||||||
const [document, recipient, fields, completedFields] = await Promise.all([
|
const [document, recipient, fields, completedFields, isLastRecipient] = await Promise.all([
|
||||||
getDocumentAndSenderByToken({
|
getDocumentAndSenderByToken({
|
||||||
token,
|
token,
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
@ -53,6 +54,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
getRecipientByToken({ token }).catch(() => null),
|
getRecipientByToken({ token }).catch(() => null),
|
||||||
getFieldsForToken({ token }),
|
getFieldsForToken({ token }),
|
||||||
getCompletedFieldsForToken({ token }),
|
getCompletedFieldsForToken({ token }),
|
||||||
|
getIsLastRecipient({ token }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -169,6 +171,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
completedFields={completedFields}
|
completedFields={completedFields}
|
||||||
isRecipientsTurn={isRecipientsTurn}
|
isRecipientsTurn={isRecipientsTurn}
|
||||||
allRecipients={allRecipients}
|
allRecipients={allRecipients}
|
||||||
|
isLastRecipient={isLastRecipient}
|
||||||
/>
|
/>
|
||||||
</DocumentAuthProvider>
|
</DocumentAuthProvider>
|
||||||
</SigningProvider>
|
</SigningProvider>
|
||||||
|
|||||||
@ -37,11 +37,6 @@ export const RecipientProvider = ({
|
|||||||
recipient,
|
recipient,
|
||||||
targetSigner = null,
|
targetSigner = null,
|
||||||
}: RecipientProviderProps) => {
|
}: RecipientProviderProps) => {
|
||||||
// console.log({
|
|
||||||
// recipient,
|
|
||||||
// targetSigner,
|
|
||||||
// isAssistantMode: !!targetSigner,
|
|
||||||
// });
|
|
||||||
return (
|
return (
|
||||||
<RecipientContext.Provider
|
<RecipientContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
|||||||
@ -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,19 +1,36 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans } from '@lingui/macro';
|
import { Trans } from '@lingui/macro';
|
||||||
import { match } from 'ts-pattern';
|
import { ArrowRight } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
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';
|
||||||
import { RecipientRole } from '@documenso/prisma/client';
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
import { SigningDisclosure } from '~/components/general/signing-disclosure';
|
import { SigningDisclosure } from '~/components/general/signing-disclosure';
|
||||||
|
|
||||||
@ -22,12 +39,26 @@ export type SignDialogProps = {
|
|||||||
documentTitle: string;
|
documentTitle: string;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
fieldsValidated: () => void | Promise<void>;
|
fieldsValidated: () => void | Promise<void>;
|
||||||
onSignatureComplete: () => void | Promise<void>;
|
onSignatureComplete: (nextSigner?: { email?: string; name?: string }) => void | Promise<void>;
|
||||||
role: RecipientRole;
|
role: RecipientRole;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
canModifyNextSigner?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignDialog = ({
|
const formSchema = z.object({
|
||||||
|
modifyNextSigner: z.boolean().default(false),
|
||||||
|
nextSigner: z
|
||||||
|
.object({
|
||||||
|
email: z.string().email({ message: 'Please enter a valid email address' }).optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TFormSchema = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
export function SignDialog({
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
documentTitle,
|
documentTitle,
|
||||||
fields,
|
fields,
|
||||||
@ -35,7 +66,9 @@ export const SignDialog = ({
|
|||||||
onSignatureComplete,
|
onSignatureComplete,
|
||||||
role,
|
role,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: SignDialogProps) => {
|
canModifyNextSigner = false,
|
||||||
|
}: SignDialogProps) {
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
|
||||||
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
||||||
@ -48,134 +81,336 @@ export const SignDialog = ({
|
|||||||
setShowDialog(open);
|
setShowDialog(open);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const totalSteps = 2;
|
||||||
|
|
||||||
|
const handleContinue = () => {
|
||||||
|
if (step < totalSteps) {
|
||||||
|
setStep(step + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = useForm<TFormSchema>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onFormSubmit = async (data: TFormSchema) => {
|
||||||
|
try {
|
||||||
|
await fieldsValidated();
|
||||||
|
|
||||||
|
await onSignatureComplete({
|
||||||
|
email: data.nextSigner.email?.trim().toLowerCase(),
|
||||||
|
name: data.nextSigner.name?.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
setShowDialog(false);
|
||||||
|
form.reset();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
<>
|
||||||
<DialogTrigger asChild>
|
{!canModifyNextSigner ? (
|
||||||
<Button
|
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
||||||
className="w-full"
|
<DialogTrigger asChild>
|
||||||
type="button"
|
|
||||||
size="lg"
|
|
||||||
onClick={fieldsValidated}
|
|
||||||
loading={isSubmitting}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
{match({ isComplete, role })
|
|
||||||
.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>
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogTitle>
|
|
||||||
<div className="text-foreground text-xl font-semibold">
|
|
||||||
{match(role)
|
|
||||||
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
|
||||||
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
|
||||||
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
|
||||||
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
|
|
||||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
|
|
||||||
.exhaustive()}
|
|
||||||
</div>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<div className="text-muted-foreground max-w-[50ch]">
|
|
||||||
{match(role)
|
|
||||||
.with(RecipientRole.VIEWER, () => (
|
|
||||||
<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>
|
|
||||||
))
|
|
||||||
.with(RecipientRole.SIGNER, () => (
|
|
||||||
<span>
|
|
||||||
<Trans>
|
|
||||||
<span className="inline-flex flex-wrap">
|
|
||||||
You are about to complete signing "
|
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
|
||||||
{documentTitle}
|
|
||||||
</span>
|
|
||||||
".
|
|
||||||
</span>
|
|
||||||
<br /> Are you sure?
|
|
||||||
</Trans>
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
.with(RecipientRole.APPROVER, () => (
|
|
||||||
<span>
|
|
||||||
<Trans>
|
|
||||||
<span className="inline-flex flex-wrap">
|
|
||||||
You are about to complete approving{' '}
|
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
|
||||||
"{documentTitle}"
|
|
||||||
</span>
|
|
||||||
.
|
|
||||||
</span>
|
|
||||||
<br /> Are you sure?
|
|
||||||
</Trans>
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
.otherwise(() => (
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<SigningDisclosure className="mt-4" />
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
|
||||||
<Button
|
<Button
|
||||||
|
className="w-full"
|
||||||
type="button"
|
type="button"
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
size="lg"
|
||||||
variant="secondary"
|
onClick={fieldsValidated}
|
||||||
onClick={() => {
|
|
||||||
setShowDialog(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="flex-1"
|
|
||||||
disabled={!isComplete}
|
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
onClick={onSignatureComplete}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{match(role)
|
{isComplete ? <Trans>Complete</Trans> : <Trans>Next field</Trans>}
|
||||||
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
|
|
||||||
.with(RecipientRole.SIGNER, () => <Trans>Sign</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>
|
</DialogTrigger>
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
<DialogContent>
|
||||||
</Dialog>
|
<DialogTitle>
|
||||||
|
<div className="text-foreground text-xl font-semibold">
|
||||||
|
{role === RecipientRole.VIEWER && <Trans>Complete Viewing</Trans>}
|
||||||
|
{role === RecipientRole.SIGNER && <Trans>Complete Signing</Trans>}
|
||||||
|
{role === RecipientRole.APPROVER && <Trans>Complete Approval</Trans>}
|
||||||
|
</div>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground max-w-[50ch]">
|
||||||
|
{role === RecipientRole.VIEWER && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{role === RecipientRole.SIGNER && (
|
||||||
|
<span>
|
||||||
|
<Trans>
|
||||||
|
<span className="inline-flex flex-wrap">
|
||||||
|
You are about to complete signing "
|
||||||
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
|
{documentTitle}
|
||||||
|
</span>
|
||||||
|
".
|
||||||
|
</span>
|
||||||
|
<br /> Are you sure?
|
||||||
|
</Trans>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{role === RecipientRole.APPROVER && (
|
||||||
|
<span>
|
||||||
|
<Trans>
|
||||||
|
<span className="inline-flex flex-wrap">
|
||||||
|
You are about to complete approving{' '}
|
||||||
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
|
"{documentTitle}"
|
||||||
|
</span>
|
||||||
|
.
|
||||||
|
</span>
|
||||||
|
<br /> Are you sure?
|
||||||
|
</Trans>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SigningDisclosure className="mt-4" />
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowDialog(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!isComplete}
|
||||||
|
loading={isSubmitting}
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await onSignatureComplete();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>}
|
||||||
|
{role === RecipientRole.SIGNER && <Trans>Sign</Trans>}
|
||||||
|
{role === RecipientRole.APPROVER && <Trans>Approve</Trans>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
) : (
|
||||||
|
<Dialog
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) setStep(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
type="button"
|
||||||
|
size="lg"
|
||||||
|
onClick={fieldsValidated}
|
||||||
|
loading={isSubmitting}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{isComplete ? <Trans>Complete</Trans> : <Trans>Next field</Trans>}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogTitle>
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="text-foreground text-base font-semibold">
|
||||||
|
<Trans>
|
||||||
|
Modify Next Signer <span className="text-muted-foreground">(Optional)</span>
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="text-foreground text-xl font-semibold">
|
||||||
|
{role === RecipientRole.VIEWER && <Trans>Complete Viewing</Trans>}
|
||||||
|
{role === RecipientRole.SIGNER && <Trans>Complete Signing</Trans>}
|
||||||
|
{role === RecipientRole.APPROVER && <Trans>Complete Approval</Trans>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)} className="flex flex-col gap-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="modifyNextSigner"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="font-normal">
|
||||||
|
<Trans>Modify next signer details</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{form.watch('modifyNextSigner') && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="nextSigner.email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Next Signer Email</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="email" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="nextSigner.name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Next Signer Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<>
|
||||||
|
<div className="text-muted-foreground max-w-[50ch]">
|
||||||
|
{role === RecipientRole.VIEWER && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{role === RecipientRole.SIGNER && (
|
||||||
|
<span>
|
||||||
|
<Trans>
|
||||||
|
<span className="inline-flex flex-wrap">
|
||||||
|
You are about to complete signing "
|
||||||
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
|
{documentTitle}
|
||||||
|
</span>
|
||||||
|
".
|
||||||
|
</span>
|
||||||
|
<br /> Are you sure?
|
||||||
|
</Trans>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{role === RecipientRole.APPROVER && (
|
||||||
|
<span>
|
||||||
|
<Trans>
|
||||||
|
<span className="inline-flex flex-wrap">
|
||||||
|
You are about to complete approving{' '}
|
||||||
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
|
"{documentTitle}"
|
||||||
|
</span>
|
||||||
|
.
|
||||||
|
</span>
|
||||||
|
<br /> Are you sure?
|
||||||
|
</Trans>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SigningDisclosure className="mt-4" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
|
||||||
|
<div className="flex justify-center space-x-1.5 max-sm:order-1">
|
||||||
|
{[...Array(totalSteps)].map((_, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => setStep(index + 1)}
|
||||||
|
className={cn(
|
||||||
|
'bg-primary h-1.5 w-1.5 rounded-full',
|
||||||
|
index + 1 === step ? 'bg-primary' : 'opacity-20',
|
||||||
|
)}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Go to step ${index + 1}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="ghost">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<Button className="group" type="button" onClick={handleContinue}>
|
||||||
|
Next
|
||||||
|
<ArrowRight
|
||||||
|
className="-me-1 ms-2 opacity-60 transition-transform group-hover:translate-x-0.5"
|
||||||
|
size={16}
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!isComplete}
|
||||||
|
loading={isSubmitting}
|
||||||
|
onClick={form.handleSubmit(onFormSubmit)}
|
||||||
|
>
|
||||||
|
{role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>}
|
||||||
|
{role === RecipientRole.SIGNER && <Trans>Sign</Trans>}
|
||||||
|
{role === RecipientRole.APPROVER && <Trans>Approve</Trans>}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -49,6 +49,7 @@ export type SigningPageViewProps = {
|
|||||||
completedFields: CompletedField[];
|
completedFields: CompletedField[];
|
||||||
isRecipientsTurn: boolean;
|
isRecipientsTurn: boolean;
|
||||||
allRecipients?: RecipientWithFields[];
|
allRecipients?: RecipientWithFields[];
|
||||||
|
isLastRecipient: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SigningPageView = ({
|
export const SigningPageView = ({
|
||||||
@ -58,6 +59,7 @@ export const SigningPageView = ({
|
|||||||
completedFields,
|
completedFields,
|
||||||
isRecipientsTurn,
|
isRecipientsTurn,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
|
isLastRecipient,
|
||||||
}: SigningPageViewProps) => {
|
}: SigningPageViewProps) => {
|
||||||
const { documentData, documentMeta } = document;
|
const { documentData, documentMeta } = document;
|
||||||
|
|
||||||
@ -159,6 +161,7 @@ export const SigningPageView = ({
|
|||||||
redirectUrl={documentMeta?.redirectUrl}
|
redirectUrl={documentMeta?.redirectUrl}
|
||||||
isRecipientsTurn={isRecipientsTurn}
|
isRecipientsTurn={isRecipientsTurn}
|
||||||
allRecipients={allRecipients}
|
allRecipients={allRecipients}
|
||||||
|
isLastRecipient={isLastRecipient}
|
||||||
setSelectedSignerId={setSelectedSignerId}
|
setSelectedSignerId={setSelectedSignerId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -10,7 +10,6 @@ export type EmbedDocumentCompletedPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
|
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
|
||||||
console.log({ signature });
|
|
||||||
return (
|
return (
|
||||||
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||||
<h3 className="text-foreground text-2xl font-semibold">
|
<h3 className="text-foreground text-2xl font-semibold">
|
||||||
|
|||||||
@ -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,
|
||||||
@ -299,7 +299,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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
|
||||||
};
|
|
||||||
@ -28,6 +28,7 @@ export type CreateDocumentMetaOptions = {
|
|||||||
distributionMethod?: DocumentDistributionMethod;
|
distributionMethod?: DocumentDistributionMethod;
|
||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
language?: SupportedLanguageCodes;
|
language?: SupportedLanguageCodes;
|
||||||
|
modifyNextSigner?: boolean;
|
||||||
requestMetadata: ApiRequestMetadata;
|
requestMetadata: ApiRequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -46,6 +47,7 @@ export const upsertDocumentMeta = async ({
|
|||||||
distributionMethod,
|
distributionMethod,
|
||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
language,
|
language,
|
||||||
|
modifyNextSigner,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
}: CreateDocumentMetaOptions) => {
|
}: CreateDocumentMetaOptions) => {
|
||||||
const document = await prisma.document.findFirst({
|
const document = await prisma.document.findFirst({
|
||||||
@ -98,6 +100,7 @@ export const upsertDocumentMeta = async ({
|
|||||||
distributionMethod,
|
distributionMethod,
|
||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
language,
|
language,
|
||||||
|
modifyNextSigner,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
subject,
|
subject,
|
||||||
@ -111,6 +114,7 @@ export const upsertDocumentMeta = async ({
|
|||||||
distributionMethod,
|
distributionMethod,
|
||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
language,
|
language,
|
||||||
|
modifyNextSigner,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
@ -29,6 +28,10 @@ export type CompleteDocumentWithTokenOptions = {
|
|||||||
userId?: number;
|
userId?: number;
|
||||||
authOptions?: TRecipientActionAuth;
|
authOptions?: TRecipientActionAuth;
|
||||||
requestMetadata?: RequestMetadata;
|
requestMetadata?: RequestMetadata;
|
||||||
|
nextSigner?: {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => {
|
const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => {
|
||||||
@ -52,10 +55,53 @@ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptio
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const delegateNextSigner = async ({
|
||||||
|
documentId,
|
||||||
|
currentRecipientId,
|
||||||
|
nextSigner,
|
||||||
|
}: {
|
||||||
|
documentId: number;
|
||||||
|
currentRecipientId: number;
|
||||||
|
nextSigner: { email: string; name: string };
|
||||||
|
}) => {
|
||||||
|
const document = await prisma.document.findUnique({
|
||||||
|
where: { id: documentId },
|
||||||
|
include: {
|
||||||
|
recipients: {
|
||||||
|
orderBy: [{ signingOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new Error('Document not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRecipient = document.recipients.find((r) => r.id === currentRecipientId);
|
||||||
|
const nextRecipient = document.recipients.find(
|
||||||
|
(r) => r.signingOrder === (currentRecipient?.signingOrder ?? 0) + 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!nextRecipient) {
|
||||||
|
throw new Error('Next recipient not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.recipient.update({
|
||||||
|
where: { id: nextRecipient.id },
|
||||||
|
data: {
|
||||||
|
email: nextSigner.email,
|
||||||
|
name: nextSigner.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return nextRecipient;
|
||||||
|
};
|
||||||
|
|
||||||
export const completeDocumentWithToken = async ({
|
export const completeDocumentWithToken = async ({
|
||||||
token,
|
token,
|
||||||
documentId,
|
documentId,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
|
nextSigner,
|
||||||
}: CompleteDocumentWithTokenOptions) => {
|
}: CompleteDocumentWithTokenOptions) => {
|
||||||
const document = await getDocument({ token, documentId });
|
const document = await getDocument({ token, documentId });
|
||||||
|
|
||||||
@ -73,13 +119,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 });
|
||||||
|
|
||||||
@ -120,6 +159,18 @@ export const completeDocumentWithToken = async ({
|
|||||||
// throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
|
// throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
if (
|
||||||
|
nextSigner &&
|
||||||
|
document.documentMeta?.modifyNextSigner &&
|
||||||
|
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL
|
||||||
|
) {
|
||||||
|
await delegateNextSigner({
|
||||||
|
documentId: document.id,
|
||||||
|
currentRecipientId: recipient.id,
|
||||||
|
nextSigner,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
await tx.recipient.update({
|
await tx.recipient.update({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
46
packages/lib/server-only/recipient/get-is-last-recipient.ts
Normal file
46
packages/lib/server-only/recipient/get-is-last-recipient.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { DocumentSigningOrder, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type GetIsLastRecipientOptions = {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getIsLastRecipient({ token }: GetIsLastRecipientOptions) {
|
||||||
|
const document = await prisma.document.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
recipients: {
|
||||||
|
some: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
documentMeta: true,
|
||||||
|
recipients: {
|
||||||
|
where: {
|
||||||
|
role: {
|
||||||
|
not: RecipientRole.CC,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (document.documentMeta?.signingOrder !== DocumentSigningOrder.SEQUENTIAL) {
|
||||||
|
const unsignedRecipients = document.recipients.filter(
|
||||||
|
(recipient) => recipient.signingStatus !== SigningStatus.SIGNED,
|
||||||
|
);
|
||||||
|
|
||||||
|
return unsignedRecipients.length <= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { recipients } = document;
|
||||||
|
const currentRecipientIndex = recipients.findIndex((r) => r.token === token);
|
||||||
|
|
||||||
|
if (currentRecipientIndex === -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentRecipientIndex === recipients.length - 1;
|
||||||
|
}
|
||||||
@ -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,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@ -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: {
|
||||||
@ -434,47 +258,6 @@ export const createDocumentFromTemplate = async ({
|
|||||||
|
|
||||||
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
|
|
||||||
const allTemplateFieldIds = finalRecipients.flatMap((recipient) =>
|
|
||||||
recipient.fields.map((field) => field.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
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 }) => {
|
Object.values(finalRecipients).forEach(({ email, fields }) => {
|
||||||
const recipient = document.recipients.find((recipient) => recipient.email === email);
|
const recipient = document.recipients.find((recipient) => recipient.email === email);
|
||||||
|
|
||||||
@ -483,25 +266,19 @@ export const createDocumentFromTemplate = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -55,6 +55,7 @@ export const ZDocumentSchema = DocumentSchema.pick({
|
|||||||
typedSignatureEnabled: true,
|
typedSignatureEnabled: true,
|
||||||
language: true,
|
language: true,
|
||||||
emailSettings: true,
|
emailSettings: true,
|
||||||
|
modifyNextSigner: true,
|
||||||
}).nullable(),
|
}).nullable(),
|
||||||
recipients: ZRecipientLiteSchema.array(),
|
recipients: ZRecipientLiteSchema.array(),
|
||||||
fields: ZFieldSchema.array(),
|
fields: ZFieldSchema.array(),
|
||||||
|
|||||||
@ -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 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DocumentMeta" ADD COLUMN "modifyNextSigner" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "TemplateMeta" ADD COLUMN "modifyNextSigner" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@ -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 {
|
||||||
@ -397,6 +390,7 @@ model DocumentMeta {
|
|||||||
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||||
redirectUrl String?
|
redirectUrl String?
|
||||||
signingOrder DocumentSigningOrder @default(PARALLEL)
|
signingOrder DocumentSigningOrder @default(PARALLEL)
|
||||||
|
modifyNextSigner Boolean @default(false)
|
||||||
typedSignatureEnabled Boolean @default(true)
|
typedSignatureEnabled Boolean @default(true)
|
||||||
language String @default("en")
|
language String @default("en")
|
||||||
distributionMethod DocumentDistributionMethod @default(EMAIL)
|
distributionMethod DocumentDistributionMethod @default(EMAIL)
|
||||||
@ -666,6 +660,7 @@ model TemplateMeta {
|
|||||||
signingOrder DocumentSigningOrder? @default(PARALLEL)
|
signingOrder DocumentSigningOrder? @default(PARALLEL)
|
||||||
typedSignatureEnabled Boolean @default(true)
|
typedSignatureEnabled Boolean @default(true)
|
||||||
distributionMethod DocumentDistributionMethod @default(EMAIL)
|
distributionMethod DocumentDistributionMethod @default(EMAIL)
|
||||||
|
modifyNextSigner Boolean @default(false)
|
||||||
|
|
||||||
templateId Int @unique
|
templateId Int @unique
|
||||||
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@ -266,15 +266,14 @@ export const documentRouter = router({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @public
|
* @public
|
||||||
*
|
|
||||||
* Todo: Refactor to updateDocument.
|
|
||||||
*/
|
*/
|
||||||
setSettingsForDocument: authenticatedProcedure
|
updateDocument: authenticatedProcedure
|
||||||
.meta({
|
.meta({
|
||||||
openapi: {
|
openapi: {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/document/update',
|
path: '/document/update',
|
||||||
summary: 'Update document',
|
summary: 'Update document',
|
||||||
|
description: 'Update an existing document',
|
||||||
tags: ['Document'],
|
tags: ['Document'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -286,9 +285,9 @@ export const documentRouter = router({
|
|||||||
|
|
||||||
const userId = ctx.user.id;
|
const userId = ctx.user.id;
|
||||||
|
|
||||||
if (Object.values(meta).length > 0) {
|
if (Object.keys(meta).length > 0) {
|
||||||
await upsertDocumentMeta({
|
await upsertDocumentMeta({
|
||||||
userId: ctx.user.id,
|
userId,
|
||||||
teamId,
|
teamId,
|
||||||
documentId,
|
documentId,
|
||||||
subject: meta.subject,
|
subject: meta.subject,
|
||||||
@ -301,6 +300,7 @@ export const documentRouter = router({
|
|||||||
distributionMethod: meta.distributionMethod,
|
distributionMethod: meta.distributionMethod,
|
||||||
signingOrder: meta.signingOrder,
|
signingOrder: meta.signingOrder,
|
||||||
emailSettings: meta.emailSettings,
|
emailSettings: meta.emailSettings,
|
||||||
|
modifyNextSigner: meta.modifyNextSigner,
|
||||||
requestMetadata: ctx.metadata,
|
requestMetadata: ctx.metadata,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -251,6 +251,7 @@ export const ZUpdateDocumentRequestSchema = z.object({
|
|||||||
language: ZDocumentMetaLanguageSchema.optional(),
|
language: ZDocumentMetaLanguageSchema.optional(),
|
||||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||||
|
modifyNextSigner: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -452,7 +452,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,
|
||||||
|
|||||||
@ -153,7 +153,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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -437,13 +437,14 @@ export const recipientRouter = router({
|
|||||||
completeDocumentWithToken: procedure
|
completeDocumentWithToken: procedure
|
||||||
.input(ZCompleteDocumentWithTokenMutationSchema)
|
.input(ZCompleteDocumentWithTokenMutationSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { token, documentId, authOptions } = input;
|
const { token, documentId, authOptions, nextSigner } = input;
|
||||||
|
|
||||||
return await completeDocumentWithToken({
|
return await completeDocumentWithToken({
|
||||||
token,
|
token,
|
||||||
documentId,
|
documentId,
|
||||||
authOptions,
|
authOptions,
|
||||||
userId: ctx.user?.id,
|
userId: ctx.user?.id,
|
||||||
|
nextSigner,
|
||||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -212,6 +212,12 @@ export const ZCompleteDocumentWithTokenMutationSchema = z.object({
|
|||||||
token: z.string(),
|
token: z.string(),
|
||||||
documentId: z.number(),
|
documentId: z.number(),
|
||||||
authOptions: ZRecipientActionAuthSchema.optional(),
|
authOptions: ZRecipientActionAuthSchema.optional(),
|
||||||
|
nextSigner: z
|
||||||
|
.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
name: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TCompleteDocumentWithTokenMutationSchema = z.infer<
|
export type TCompleteDocumentWithTokenMutationSchema = z.infer<
|
||||||
|
|||||||
@ -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,
|
||||||
@ -68,7 +67,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;
|
||||||
|
|||||||
@ -49,6 +49,7 @@ export type AddSignersFormProps = {
|
|||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
signingOrder?: DocumentSigningOrder | null;
|
signingOrder?: DocumentSigningOrder | null;
|
||||||
|
modifyNextSigner?: boolean | null;
|
||||||
isDocumentEnterprise: boolean;
|
isDocumentEnterprise: boolean;
|
||||||
onSubmit: (_data: TAddSignersFormSchema) => void;
|
onSubmit: (_data: TAddSignersFormSchema) => void;
|
||||||
isDocumentPdfLoaded: boolean;
|
isDocumentPdfLoaded: boolean;
|
||||||
@ -59,6 +60,7 @@ export const AddSignersFormPartial = ({
|
|||||||
recipients,
|
recipients,
|
||||||
fields,
|
fields,
|
||||||
signingOrder,
|
signingOrder,
|
||||||
|
modifyNextSigner,
|
||||||
isDocumentEnterprise,
|
isDocumentEnterprise,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isDocumentPdfLoaded,
|
isDocumentPdfLoaded,
|
||||||
@ -107,6 +109,7 @@ export const AddSignersFormPartial = ({
|
|||||||
)
|
)
|
||||||
: defaultRecipients,
|
: defaultRecipients,
|
||||||
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
|
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
|
||||||
|
modifyNextSigner: modifyNextSigner ?? false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -404,6 +407,35 @@ export const AddSignersFormPartial = ({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isSigningOrderSequential && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="modifyNextSigner"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
id="modifyNextSigner"
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
field.onChange(checked);
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting || hasDocumentBeenSent}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormLabel
|
||||||
|
htmlFor="modifyNextSigner"
|
||||||
|
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
<Trans>Modify next signer</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<DragDropContext
|
<DragDropContext
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
sensors={[
|
sensors={[
|
||||||
|
|||||||
@ -25,6 +25,7 @@ export const ZAddSignersFormSchema = z
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||||
|
modifyNextSigner: z.boolean(),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(schema) => {
|
(schema) => {
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -183,12 +183,12 @@ const FormMessage = React.forwardRef<
|
|||||||
FormMessage.displayName = 'FormMessage';
|
FormMessage.displayName = 'FormMessage';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
useFormField,
|
|
||||||
Form,
|
Form,
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
FormDescription,
|
||||||
FormMessage,
|
|
||||||
FormField,
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
useFormField,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user