Compare commits
23 Commits
v1.9.1-rc.
...
v1.9.1-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e95e13021d | ||
|
|
5dfb17c216 | ||
|
|
27b81bc807 | ||
|
|
6ce0be67ab | ||
|
|
123a709836 | ||
|
|
ae6cc24317 | ||
|
|
a41ac632d0 | ||
|
|
1789eff564 | ||
|
|
235d846d2b | ||
|
|
ca3d65ad10 | ||
|
|
617e3a46e0 | ||
|
|
255c33cdab | ||
|
|
1560218d4a | ||
|
|
5c7768c253 | ||
|
|
7bb93e4233 | ||
|
|
42e39f7ef1 | ||
|
|
838e399c73 | ||
|
|
b6a891acc8 | ||
|
|
84b4d58856 | ||
|
|
c51c32fdc6 | ||
|
|
59c1e55233 | ||
|
|
2fbaf56c06 | ||
|
|
70320cd24b |
@@ -1,4 +1,4 @@
|
|||||||
> 🚨 We are live on Product Hunt 🎉 Check out our latest launch: <a href="documen.so/sign-everywhere">The Platform Plan</a>!
|
> 🚨 We are live on Product Hunt 🎉 Check out our latest launch: <a href="https://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>
|
||||||
|
|
||||||
|
|||||||
@@ -6,5 +6,6 @@
|
|||||||
"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={{
|
||||||
colorPrimary: '#0000FF',
|
primary: '#0000FF',
|
||||||
colorBackground: '#F5F5F5',
|
background: '#F5F5F5',
|
||||||
borderRadius: '8px',
|
radius: '8px',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
@@ -73,14 +73,15 @@ 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.
|
||||||
|
|
||||||
@@ -166,6 +167,7 @@ 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.
|
||||||
|
|
||||||
@@ -177,4 +179,5 @@ 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 = {
|
||||||
colorPrimary: '#0000FF',
|
primary: '#0000FF',
|
||||||
colorBackground: '#F5F5F5',
|
background: '#F5F5F5',
|
||||||
borderRadius: '8px',
|
radius: '8px',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -99,9 +99,9 @@ const MyEmbeddingComponent = () => {
|
|||||||
`}
|
`}
|
||||||
// CSS Variables
|
// CSS Variables
|
||||||
cssVars={{
|
cssVars={{
|
||||||
colorPrimary: '#0000FF',
|
primary: '#0000FF',
|
||||||
colorBackground: '#F5F5F5',
|
background: '#F5F5F5',
|
||||||
borderRadius: '8px',
|
radius: '8px',
|
||||||
}}
|
}}
|
||||||
// Dark Mode Control
|
// Dark Mode Control
|
||||||
darkModeDisabled={true}
|
darkModeDisabled={true}
|
||||||
|
|||||||
@@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
colorPrimary: '#0000FF',
|
primary: '#0000FF',
|
||||||
colorBackground: '#F5F5F5',
|
background: '#F5F5F5',
|
||||||
borderRadius: '8px',
|
radius: '8px',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
colorPrimary: '#0000FF',
|
primary: '#0000FF',
|
||||||
colorBackground: '#F5F5F5',
|
background: '#F5F5F5',
|
||||||
borderRadius: '8px',
|
radius: '8px',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
colorPrimary: '#0000FF',
|
primary: '#0000FF',
|
||||||
colorBackground: '#F5F5F5',
|
background: '#F5F5F5',
|
||||||
borderRadius: '8px',
|
radius: '8px',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
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,14 +21,25 @@ Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) f
|
|||||||
|
|
||||||
## API V2 - Beta
|
## API V2 - Beta
|
||||||
|
|
||||||
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.
|
<Callout type="warning">API V2 is currently beta, and will be subject to breaking changes</Callout>
|
||||||
|
|
||||||
<Callout type="warning">
|
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.
|
||||||
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,3 +532,93 @@ 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).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/web",
|
"name": "@documenso/web",
|
||||||
"version": "1.9.1-rc.2",
|
"version": "1.9.1-rc.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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.startsWith('data:') ? null : value.value,
|
typedSignature: value.value && !value.value.startsWith('data:') ? value.value : null,
|
||||||
} satisfies Signature;
|
} satisfies Signature;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,12 @@ 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(field.fieldMeta);
|
const parsedFieldMeta = ZCheckboxFieldMeta.parse(
|
||||||
|
field.fieldMeta ?? {
|
||||||
|
type: 'checkbox',
|
||||||
|
values: [{ id: 1, checked: false, value: '' }],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const values = parsedFieldMeta.values?.map((item) => ({
|
const values = parsedFieldMeta.values?.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
|
|||||||
@@ -311,7 +311,11 @@ 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">
|
||||||
<Trans>Please review the document before signing.</Trans>
|
{recipient.role === RecipientRole.APPROVER && !hasSignatureField ? (
|
||||||
|
<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" />
|
||||||
@@ -335,38 +339,40 @@ export const SigningForm = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{hasSignatureField && (
|
||||||
<Label htmlFor="Signature">
|
<div>
|
||||||
<Trans>Signature</Trans>
|
<Label htmlFor="Signature">
|
||||||
</Label>
|
<Trans>Signature</Trans>
|
||||||
|
</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>
|
||||||
|
|
||||||
{hasSignatureField && !signatureValid && (
|
{!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">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/macro';
|
import { Trans } from '@lingui/macro';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import type { Field } from '@documenso/prisma/client';
|
import type { Field } from '@documenso/prisma/client';
|
||||||
@@ -58,62 +59,88 @@ export const SignDialog = ({
|
|||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{isComplete ? <Trans>Complete</Trans> : <Trans>Next field</Trans>}
|
{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>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<div className="text-foreground text-xl font-semibold">
|
<div className="text-foreground text-xl font-semibold">
|
||||||
{role === RecipientRole.VIEWER && <Trans>Complete Viewing</Trans>}
|
{match(role)
|
||||||
{role === RecipientRole.SIGNER && <Trans>Complete Signing</Trans>}
|
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
||||||
{role === RecipientRole.APPROVER && <Trans>Complete Approval</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>
|
</div>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<div className="text-muted-foreground max-w-[50ch]">
|
<div className="text-muted-foreground max-w-[50ch]">
|
||||||
{role === RecipientRole.VIEWER && (
|
{match(role)
|
||||||
<span>
|
.with(RecipientRole.VIEWER, () => (
|
||||||
<Trans>
|
<span>
|
||||||
<span className="inline-flex flex-wrap">
|
<Trans>
|
||||||
You are about to complete viewing "
|
<span className="inline-flex flex-wrap">
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
You are about to complete viewing "
|
||||||
{documentTitle}
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
|
{documentTitle}
|
||||||
|
</span>
|
||||||
|
".
|
||||||
</span>
|
</span>
|
||||||
".
|
<br /> Are you sure?
|
||||||
</span>
|
</Trans>
|
||||||
<br /> Are you sure?
|
</span>
|
||||||
</Trans>
|
))
|
||||||
</span>
|
.with(RecipientRole.SIGNER, () => (
|
||||||
)}
|
<span>
|
||||||
{role === RecipientRole.SIGNER && (
|
<Trans>
|
||||||
<span>
|
<span className="inline-flex flex-wrap">
|
||||||
<Trans>
|
You are about to complete signing "
|
||||||
<span className="inline-flex flex-wrap">
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
You are about to complete signing "
|
{documentTitle}
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
</span>
|
||||||
{documentTitle}
|
".
|
||||||
</span>
|
</span>
|
||||||
".
|
<br /> Are you sure?
|
||||||
</span>
|
</Trans>
|
||||||
<br /> Are you sure?
|
</span>
|
||||||
</Trans>
|
))
|
||||||
</span>
|
.with(RecipientRole.APPROVER, () => (
|
||||||
)}
|
<span>
|
||||||
{role === RecipientRole.APPROVER && (
|
<Trans>
|
||||||
<span>
|
<span className="inline-flex flex-wrap">
|
||||||
<Trans>
|
You are about to complete approving{' '}
|
||||||
<span className="inline-flex flex-wrap">
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
You are about to complete approving{' '}
|
"{documentTitle}"
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
</span>
|
||||||
"{documentTitle}"
|
.
|
||||||
</span>
|
</span>
|
||||||
.
|
<br /> Are you sure?
|
||||||
</span>
|
</Trans>
|
||||||
<br /> Are you sure?
|
</span>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<SigningDisclosure className="mt-4" />
|
<SigningDisclosure className="mt-4" />
|
||||||
@@ -138,9 +165,13 @@ export const SignDialog = ({
|
|||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
onClick={onSignatureComplete}
|
onClick={onSignatureComplete}
|
||||||
>
|
>
|
||||||
{role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>}
|
{match(role)
|
||||||
{role === RecipientRole.SIGNER && <Trans>Sign</Trans>}
|
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
|
||||||
{role === RecipientRole.APPROVER && <Trans>Approve</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>
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -182,6 +182,23 @@ export const SigningFieldContainer = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
|
||||||
|
field.fieldMeta?.label && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute -top-16 left-0 right-0 rounded-md p-2 text-center text-xs text-gray-700',
|
||||||
|
{
|
||||||
|
'bg-foreground/5 border-border border': !field.inserted,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'bg-documenso-200 border-primary border': field.inserted,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{field.fieldMeta.label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</FieldRootContainer>
|
</FieldRootContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ 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';
|
||||||
@@ -49,7 +53,7 @@ export type EmbedDirectTemplateClientPageProps = {
|
|||||||
fields: Field[];
|
fields: Field[];
|
||||||
metadata?: DocumentMeta | TemplateMeta | null;
|
metadata?: DocumentMeta | TemplateMeta | null;
|
||||||
hidePoweredBy?: boolean;
|
hidePoweredBy?: boolean;
|
||||||
isPlatformOrEnterprise?: boolean;
|
allowWhiteLabelling?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EmbedDirectTemplateClientPage = ({
|
export const EmbedDirectTemplateClientPage = ({
|
||||||
@@ -60,7 +64,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
fields,
|
fields,
|
||||||
metadata,
|
metadata,
|
||||||
hidePoweredBy = false,
|
hidePoweredBy = false,
|
||||||
isPlatformOrEnterprise = false,
|
allowWhiteLabelling = false,
|
||||||
}: EmbedDirectTemplateClientPageProps) => {
|
}: EmbedDirectTemplateClientPageProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -94,7 +98,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) => !field.inserted),
|
localFields.filter((field) => isFieldUnsignedAndRequired(field)),
|
||||||
localFields.filter((field) => field.inserted),
|
localFields.filter((field) => field.inserted),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -112,7 +116,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,
|
||||||
});
|
});
|
||||||
@@ -123,8 +127,10 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
created: new Date(),
|
created: new Date(),
|
||||||
recipientId: 1,
|
recipientId: 1,
|
||||||
fieldId: 1,
|
fieldId: 1,
|
||||||
signatureImageAsBase64: payload.value.startsWith('data:') ? payload.value : null,
|
signatureImageAsBase64:
|
||||||
typedSignature: payload.value.startsWith('data:') ? null : payload.value,
|
payload.value && payload.value.startsWith('data:') ? payload.value : null,
|
||||||
|
typedSignature:
|
||||||
|
payload.value && !payload.value.startsWith('data:') ? payload.value : null,
|
||||||
} satisfies Signature;
|
} satisfies Signature;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +188,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onNextFieldClick = () => {
|
const onNextFieldClick = () => {
|
||||||
validateFieldsInserted(localFields);
|
validateFieldsInserted(pendingFields);
|
||||||
|
|
||||||
setShowPendingFieldTooltip(true);
|
setShowPendingFieldTooltip(true);
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
@@ -194,7 +200,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = validateFieldsInserted(localFields);
|
const valid = validateFieldsInserted(pendingFields);
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
setShowPendingFieldTooltip(true);
|
setShowPendingFieldTooltip(true);
|
||||||
@@ -207,12 +213,6 @@ 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,13 +223,11 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
directRecipientName: fullName,
|
directRecipientName: fullName,
|
||||||
directRecipientEmail: email,
|
directRecipientEmail: email,
|
||||||
templateUpdatedAt: updatedAt,
|
templateUpdatedAt: updatedAt,
|
||||||
signedFieldValues: localFields.map((field) => {
|
signedFieldValues: localFields
|
||||||
if (!field.signedValue) {
|
.filter((field) => {
|
||||||
throw new Error('Invalid configuration');
|
return field.signedValue && (isRequiredField(field) || field.inserted);
|
||||||
}
|
})
|
||||||
|
.map((field) => field.signedValue!),
|
||||||
return field.signedValue;
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (window.parent) {
|
if (window.parent) {
|
||||||
@@ -288,7 +286,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
document.documentElement.classList.add('dark-mode-disabled');
|
document.documentElement.classList.add('dark-mode-disabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPlatformOrEnterprise) {
|
if (allowWhiteLabelling) {
|
||||||
injectCss({
|
injectCss({
|
||||||
css: data.css,
|
css: data.css,
|
||||||
cssVars: data.cssVars,
|
cssVars: data.cssVars,
|
||||||
@@ -349,7 +347,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 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 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"
|
||||||
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">
|
||||||
@@ -360,19 +358,34 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
<Trans>Sign document</Trans>
|
<Trans>Sign document</Trans>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
{isExpanded ? (
|
||||||
{isExpanded ? (
|
<Button
|
||||||
<LucideChevronDown
|
variant="outline"
|
||||||
className="text-muted-foreground h-5 w-5"
|
className="h-8 w-8 p-0 md:hidden"
|
||||||
onClick={() => setIsExpanded(false)}
|
onClick={() => setIsExpanded(false)}
|
||||||
/>
|
>
|
||||||
) : (
|
<LucideChevronDown className="text-muted-foreground h-5 w-5" />
|
||||||
<LucideChevronUp
|
</Button>
|
||||||
className="text-muted-foreground h-5 w-5"
|
) : pendingFields.length > 0 ? (
|
||||||
onClick={() => setIsExpanded(true)}
|
<Button
|
||||||
/>
|
variant="outline"
|
||||||
)}
|
className="h-8 w-8 p-0 md:hidden"
|
||||||
</Button>
|
onClick={() => setIsExpanded(true)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
@@ -417,40 +430,42 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{hasSignatureField && (
|
||||||
<Label htmlFor="Signature">
|
<div>
|
||||||
<Trans>Signature</Trans>
|
<Label htmlFor="Signature">
|
||||||
</Label>
|
<Trans>Signature</Trans>
|
||||||
|
</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,6 +2,7 @@ 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';
|
||||||
@@ -55,12 +56,16 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
|
|||||||
documentAuth: template.authOptions,
|
documentAuth: template.authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isPlatformDocument, isEnterpriseDocument] = await Promise.all([
|
const [isPlatformDocument, isEnterpriseDocument, isCommunityPlan] = 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)
|
||||||
@@ -105,8 +110,10 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
|
|||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
metadata={template.templateMeta}
|
metadata={template.templateMeta}
|
||||||
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
|
hidePoweredBy={
|
||||||
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
|
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
|
||||||
|
}
|
||||||
|
allowWhiteLabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
|
||||||
/>
|
/>
|
||||||
</RecipientProvider>
|
</RecipientProvider>
|
||||||
</DocumentAuthProvider>
|
</DocumentAuthProvider>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useId, useLayoutEffect, useState } from 'react';
|
import { useEffect, useId, useLayoutEffect, useMemo, 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,6 +8,7 @@ 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 {
|
||||||
@@ -51,7 +52,7 @@ export type EmbedSignDocumentClientPageProps = {
|
|||||||
metadata?: DocumentMeta | TemplateMeta | null;
|
metadata?: DocumentMeta | TemplateMeta | null;
|
||||||
isCompleted?: boolean;
|
isCompleted?: boolean;
|
||||||
hidePoweredBy?: boolean;
|
hidePoweredBy?: boolean;
|
||||||
isPlatformOrEnterprise?: boolean;
|
allowWhitelabelling?: boolean;
|
||||||
allRecipients?: RecipientWithFields[];
|
allRecipients?: RecipientWithFields[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
metadata,
|
metadata,
|
||||||
isCompleted,
|
isCompleted,
|
||||||
hidePoweredBy = false,
|
hidePoweredBy = false,
|
||||||
isPlatformOrEnterprise = false,
|
allowWhitelabelling = false,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
}: EmbedSignDocumentClientPageProps) => {
|
}: EmbedSignDocumentClientPageProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
@@ -102,19 +103,26 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
|
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
|
||||||
|
|
||||||
const [pendingFields, _completedFields] = [
|
const [pendingFields, _completedFields] = [
|
||||||
fields.filter((field) => field.recipientId === recipient.id && !field.inserted),
|
fields.filter(
|
||||||
|
(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(fields);
|
validateFieldsInserted(fieldsRequiringValidation);
|
||||||
|
|
||||||
setShowPendingFieldTooltip(true);
|
setShowPendingFieldTooltip(true);
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
@@ -126,7 +134,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = validateFieldsInserted(fields);
|
const valid = validateFieldsInserted(fieldsRequiringValidation);
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
setShowPendingFieldTooltip(true);
|
setShowPendingFieldTooltip(true);
|
||||||
@@ -212,7 +220,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
document.documentElement.classList.add('dark-mode-disabled');
|
document.documentElement.classList.add('dark-mode-disabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPlatformOrEnterprise) {
|
if (allowWhitelabelling) {
|
||||||
injectCss({
|
injectCss({
|
||||||
css: data.css,
|
css: data.css,
|
||||||
cssVars: data.cssVars,
|
cssVars: data.cssVars,
|
||||||
@@ -288,7 +296,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 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 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"
|
||||||
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">
|
||||||
@@ -303,19 +311,36 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
{isExpanded ? (
|
||||||
{isExpanded ? (
|
<Button
|
||||||
<LucideChevronDown
|
variant="outline"
|
||||||
className="text-muted-foreground h-5 w-5"
|
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||||
onClick={() => setIsExpanded(false)}
|
onClick={() => setIsExpanded(false)}
|
||||||
/>
|
>
|
||||||
) : (
|
<LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||||
<LucideChevronUp
|
</Button>
|
||||||
className="text-muted-foreground h-5 w-5"
|
) : pendingFields.length > 0 ? (
|
||||||
onClick={() => setIsExpanded(true)}
|
<Button
|
||||||
/>
|
variant="outline"
|
||||||
)}
|
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||||
</Button>
|
onClick={() => setIsExpanded(true)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
@@ -419,40 +444,42 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{hasSignatureField && (
|
||||||
<Label htmlFor="Signature">
|
<div>
|
||||||
<Trans>Signature</Trans>
|
<Label htmlFor="Signature">
|
||||||
</Label>
|
<Trans>Signature</Trans>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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';
|
||||||
@@ -62,12 +63,16 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
|
|||||||
return <EmbedPaywall />;
|
return <EmbedPaywall />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [isPlatformDocument, isEnterpriseDocument] = await Promise.all([
|
const [isPlatformDocument, isEnterpriseDocument, isCommunityPlan] = 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({
|
||||||
@@ -126,8 +131,10 @@ 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={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
|
hidePoweredBy={
|
||||||
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
|
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
|
||||||
|
}
|
||||||
|
allowWhitelabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
|
||||||
allRecipients={allRecipients}
|
allRecipients={allRecipients}
|
||||||
/>
|
/>
|
||||||
</DocumentAuthProvider>
|
</DocumentAuthProvider>
|
||||||
|
|||||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "1.9.1-rc.2",
|
"version": "1.9.1-rc.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "1.9.1-rc.2",
|
"version": "1.9.1-rc.9",
|
||||||
"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.2",
|
"version": "1.9.1-rc.9",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/api": "*",
|
"@documenso/api": "*",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.9.1-rc.2",
|
"version": "1.9.1-rc.9",
|
||||||
"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,6 +586,7 @@ 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 { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
import {
|
import {
|
||||||
DocumentDataType,
|
DocumentDataType,
|
||||||
DocumentDistributionMethod,
|
DocumentDistributionMethod,
|
||||||
@@ -299,6 +299,7 @@ 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<
|
||||||
|
|||||||
612
packages/app-tests/e2e/api/v1/template-field-prefill.spec.ts
Normal file
612
packages/app-tests/e2e/api/v1/template-field-prefill.spec.ts
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
600
packages/app-tests/e2e/api/v2/template-field-prefill.spec.ts
Normal file
600
packages/app-tests/e2e/api/v2/template-field-prefill.spec.ts
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
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,7 +384,9 @@ 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.getByRole('button', { name: 'Complete' }).click();
|
await page
|
||||||
|
.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();
|
||||||
@@ -454,7 +456,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: 'Complete' }).click();
|
await page.getByRole('button', { name: 'Approve' }).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,15 +4,14 @@ 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 = typeof plan === 'string' ? [plan] : plan;
|
const planTypes: string[] = typeof plan === 'string' ? [plan] : plan;
|
||||||
|
|
||||||
const query = planTypes.map((planType) => `metadata['plan']:'${planType}'`).join(' OR ');
|
const prices = await stripe.prices.list({
|
||||||
|
|
||||||
const { data: prices } = await stripe.prices.search({
|
|
||||||
query,
|
|
||||||
expand: ['data.product'],
|
expand: ['data.product'],
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
return prices.filter((price) => price.type === 'recurring');
|
return prices.data.filter(
|
||||||
|
(price) => price.type === 'recurring' && planTypes.includes(price.metadata.plan),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
56
packages/ee/server-only/util/is-community-plan.ts
Normal file
56
packages/ee/server-only/util/is-community-plan.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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);
|
||||||
|
};
|
||||||
@@ -88,6 +88,7 @@ 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,6 +34,14 @@ 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: {
|
||||||
@@ -88,6 +96,23 @@ 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: {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ 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 {
|
||||||
@@ -175,20 +176,28 @@ 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(
|
||||||
directTemplateRecipient.fields.map(async (templateField) => {
|
fieldsToProcess.map(async (templateField) => {
|
||||||
const signedFieldValue = signedFieldValues.find(
|
const signedFieldValue = signedFieldValues.find(
|
||||||
(value) => value.fieldId === templateField.id,
|
(value) => value.fieldId === templateField.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!signedFieldValue) {
|
if (isRequiredField(templateField) && !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({
|
||||||
@@ -199,9 +208,18 @@ 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 =
|
||||||
@@ -379,7 +397,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,3 +1,5 @@
|
|||||||
|
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';
|
||||||
@@ -17,7 +19,20 @@ 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 { ZFieldMetaSchema } from '../../types/field-meta';
|
import type {
|
||||||
|
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,
|
||||||
@@ -50,6 +65,7 @@ export type CreateDocumentFromTemplateOptions = {
|
|||||||
email: string;
|
email: string;
|
||||||
signingOrder?: number | null;
|
signingOrder?: number | null;
|
||||||
}[];
|
}[];
|
||||||
|
prefillFields?: TFieldMetaPrefillFieldsSchema[];
|
||||||
customDocumentDataId?: string;
|
customDocumentDataId?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,6 +88,165 @@ 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,
|
||||||
@@ -81,6 +256,7 @@ 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: {
|
||||||
@@ -258,6 +434,47 @@ 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);
|
||||||
|
|
||||||
@@ -266,19 +483,25 @@ export const createDocumentFromTemplate = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
fieldsToCreate = fieldsToCreate.concat(
|
fieldsToCreate = fieldsToCreate.concat(
|
||||||
fields.map((field) => ({
|
fields.map((field) => {
|
||||||
documentId: document.id,
|
const prefillField = prefillFields?.find((value) => value.id === field.id);
|
||||||
recipientId: recipient.id,
|
// Use type assertion to help TypeScript understand the structure
|
||||||
type: field.type,
|
const updatedFieldMeta = getUpdatedFieldMeta(field, prefillField);
|
||||||
page: field.page,
|
|
||||||
positionX: field.positionX,
|
return {
|
||||||
positionY: field.positionY,
|
documentId: document.id,
|
||||||
width: field.width,
|
recipientId: recipient.id,
|
||||||
height: field.height,
|
type: field.type,
|
||||||
customText: '',
|
page: field.page,
|
||||||
inserted: false,
|
positionX: field.positionX,
|
||||||
fieldMeta: field.fieldMeta,
|
positionY: field.positionY,
|
||||||
})),
|
width: field.width,
|
||||||
|
height: field.height,
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
fieldMeta: updatedFieldMeta,
|
||||||
|
};
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,42 @@ 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.
|
||||||
|
|||||||
@@ -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(),
|
value: z.string().trim().optional(),
|
||||||
isBase64: z.boolean().optional(),
|
isBase64: z.boolean().optional(),
|
||||||
authOptions: ZRecipientActionAuthSchema.optional(),
|
authOptions: ZRecipientActionAuthSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -227,7 +227,8 @@ 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 } = input;
|
const { templateId, recipients, distributeDocument, customDocumentDataId, prefillFields } =
|
||||||
|
input;
|
||||||
|
|
||||||
const limits = await getServerLimits({ email: ctx.user.email, teamId });
|
const limits = await getServerLimits({ email: ctx.user.email, teamId });
|
||||||
|
|
||||||
@@ -242,6 +243,7 @@ export const templateRouter = router({
|
|||||||
recipients,
|
recipients,
|
||||||
customDocumentDataId,
|
customDocumentDataId,
|
||||||
requestMetadata: ctx.metadata,
|
requestMetadata: ctx.metadata,
|
||||||
|
prefillFields,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (distributeDocument) {
|
if (distributeDocument) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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,
|
||||||
@@ -67,6 +68,7 @@ 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;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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';
|
||||||
@@ -185,11 +186,35 @@ 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(
|
||||||
@@ -229,6 +254,21 @@ 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,6 +126,18 @@ 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,8 +105,12 @@ export const DropdownFieldAdvancedSettings = ({
|
|||||||
<Trans>Select default option</Trans>
|
<Trans>Select default option</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
defaultValue={defaultValue}
|
value={defaultValue}
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
|
if (!val) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setDefaultValue(val);
|
setDefaultValue(val);
|
||||||
handleFieldChange('defaultValue', val);
|
handleFieldChange('defaultValue', val);
|
||||||
}}
|
}}
|
||||||
@@ -172,7 +176,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,7 +2,8 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/macro';
|
import { Trans, msg } 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';
|
||||||
@@ -27,6 +28,8 @@ 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' }],
|
||||||
@@ -102,6 +105,18 @@ 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,7 +126,13 @@ export const TextFieldAdvancedSettings = ({
|
|||||||
|
|
||||||
<Select
|
<Select
|
||||||
value={fieldState.textAlign}
|
value={fieldState.textAlign}
|
||||||
onValueChange={(value) => handleInput('textAlign', value)}
|
onValueChange={(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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user