mirror of
https://github.com/documenso/documenso.git
synced 2026-07-13 22:37:24 +10:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5efd34e95 | |||
| 21cff7a727 | |||
| 400b6a24f1 |
@@ -1,222 +0,0 @@
|
|||||||
---
|
|
||||||
date: 2026-05-07
|
|
||||||
title: Pdf Placeholder Selection Fields
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Extend PDF placeholders so radio and dropdown fields can be configured from the existing Documenso placeholder syntax:
|
|
||||||
|
|
||||||
```text
|
|
||||||
{{FIELD_TYPE, RECIPIENT, key=value, key=value}}
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not introduce a new delimiter style. Existing applications may already generate placeholders in this format, so the new selection-field behavior should fit into it.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
- Keep the current placeholder grammar unchanged.
|
|
||||||
- Support checkbox placeholders with option lists, checked values, validation, direction, required, read-only, and font size.
|
|
||||||
- Support radio placeholders with option lists, default/preselected values, direction, required, read-only, and font size.
|
|
||||||
- Support dropdown placeholders with option lists, default value, required, read-only, and font size.
|
|
||||||
- Use `options` as the only public list key in PDF placeholders.
|
|
||||||
- Convert `options` into internal `fieldMeta.values` during parsing.
|
|
||||||
- Make generated fields usable immediately in the editor, signing UI, preview renderer, and final PDF export.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- No semicolon placeholder syntax.
|
|
||||||
- No `values` alias in PDF placeholder syntax.
|
|
||||||
- No database migration.
|
|
||||||
- No behavior change for existing placeholders such as `{{text, r1, required=true}}`.
|
|
||||||
|
|
||||||
## Placeholder Syntax
|
|
||||||
|
|
||||||
Use the existing comma-separated placeholder format:
|
|
||||||
|
|
||||||
```text
|
|
||||||
{{checkbox, r1, options=Email|SMS|Phone, checked=Email|Phone, validationRule=atLeast, validationLength=1}}
|
|
||||||
{{radio, r1, options=Card|Bank transfer|Check, defaultValue=Check}}
|
|
||||||
{{radio, r1, options=Basic|Pro|Enterprise, selected=Pro, direction=horizontal}}
|
|
||||||
{{dropdown, r1, options=United States|Canada|United Kingdom}}
|
|
||||||
{{dropdown, r2, options=Sales|Legal|Finance, defaultValue=Legal}}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `|` inside `options` because `,` is already the top-level placeholder delimiter.
|
|
||||||
|
|
||||||
Parsing rules:
|
|
||||||
|
|
||||||
- Split top-level placeholder tokens on unescaped commas.
|
|
||||||
- Split metadata tokens on the first unescaped equals sign.
|
|
||||||
- Split `options` on unescaped pipes.
|
|
||||||
- Trim option values and drop empty values.
|
|
||||||
- Preserve option order.
|
|
||||||
- Support escaped delimiters: `\,`, `\=`, and `\|`.
|
|
||||||
- Treat field type values case-insensitively.
|
|
||||||
|
|
||||||
## Field Type Mapping
|
|
||||||
|
|
||||||
- `checkbox` maps to `FieldType.CHECKBOX`.
|
|
||||||
- `radio` maps to `FieldType.RADIO`.
|
|
||||||
- `dropdown` maps to `FieldType.DROPDOWN`.
|
|
||||||
|
|
||||||
## Metadata Mapping
|
|
||||||
|
|
||||||
### Checkbox
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
{{checkbox, r1, options=Email|SMS|Phone, checked=Email|Phone, validationRule=atLeast, validationLength=1}}
|
|
||||||
```
|
|
||||||
|
|
||||||
Normalize to:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
{
|
|
||||||
type: FieldType.CHECKBOX,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'checkbox',
|
|
||||||
validationRule: 'Select at least',
|
|
||||||
validationLength: 1,
|
|
||||||
values: [
|
|
||||||
{ id: 1, value: 'Email', checked: true },
|
|
||||||
{ id: 2, value: 'SMS', checked: false },
|
|
||||||
{ id: 3, value: 'Phone', checked: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Accepted keys:
|
|
||||||
|
|
||||||
- `options`
|
|
||||||
- `checked`
|
|
||||||
- `direction=vertical|horizontal`
|
|
||||||
- `validationRule=atLeast|exactly|atMost`
|
|
||||||
- `validationLength=1`
|
|
||||||
- `required=true|false`
|
|
||||||
- `readOnly=true|false`
|
|
||||||
- `fontSize=12`
|
|
||||||
|
|
||||||
Map checkbox validation aliases internally: `atLeast` -> `Select at least`, `exactly` -> `Select exactly`, `atMost` -> `Select at most`.
|
|
||||||
|
|
||||||
Checkbox placeholders do not support `label` or `placeholder` metadata.
|
|
||||||
|
|
||||||
### Radio
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
{{radio, r1, options=Card|Bank transfer|Check, selected=Bank transfer}}
|
|
||||||
```
|
|
||||||
|
|
||||||
Normalize to:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
{
|
|
||||||
type: FieldType.RADIO,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'radio',
|
|
||||||
values: [
|
|
||||||
{ id: 1, value: 'Card', checked: false },
|
|
||||||
{ id: 2, value: 'Bank transfer', checked: true },
|
|
||||||
{ id: 3, value: 'Check', checked: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Accepted keys:
|
|
||||||
|
|
||||||
- `options`
|
|
||||||
- `selected`, `default`, or `defaultValue`
|
|
||||||
- `direction=vertical|horizontal`
|
|
||||||
- `required=true|false`
|
|
||||||
- `readOnly=true|false`
|
|
||||||
- `fontSize=12`
|
|
||||||
|
|
||||||
Radio placeholders do not support `label` or `placeholder` metadata.
|
|
||||||
|
|
||||||
### Dropdown
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
{{dropdown, r1, options=Sales|Legal|Finance, defaultValue=Legal}}
|
|
||||||
```
|
|
||||||
|
|
||||||
Normalize to:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
{
|
|
||||||
type: FieldType.DROPDOWN,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'dropdown',
|
|
||||||
values: [{ value: 'Sales' }, { value: 'Legal' }, { value: 'Finance' }],
|
|
||||||
defaultValue: 'Legal',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Accepted keys:
|
|
||||||
|
|
||||||
- `options`
|
|
||||||
- `selected`, `default`, or `defaultValue`
|
|
||||||
- `required=true|false`
|
|
||||||
- `readOnly=true|false`
|
|
||||||
- `fontSize=12`
|
|
||||||
|
|
||||||
`defaultValue` should only be set if it matches one parsed option.
|
|
||||||
|
|
||||||
Dropdown placeholders do not support `label` or `placeholder` metadata.
|
|
||||||
|
|
||||||
## Code Touchpoints
|
|
||||||
|
|
||||||
- `packages/lib/server-only/pdf/helpers.ts`
|
|
||||||
- Extend `parseFieldMetaFromPlaceholder` so `options` normalizes into checkbox/radio/dropdown `fieldMeta.values`.
|
|
||||||
- Add delimiter-aware helpers for commas, equals signs, and pipes.
|
|
||||||
- `packages/lib/server-only/pdf/auto-place-fields.ts`
|
|
||||||
- Replace plain comma splitting with delimiter-aware splitting.
|
|
||||||
- Preserve the existing positional structure: field type, recipient, metadata.
|
|
||||||
- `packages/lib/types/field-meta.ts`
|
|
||||||
- Keep current internal schemas: checkbox/radio/dropdown still store options as `fieldMeta.values`.
|
|
||||||
- `packages/ui/primitives/document-flow/field-content.tsx`
|
|
||||||
- Display a radio fallback when a placeholder-created radio has no options.
|
|
||||||
- Docs:
|
|
||||||
- `apps/docs/content/docs/users/documents/advanced/pdf-placeholders.mdx`
|
|
||||||
- `apps/docs/content/docs/developers/api/fields.mdx`
|
|
||||||
|
|
||||||
## Test Plan
|
|
||||||
|
|
||||||
Unit tests:
|
|
||||||
|
|
||||||
- `options=Yes|No|Maybe` becomes stable radio values.
|
|
||||||
- `selected=No` marks only the matching radio option checked.
|
|
||||||
- Checkbox `options`, `checked`, `validationRule`, and `validationLength` normalize correctly.
|
|
||||||
- Dropdown `options` and `defaultValue` normalize correctly.
|
|
||||||
- Escaped delimiters parse correctly, for example `options=Sales\|Ops|Legal\, Compliance|A\=B`.
|
|
||||||
|
|
||||||
E2E/API tests:
|
|
||||||
|
|
||||||
- Add a PDF fixture with checkbox, radio, and dropdown placeholders using the current syntax.
|
|
||||||
- Verify created fields have schema-compatible metadata and expected options/defaults.
|
|
||||||
|
|
||||||
Suggested verification:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run test -w @documenso/lib -- server-only/pdf/helpers.test.ts
|
|
||||||
npm run test:dev -w @documenso/app-tests -- e2e/auto-placing-fields/auto-place-fields-document.spec.ts
|
|
||||||
npm run test:dev -w @documenso/app-tests -- e2e/envelope-editor-v2/envelope-fields.spec.ts
|
|
||||||
npx tsc --noEmit -p packages/lib/tsconfig.json
|
|
||||||
npx tsc --noEmit -p apps/remix/tsconfig.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not use `npm run build` for routine verification unless explicitly requested.
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
- Keep the existing placeholder format.
|
|
||||||
- Use only `options` publicly.
|
|
||||||
- Keep `values` as an internal metadata field only.
|
|
||||||
- Use `|` as the option delimiter inside `options`.
|
|
||||||
@@ -65,3 +65,4 @@ When you exceed a resource limit:
|
|||||||
- [Authentication](/docs/developers/getting-started/authentication) - API authentication guide
|
- [Authentication](/docs/developers/getting-started/authentication) - API authentication guide
|
||||||
- [API Versioning](/docs/developers/api/versioning) - API version management
|
- [API Versioning](/docs/developers/api/versioning) - API version management
|
||||||
- [First API Call](/docs/developers/getting-started/first-api-call) - Getting started with the API
|
- [First API Call](/docs/developers/getting-started/first-api-call) - Getting started with the API
|
||||||
|
- [Organisation Limits](/docs/self-hosting/configuration/organisation-limits) - Admins: set per-organisation resource quotas and rate limits (the HTTP rate limit above is separate and not admin-settable)
|
||||||
|
|||||||
@@ -510,4 +510,5 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password"
|
|||||||
- [Email Configuration](/docs/self-hosting/configuration/email) - Configure email delivery
|
- [Email Configuration](/docs/self-hosting/configuration/email) - Configure email delivery
|
||||||
- [Storage Configuration](/docs/self-hosting/configuration/storage) - Set up S3 storage
|
- [Storage Configuration](/docs/self-hosting/configuration/storage) - Set up S3 storage
|
||||||
- [Signing Certificate](/docs/self-hosting/configuration/signing-certificate) - Configure document signing
|
- [Signing Certificate](/docs/self-hosting/configuration/signing-certificate) - Configure document signing
|
||||||
|
- [Organisation Limits](/docs/self-hosting/configuration/organisation-limits) - Set per-organisation document, email, and API limits from the admin panel
|
||||||
- [Troubleshooting](/docs/self-hosting/maintenance/troubleshooting) - Common configuration issues
|
- [Troubleshooting](/docs/self-hosting/maintenance/troubleshooting) - Common configuration issues
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ description: Configure your self-hosted Documenso instance with environment vari
|
|||||||
description="Digital signature certificate setup."
|
description="Digital signature certificate setup."
|
||||||
href="/docs/self-hosting/configuration/signing-certificate"
|
href="/docs/self-hosting/configuration/signing-certificate"
|
||||||
/>
|
/>
|
||||||
|
<Card
|
||||||
|
title="Organisation Limits"
|
||||||
|
description="Set per-organisation document, email, and API limits via the admin panel."
|
||||||
|
href="/docs/self-hosting/configuration/organisation-limits"
|
||||||
|
/>
|
||||||
</Cards>
|
</Cards>
|
||||||
|
|
||||||
## Required Configuration
|
## Required Configuration
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"background-jobs",
|
"background-jobs",
|
||||||
"signing-certificate",
|
"signing-certificate",
|
||||||
"telemetry",
|
"telemetry",
|
||||||
|
"organisation-limits",
|
||||||
"advanced"
|
"advanced"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
---
|
||||||
|
title: Organisation Limits
|
||||||
|
description: View and set per-organisation document, email, and API limits on a self-hosted Documenso instance using the admin panel's subscription claims.
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Callout } from 'fumadocs-ui/components/callout';
|
||||||
|
|
||||||
|
Per-organisation limits — document, email, and API usage, plus feature toggles and team/member caps — are controlled by **subscription claims**. You configure them in the admin panel, not through environment variables.
|
||||||
|
|
||||||
|
There are three distinct kinds of limit:
|
||||||
|
|
||||||
|
| Limit | Caps | Admin-settable |
|
||||||
|
| ---------------------- | ------------------------------------------------- | ----------------------- |
|
||||||
|
| Resource quota | Documents, emails, and API requests **per month** | Yes — per claim and org |
|
||||||
|
| Resource rate limit | The same resources over a short window (e.g. `1h`) | Yes — per claim and org |
|
||||||
|
| Global HTTP rate limit | API requests per IP (100/min, hardcoded) | No — see [Limitations](#limitations) |
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A running self-hosted Documenso instance.
|
||||||
|
- An account with the **`ADMIN`** role — an account-level role, separate from organisation and team roles. New accounts are created with the `USER` role only. Grant the first admin by adding `ADMIN` to that user's `roles` directly in the database; after that, an existing admin can grant the role to others under **Admin Panel > Users > _(user)_ > Roles > Update user**.
|
||||||
|
|
||||||
|
Open the admin panel at `/admin`. The sidebar sections used below are **Claims**, **Organisations**, and **Organisation Stats**.
|
||||||
|
|
||||||
|
## Viewing usage
|
||||||
|
|
||||||
|
**One organisation:** open **Admin Panel > Organisations** and select it. The **Organisation usage** section shows the current period's document, email, and API usage against its quotas.
|
||||||
|
|
||||||
|
**All organisations:** open **Admin Panel > Organisation Stats** to sort and filter monthly usage. Filter by **claim** and by **period** (a UTC calendar month, shown as `YYYY-MM`), and switch between **Show usage**, **Show usage with quotas**, and **Show daily averages**.
|
||||||
|
|
||||||
|
<Callout type="warn">
|
||||||
|
Usage counts **attempts**, not only successful actions. A request that exceeds a quota is still counted before it is rejected, so displayed usage can read higher than the number of actions that succeeded.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
## Subscription claims
|
||||||
|
|
||||||
|
A subscription claim is a named bundle of limits and feature flags (for example `Free`, `Individual`, `Teams`, `Platform`, or `Enterprise`). Claims are **templates**: when an organisation is created it receives a private copy of its claim and reads from that copy afterwards. Editing a claim template therefore affects organisations created later, not existing ones — to change an existing organisation, [edit it directly](#change-limits-for-one-organisation).
|
||||||
|
|
||||||
|
### Claim fields
|
||||||
|
|
||||||
|
Under **Admin Panel > Claims** (`/admin/claims`), each claim has:
|
||||||
|
|
||||||
|
| Field | Controls |
|
||||||
|
| ----------------------- | --------------------------------------------------------------------------------- |
|
||||||
|
| **Name** | The claim's display name. |
|
||||||
|
| **Team Count** | Teams allowed. `0` = unlimited. |
|
||||||
|
| **Member Count** | Members allowed. `0` = unlimited. |
|
||||||
|
| **Envelope Item Count** | Uploaded files allowed per envelope. Minimum `1`. |
|
||||||
|
| **Recipient Count** | Recipients allowed per document. `0` = unlimited. |
|
||||||
|
| **Feature Flags** | Feature toggles (see [Feature flags](#feature-flags)). |
|
||||||
|
| **Limits** | Monthly quota and rate-limit windows for Documents, Emails, and API. |
|
||||||
|
| **Email transport** | Transport the claim uses. *Default (system mailer)* uses the instance default. |
|
||||||
|
|
||||||
|
### Quotas and rate limits
|
||||||
|
|
||||||
|
The **Limits** section has a column for **Documents**, **Emails**, and **API**, each with two controls:
|
||||||
|
|
||||||
|
- **Monthly quota** — how many of that resource are allowed per calendar month. An **empty** field is unlimited; **`0`** blocks the resource entirely.
|
||||||
|
- **Rate limit windows** — optional short-window caps, each a duration and a maximum. A window is a number and a unit (`s`, `m`, `h`, `d`), such as `5m`, `1h`, or `24h`, and must be unique within the resource.
|
||||||
|
|
||||||
|
<Callout type="warn">
|
||||||
|
Quotas and counts use opposite conventions for "unlimited": an **empty** quota is unlimited (and `0` blocks the resource), whereas `0` in the **Team**, **Member**, and **Recipient Count** fields means unlimited.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
### Feature flags
|
||||||
|
|
||||||
|
The **Feature Flags** section toggles capabilities such as Unlimited documents, Branding, Hide Documenso branding, Email domains, Embed authoring, Embed signing, White label for embed authoring/signing, 21 CFR, HIPAA, Authentication portal, Allow Legacy Envelopes, Signing reminders, QES signing, and Disable emails.
|
||||||
|
|
||||||
|
Some flags are Enterprise features. If your license does not include one, it is marked and cannot be enabled (you can still turn it off). See [Enterprise Edition](/docs/policies/enterprise-edition).
|
||||||
|
|
||||||
|
### Create or edit a claim template
|
||||||
|
|
||||||
|
1. Go to **Admin Panel > Claims**.
|
||||||
|
2. Select **New claim**, or select an existing claim to edit it.
|
||||||
|
3. Set the counts, feature flags, and the **Limits** section.
|
||||||
|
4. Save. Changes apply to organisations created afterwards, not existing ones.
|
||||||
|
|
||||||
|
### Change limits for one organisation
|
||||||
|
|
||||||
|
To change limits for an existing organisation, edit it directly rather than its claim template.
|
||||||
|
|
||||||
|
1. Go to **Admin Panel > Organisations** and open the organisation.
|
||||||
|
2. Adjust its quota, rate-limit, feature-flag, or email-transport fields.
|
||||||
|
3. Save. Changes take effect immediately.
|
||||||
|
|
||||||
|
The organisation also shows the **Inherited subscription claim** it was created from.
|
||||||
|
|
||||||
|
## Usage reset
|
||||||
|
|
||||||
|
Monthly quota usage is keyed to the **UTC calendar month**. There is no scheduled reset job — when the month rolls over, the new period's counter starts at `0`.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
The **global HTTP rate limit is not configurable.** Documenso enforces a hardcoded **100 requests per minute per IP address** on its API endpoint groups (`/api/v1`, `/api/v2`, and the tRPC API are limited separately), returning `429 Too Many Requests`. It is a per-IP safeguard applied at the HTTP layer — not per-organisation, not stored on any claim, and not adjustable from the admin panel. See [Rate Limits](/docs/developers/api/rate-limits).
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause and fix |
|
||||||
|
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| An organisation hit its limit unexpectedly | Usage counts rejected over-quota attempts. Compare usage against the quota under **Organisation Stats > Show usage with quotas**. |
|
||||||
|
| A resource is blocked entirely, not just capped | The **Monthly quota** is `0`, which blocks the resource. Leave it empty for unlimited. |
|
||||||
|
| Emails are not sending for an organisation | Check whether the **Disable emails** flag is enabled on the organisation's claim — it blocks all emails regardless of quota. |
|
||||||
|
| A claim template edit had no effect | Template edits are not retroactive. Edit the organisation directly under **Admin Panel > Organisations**. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Environment Variables](/docs/self-hosting/configuration/environment) - All configuration options
|
||||||
|
- [Rate Limits](/docs/developers/api/rate-limits) - The global HTTP API rate limit (separate from claims)
|
||||||
|
- [Enterprise Edition](/docs/policies/enterprise-edition) - Features unlocked by license flags
|
||||||
@@ -109,37 +109,6 @@ You can customize fields by adding options after the recipient identifier:
|
|||||||
| `maxValue` | Number | Maximum allowed value |
|
| `maxValue` | Number | Maximum allowed value |
|
||||||
| `numberFormat` | Format string | Number display format |
|
| `numberFormat` | Format string | Number display format |
|
||||||
|
|
||||||
### Selection Field Options
|
|
||||||
|
|
||||||
Checkbox, radio, and dropdown placeholders can define their selectable choices via the `options` property.
|
|
||||||
Separate choices with pipe (`|`) characters.
|
|
||||||
Checkbox, radio, and dropdown placeholders do not support `label` or `placeholder` metadata.
|
|
||||||
|
|
||||||
| Option | Applies To | Values | Description |
|
|
||||||
| ------------------ | ------------------------- | ------------------------ | ---------------------------------------- |
|
|
||||||
| `options` | Checkbox, Radio, Dropdown | `Option 1|Option 2` | Selectable choices |
|
|
||||||
| `checked` | Checkbox | `Option 1|Option 2` | Pre-checked choices |
|
|
||||||
| `selected` | Radio, Dropdown | One option value | Pre-selected/default choice |
|
|
||||||
| `default` | Radio, Dropdown | One option value | Alias for `selected` |
|
|
||||||
| `defaultValue` | Radio, Dropdown | One option value | Alias for `selected` |
|
|
||||||
| `direction` | Checkbox, Radio | `vertical`, `horizontal` | Option layout |
|
|
||||||
| `validationRule` | Checkbox | `atLeast`, `exactly`, `atMost` | Checkbox selection validation rule |
|
|
||||||
| `validationLength` | Checkbox | Number (e.g., `1`) | Checkbox validation option count |
|
|
||||||
| `required` | Checkbox, Radio, Dropdown | `true`, `false` | Whether the field must be completed |
|
|
||||||
| `readOnly` | Checkbox, Radio, Dropdown | `true`, `false` | Whether the pre-selected value is locked |
|
|
||||||
| `fontSize` | Checkbox, Radio, Dropdown | Number (e.g., `12`) | Field text size |
|
|
||||||
|
|
||||||
For checkbox validation, `validationLength` defines the option count:
|
|
||||||
- `atLeast` means at least that many options must be selected
|
|
||||||
- `exactly` means exactly that many options must be selected
|
|
||||||
- `atMost` means at most that many options must be selected
|
|
||||||
|
|
||||||
If an option needs a literal delimiter, escape it with a backslash:
|
|
||||||
|
|
||||||
```
|
|
||||||
{{dropdown, r1, options=Sales\|Ops|Legal\, Compliance|A\=B}}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Examples with Options
|
### Examples with Options
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -147,10 +116,6 @@ If an option needs a literal delimiter, escape it with a backslash:
|
|||||||
{{number, r1, minValue=0, maxValue=100, value=50}}
|
{{number, r1, minValue=0, maxValue=100, value=50}}
|
||||||
{{name, r1, fontSize=14}}
|
{{name, r1, fontSize=14}}
|
||||||
{{text, r2, readOnly=true, text=Contract #12345}}
|
{{text, r2, readOnly=true, text=Contract #12345}}
|
||||||
{{checkbox, r1, options=Email|SMS|Phone, checked=Email|Phone, validationRule=atLeast, validationLength=1}}
|
|
||||||
{{radio, r1, options=Card|Bank transfer|Check, selected=Check}}
|
|
||||||
{{dropdown, r1, options=United States|Canada|United Kingdom}}
|
|
||||||
{{dropdown, r2, options=Sales|Legal|Finance, defaultValue=Legal}}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export const SignUpForm = ({
|
|||||||
password: '',
|
password: '',
|
||||||
signature: '',
|
signature: '',
|
||||||
},
|
},
|
||||||
mode: 'onBlur',
|
mode: 'onChange',
|
||||||
resolver: zodResolver(ZSignUpFormSchema),
|
resolver: zodResolver(ZSignUpFormSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -106,5 +106,5 @@
|
|||||||
"vite-plugin-babel-macros": "^1.0.6",
|
"vite-plugin-babel-macros": "^1.0.6",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
},
|
},
|
||||||
"version": "2.14.0"
|
"version": "2.15.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+3
-123
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "2.14.0",
|
"version": "2.15.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "2.14.0",
|
"version": "2.15.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
@@ -406,7 +406,7 @@
|
|||||||
},
|
},
|
||||||
"apps/remix": {
|
"apps/remix": {
|
||||||
"name": "@documenso/remix",
|
"name": "@documenso/remix",
|
||||||
"version": "2.14.0",
|
"version": "2.15.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cantoo/pdf-lib": "^2.5.3",
|
"@cantoo/pdf-lib": "^2.5.3",
|
||||||
"@documenso/api": "*",
|
"@documenso/api": "*",
|
||||||
@@ -31817,126 +31817,6 @@
|
|||||||
"react": "^18",
|
"react": "^18",
|
||||||
"typescript": "5.6.2"
|
"typescript": "5.6.2"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
|
||||||
"version": "16.2.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz",
|
|
||||||
"integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
|
||||||
"version": "16.2.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz",
|
|
||||||
"integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
|
||||||
"version": "16.2.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz",
|
|
||||||
"integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
|
||||||
"version": "16.2.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz",
|
|
||||||
"integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
|
||||||
"version": "16.2.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz",
|
|
||||||
"integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
|
||||||
"version": "16.2.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz",
|
|
||||||
"integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
|
||||||
"version": "16.2.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz",
|
|
||||||
"integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
|
||||||
"version": "16.2.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz",
|
|
||||||
"integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"version": "2.14.0",
|
"version": "2.15.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
|
|||||||
@@ -57,10 +57,7 @@ export const UNSAFE_createEnvelopeItems = async ({
|
|||||||
flattenForm: envelope.type !== 'TEMPLATE',
|
flattenForm: envelope.type !== 'TEMPLATE',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
|
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
||||||
envelopeId: envelope.id,
|
|
||||||
fileName: file.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { documentData } = await putPdfFileServerSide({
|
const { documentData } = await putPdfFileServerSide({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
|
|||||||
@@ -85,10 +85,7 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
|
|||||||
flattenForm: envelope.type !== 'TEMPLATE',
|
flattenForm: envelope.type !== 'TEMPLATE',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
|
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
||||||
envelopeId: envelope.id,
|
|
||||||
fileName: data.file.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Upload the new PDF and get a new DocumentData record.
|
// Upload the new PDF and get a new DocumentData record.
|
||||||
const { documentData: newDocumentData, filePageCount } = await putPdfFileServerSide({
|
const { documentData: newDocumentData, filePageCount } = await putPdfFileServerSide({
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { type TFieldAndMeta, ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
import { type TFieldAndMeta, ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
import { logger } from '@documenso/lib/utils/logger';
|
|
||||||
import { PDF, rgb } from '@libpdf/core';
|
import { PDF, rgb } from '@libpdf/core';
|
||||||
import type { FieldType, Recipient } from '@prisma/client';
|
import type { FieldType, Recipient } from '@prisma/client';
|
||||||
|
|
||||||
import {
|
import { parseFieldMetaFromPlaceholder, parseFieldTypeFromPlaceholder } from './helpers';
|
||||||
parseFieldMetaFromPlaceholder,
|
|
||||||
parseFieldTypeFromPlaceholder,
|
|
||||||
parsePlaceholderData,
|
|
||||||
parseRawFieldMetaFromPlaceholder,
|
|
||||||
} from './helpers';
|
|
||||||
|
|
||||||
const PLACEHOLDER_REGEX = /\{\{([^}]+)\}\}/g;
|
const PLACEHOLDER_REGEX = /\{\{([^}]+)\}\}/g;
|
||||||
const DEFAULT_FIELD_HEIGHT_PERCENT = 2;
|
const DEFAULT_FIELD_HEIGHT_PERCENT = 2;
|
||||||
@@ -68,15 +61,7 @@ export type FieldToCreate = TFieldAndMeta & {
|
|||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ExtractPlaceholdersLogContext = {
|
export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<PlaceholderInfo[]> => {
|
||||||
envelopeId?: string;
|
|
||||||
fileName?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const extractPlaceholdersFromPDF = async (
|
|
||||||
pdf: Buffer,
|
|
||||||
logContext?: ExtractPlaceholdersLogContext,
|
|
||||||
): Promise<PlaceholderInfo[]> => {
|
|
||||||
const pdfDoc = await PDF.load(new Uint8Array(pdf));
|
const pdfDoc = await PDF.load(new Uint8Array(pdf));
|
||||||
|
|
||||||
const placeholders: PlaceholderInfo[] = [];
|
const placeholders: PlaceholderInfo[] = [];
|
||||||
@@ -100,7 +85,7 @@ export const extractPlaceholdersFromPDF = async (
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const placeholderData = parsePlaceholderData(innerMatch[1]);
|
const placeholderData = innerMatch[1].split(',').map((property) => property.trim());
|
||||||
const [fieldTypeString, recipientOrMeta, ...fieldMetaData] = placeholderData;
|
const [fieldTypeString, recipientOrMeta, ...fieldMetaData] = placeholderData;
|
||||||
|
|
||||||
let fieldType: FieldType;
|
let fieldType: FieldType;
|
||||||
@@ -124,51 +109,14 @@ export const extractPlaceholdersFromPDF = async (
|
|||||||
|
|
||||||
const recipient = recipientOrMeta;
|
const recipient = recipientOrMeta;
|
||||||
|
|
||||||
/*
|
const rawFieldMeta = Object.fromEntries(fieldMetaData.map((property) => property.split('=')));
|
||||||
Parse and validate the field metadata. A malformed selection placeholder
|
|
||||||
(e.g. an unknown validation rule or a default value that doesn't match an
|
|
||||||
option) is skipped like an invalid field type rather than aborting the whole
|
|
||||||
upload, which may contain other valid placeholders and files.
|
|
||||||
*/
|
|
||||||
let fieldAndMeta: TFieldAndMeta;
|
|
||||||
|
|
||||||
try {
|
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
|
||||||
const rawFieldMeta = parseRawFieldMetaFromPlaceholder(fieldMetaData);
|
|
||||||
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
|
|
||||||
|
|
||||||
const parsedFieldAndMeta = ZEnvelopeFieldAndMetaSchema.safeParse({
|
const fieldAndMeta: TFieldAndMeta = ZEnvelopeFieldAndMetaSchema.parse({
|
||||||
type: fieldType,
|
type: fieldType,
|
||||||
fieldMeta: parsedFieldMeta,
|
fieldMeta: parsedFieldMeta,
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
|
||||||
Surface schema failures as INVALID_BODY (400) instead of letting the raw
|
|
||||||
ZodError bubble up to the caller as an INTERNAL_SERVER_ERROR (500).
|
|
||||||
*/
|
|
||||||
if (!parsedFieldAndMeta.success) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: `Invalid field metadata for placeholder "${placeholder}": ${parsedFieldAndMeta.error.message}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fieldAndMeta = parsedFieldAndMeta.data;
|
|
||||||
} catch (error) {
|
|
||||||
const appError = AppError.parseError(error);
|
|
||||||
|
|
||||||
logger.warn(
|
|
||||||
{
|
|
||||||
envelopeId: logContext?.envelopeId,
|
|
||||||
fileName: logContext?.fileName,
|
|
||||||
placeholder,
|
|
||||||
page: page.index + 1,
|
|
||||||
code: appError.code,
|
|
||||||
message: appError.message,
|
|
||||||
},
|
|
||||||
'Skipping placeholder with invalid field metadata',
|
|
||||||
);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
LibPDF returns bbox in points with bottom-left origin.
|
LibPDF returns bbox in points with bottom-left origin.
|
||||||
@@ -234,9 +182,8 @@ export const removePlaceholdersFromPDF = async (pdf: Buffer, placeholders?: Plac
|
|||||||
*/
|
*/
|
||||||
export const extractPdfPlaceholders = async (
|
export const extractPdfPlaceholders = async (
|
||||||
pdf: Buffer,
|
pdf: Buffer,
|
||||||
logContext?: ExtractPlaceholdersLogContext,
|
|
||||||
): Promise<{ cleanedPdf: Buffer; placeholders: PlaceholderInfo[] }> => {
|
): Promise<{ cleanedPdf: Buffer; placeholders: PlaceholderInfo[] }> => {
|
||||||
const placeholders = await extractPlaceholdersFromPDF(pdf, logContext);
|
const placeholders = await extractPlaceholdersFromPDF(pdf);
|
||||||
|
|
||||||
if (placeholders.length === 0) {
|
if (placeholders.length === 0) {
|
||||||
return { cleanedPdf: pdf, placeholders: [] };
|
return { cleanedPdf: pdf, placeholders: [] };
|
||||||
|
|||||||
@@ -1,235 +0,0 @@
|
|||||||
import { FieldType } from '@prisma/client';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
|
||||||
import {
|
|
||||||
parseFieldMetaFromPlaceholder,
|
|
||||||
parseFieldTypeFromPlaceholder,
|
|
||||||
parsePlaceholderData,
|
|
||||||
parseRawFieldMetaFromPlaceholder,
|
|
||||||
} from './helpers';
|
|
||||||
|
|
||||||
const expectInvalidBody = (fn: () => unknown) => {
|
|
||||||
try {
|
|
||||||
fn();
|
|
||||||
expect.unreachable('Expected an AppError to be thrown');
|
|
||||||
} catch (error) {
|
|
||||||
expect(error).toBeInstanceOf(AppError);
|
|
||||||
expect((error as AppError).code).toBe(AppErrorCode.INVALID_BODY);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('parseFieldTypeFromPlaceholder function', () => {
|
|
||||||
it('maps known field type strings to the FieldType enum', () => {
|
|
||||||
expect(parseFieldTypeFromPlaceholder('signature')).toBe(FieldType.SIGNATURE);
|
|
||||||
expect(parseFieldTypeFromPlaceholder('radio')).toBe(FieldType.RADIO);
|
|
||||||
expect(parseFieldTypeFromPlaceholder('checkbox')).toBe(FieldType.CHECKBOX);
|
|
||||||
expect(parseFieldTypeFromPlaceholder('dropdown')).toBe(FieldType.DROPDOWN);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is case-insensitive and trims surrounding whitespace', () => {
|
|
||||||
expect(parseFieldTypeFromPlaceholder(' SiGnAtUrE ')).toBe(FieldType.SIGNATURE);
|
|
||||||
expect(parseFieldTypeFromPlaceholder('RADIO')).toBe(FieldType.RADIO);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws INVALID_BODY for an unknown field type', () => {
|
|
||||||
expectInvalidBody(() => parseFieldTypeFromPlaceholder('FILE'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parsePlaceholderData function', () => {
|
|
||||||
it('splits top-level parts on commas and trims each token', () => {
|
|
||||||
expect(parsePlaceholderData('SIGNATURE, r1, required=true')).toEqual(['SIGNATURE', 'r1', 'required=true']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not split on escaped commas', () => {
|
|
||||||
expect(parsePlaceholderData('dropdown, r1, options=Legal\\, Compliance|Sales')).toEqual([
|
|
||||||
'dropdown',
|
|
||||||
'r1',
|
|
||||||
'options=Legal\\, Compliance|Sales',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseRawFieldMetaFromPlaceholder function', () => {
|
|
||||||
it('splits each token into a key/value entry', () => {
|
|
||||||
expect(parseRawFieldMetaFromPlaceholder(['required=true', 'fontSize=12'])).toEqual({
|
|
||||||
required: 'true',
|
|
||||||
fontSize: '12',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('only splits on the first unescaped equals sign', () => {
|
|
||||||
expect(parseRawFieldMetaFromPlaceholder(['label=a=b'])).toEqual({ label: 'a=b' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('drops tokens without a value and overwrites duplicate keys with the last', () => {
|
|
||||||
expect(parseRawFieldMetaFromPlaceholder(['required', 'fontSize=12', 'fontSize=14'])).toEqual({
|
|
||||||
fontSize: '14',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseFieldMetaFromPlaceholder function', () => {
|
|
||||||
describe('non-field-meta cases', () => {
|
|
||||||
it('returns undefined for signature and free signature fields', () => {
|
|
||||||
expect(parseFieldMetaFromPlaceholder({ required: 'true' }, FieldType.SIGNATURE)).toBeUndefined();
|
|
||||||
expect(parseFieldMetaFromPlaceholder({}, FieldType.FREE_SIGNATURE)).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns undefined when there is no metadata', () => {
|
|
||||||
expect(parseFieldMetaFromPlaceholder({}, FieldType.TEXT)).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('generic metadata', () => {
|
|
||||||
it('coerces required/readOnly to booleans (case-insensitive)', () => {
|
|
||||||
expect(parseFieldMetaFromPlaceholder({ required: 'TRUE', readOnly: 'false' }, FieldType.TEXT)).toEqual({
|
|
||||||
type: 'text',
|
|
||||||
required: true,
|
|
||||||
readOnly: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('coerces numeric properties to numbers', () => {
|
|
||||||
expect(parseFieldMetaFromPlaceholder({ fontSize: '14' }, FieldType.TEXT)).toEqual({
|
|
||||||
type: 'text',
|
|
||||||
fontSize: 14,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('drops numeric properties that are not a number', () => {
|
|
||||||
const parsed = parseFieldMetaFromPlaceholder({ fontSize: 'abc' }, FieldType.TEXT);
|
|
||||||
|
|
||||||
expect(parsed).toEqual({ type: 'text' });
|
|
||||||
expect(parsed).not.toHaveProperty('fontSize');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps label/placeholder for non-selection fields', () => {
|
|
||||||
expect(parseFieldMetaFromPlaceholder({ label: 'Company Name', placeholder: 'Acme' }, FieldType.TEXT)).toEqual({
|
|
||||||
type: 'text',
|
|
||||||
label: 'Company Name',
|
|
||||||
placeholder: 'Acme',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('radio fields', () => {
|
|
||||||
it('builds stable values from options', () => {
|
|
||||||
expect(parseFieldMetaFromPlaceholder({ options: 'Yes|No|Maybe' }, FieldType.RADIO)).toEqual({
|
|
||||||
type: 'radio',
|
|
||||||
values: [
|
|
||||||
{ id: 1, checked: false, value: 'Yes' },
|
|
||||||
{ id: 2, checked: false, value: 'No' },
|
|
||||||
{ id: 3, checked: false, value: 'Maybe' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks only the selected option as checked', () => {
|
|
||||||
const parsed = parseFieldMetaFromPlaceholder({ options: 'Yes|No|Maybe', selected: 'No' }, FieldType.RADIO);
|
|
||||||
|
|
||||||
expect(parsed).toEqual({
|
|
||||||
type: 'radio',
|
|
||||||
values: [
|
|
||||||
{ id: 1, checked: false, value: 'Yes' },
|
|
||||||
{ id: 2, checked: true, value: 'No' },
|
|
||||||
{ id: 3, checked: false, value: 'Maybe' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws when a default value is provided without options', () => {
|
|
||||||
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ selected: 'No' }, FieldType.RADIO));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws when the default value does not match an option', () => {
|
|
||||||
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: 'Yes|No', selected: 'Maybe' }, FieldType.RADIO));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws when options is empty', () => {
|
|
||||||
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: '' }, FieldType.RADIO));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('checkbox fields', () => {
|
|
||||||
it('builds values with checked state, validation rule alias and length', () => {
|
|
||||||
const parsed = parseFieldMetaFromPlaceholder(
|
|
||||||
{
|
|
||||||
options: 'Email|SMS|Phone',
|
|
||||||
checked: 'Email|Phone',
|
|
||||||
validationRule: 'atLeast',
|
|
||||||
validationLength: '1',
|
|
||||||
},
|
|
||||||
FieldType.CHECKBOX,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(parsed).toEqual({
|
|
||||||
type: 'checkbox',
|
|
||||||
validationRule: 'Select at least',
|
|
||||||
validationLength: 1,
|
|
||||||
values: [
|
|
||||||
{ id: 1, checked: true, value: 'Email' },
|
|
||||||
{ id: 2, checked: false, value: 'SMS' },
|
|
||||||
{ id: 3, checked: true, value: 'Phone' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws for an unknown validation rule', () => {
|
|
||||||
expectInvalidBody(() =>
|
|
||||||
parseFieldMetaFromPlaceholder({ options: 'A|B', validationRule: 'nope' }, FieldType.CHECKBOX),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws when checked values are provided without options', () => {
|
|
||||||
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ checked: 'A' }, FieldType.CHECKBOX));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws when a checked value does not match an option', () => {
|
|
||||||
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: 'A|B', checked: 'C' }, FieldType.CHECKBOX));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('dropdown fields', () => {
|
|
||||||
it('builds values and sets a matching default value', () => {
|
|
||||||
expect(
|
|
||||||
parseFieldMetaFromPlaceholder(
|
|
||||||
{ options: 'United States|Canada|United Kingdom', defaultValue: 'Canada' },
|
|
||||||
FieldType.DROPDOWN,
|
|
||||||
),
|
|
||||||
).toEqual({
|
|
||||||
type: 'dropdown',
|
|
||||||
values: [{ value: 'United States' }, { value: 'Canada' }, { value: 'United Kingdom' }],
|
|
||||||
defaultValue: 'Canada',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws when the default value does not match an option', () => {
|
|
||||||
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: 'A|B', defaultValue: 'C' }, FieldType.DROPDOWN));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('selection field options parsing', () => {
|
|
||||||
it('trims option values and drops empty entries', () => {
|
|
||||||
expect(parseFieldMetaFromPlaceholder({ options: ' A || B ' }, FieldType.DROPDOWN)).toEqual({
|
|
||||||
type: 'dropdown',
|
|
||||||
values: [{ value: 'A' }, { value: 'B' }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses escaped delimiters through the full placeholder pipeline', () => {
|
|
||||||
const [, , ...fieldMetaData] = parsePlaceholderData(
|
|
||||||
'dropdown, r1, options=Sales\\|Ops|Legal\\, Compliance|A\\=B',
|
|
||||||
);
|
|
||||||
|
|
||||||
const rawFieldMeta = parseRawFieldMetaFromPlaceholder(fieldMetaData);
|
|
||||||
const parsed = parseFieldMetaFromPlaceholder(rawFieldMeta, FieldType.DROPDOWN);
|
|
||||||
|
|
||||||
expect(parsed).toEqual({
|
|
||||||
type: 'dropdown',
|
|
||||||
values: [{ value: 'Sales|Ops' }, { value: 'Legal, Compliance' }, { value: 'A=B' }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -45,134 +45,6 @@ type RecipientPlaceholderInfo = {
|
|||||||
recipientIndex: number;
|
recipientIndex: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CHECKBOX_VALIDATION_RULE_BY_ALIAS: Record<string, string> = {
|
|
||||||
atLeast: 'Select at least',
|
|
||||||
exactly: 'Select exactly',
|
|
||||||
atMost: 'Select at most',
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
Split a string on a delimiter, treating `\` as an escape for the next character.
|
|
||||||
Delimiters preceded by `\` are kept in the output instead of splitting (e.g. `\,`, `\=`, `\|`).
|
|
||||||
|
|
||||||
With delimiter ',' (top-level placeholder parts):
|
|
||||||
'radio, r1, options=Card/Check|Bank Transfer, selected=Bank Transfer'
|
|
||||||
-> ['radio', ' r1', ' options=Card/Check|Bank Transfer', ' selected=Bank Transfer']
|
|
||||||
|
|
||||||
With delimiter '=' (split one field metadata token into key + value):
|
|
||||||
'options=Card/Check|Bank Transfer'
|
|
||||||
-> ['options', 'Card/Check|Bank Transfer']
|
|
||||||
|
|
||||||
With delimiter '|' (split option list inside 'options='):
|
|
||||||
'Card/Check|Bank Transfer'
|
|
||||||
-> ['Card/Check', 'Bank Transfer']
|
|
||||||
*/
|
|
||||||
const splitPlaceholderToken = (value: string, delimiter: string): string[] => {
|
|
||||||
const parts: string[] = [];
|
|
||||||
let currentPart = '';
|
|
||||||
|
|
||||||
for (let index = 0; index < value.length; index++) {
|
|
||||||
const char = value[index];
|
|
||||||
const nextChar = value[index + 1];
|
|
||||||
|
|
||||||
if (char === '\\' && nextChar) {
|
|
||||||
currentPart += char + nextChar;
|
|
||||||
index++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (char === delimiter) {
|
|
||||||
parts.push(currentPart);
|
|
||||||
currentPart = '';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPart += char;
|
|
||||||
}
|
|
||||||
|
|
||||||
parts.push(currentPart);
|
|
||||||
|
|
||||||
return parts;
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
Removes the escape backslashes left over after splitting, so \,=, \|, \\ become their literal characters.
|
|
||||||
|
|
||||||
E.g.
|
|
||||||
'Legal\, Compliance' -> 'Legal, Compliance'
|
|
||||||
'Card\|Check' -> 'Card|Check'
|
|
||||||
'A\=B' -> 'A=B'
|
|
||||||
'C\D' -> 'C\D'
|
|
||||||
*/
|
|
||||||
const unescapePlaceholderValue = (value: string): string => {
|
|
||||||
return value.replace(/\\([,=|\\])/g, '$1');
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
Cleans up a selection option/default after splitting:
|
|
||||||
unescapes literal delimiters, collapses repeated whitespace, and trims the ends.
|
|
||||||
|
|
||||||
E.g.
|
|
||||||
' Legal\, Compliance ' -> 'Legal, Compliance'
|
|
||||||
*/
|
|
||||||
const normalizePlaceholderSelectionValue = (value: string): string => {
|
|
||||||
return unescapePlaceholderValue(value).replace(/\s+/g, ' ').trim();
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
Split an options string into individual choices.
|
|
||||||
Splits on unescaped '|', then unescapes, trims, and drops empty entries.
|
|
||||||
|
|
||||||
E.g.
|
|
||||||
'Card/Check|Bank Transfer' -> ['Card/Check', 'Bank Transfer']
|
|
||||||
'Card\\|Check|Bank Transfer' -> ['Card|Check', 'Bank Transfer']
|
|
||||||
*/
|
|
||||||
const parsePlaceholderOptions = (value: string): string[] => {
|
|
||||||
return splitPlaceholderToken(value, '|')
|
|
||||||
.map((option) => normalizePlaceholderSelectionValue(option))
|
|
||||||
.filter((option) => option.length > 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
Split a placeholder string into top-level parts (field type, recipient, metadata).
|
|
||||||
Splits on unescaped commas, then trims whitespace.
|
|
||||||
|
|
||||||
E.g.
|
|
||||||
'SIGNATURE, r1, required=true'
|
|
||||||
-> ['SIGNATURE', 'r1', 'required=true']
|
|
||||||
*/
|
|
||||||
export const parsePlaceholderData = (value: string): string[] => {
|
|
||||||
return splitPlaceholderToken(value, ',').map((token) => token.trim());
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
Transforms the field metadata string array into a record of key/value pairs.
|
|
||||||
Each token is split on the first unescaped '='; tokens with no key or no '=' are dropped.
|
|
||||||
|
|
||||||
E.g.
|
|
||||||
['required=true', 'fontSize=12', 'label=a=b']
|
|
||||||
-> { required: 'true', fontSize: '12', label: 'a=b' }
|
|
||||||
*/
|
|
||||||
export const parseRawFieldMetaFromPlaceholder = (fieldMetaData: string[]): Record<string, string> => {
|
|
||||||
const rawFieldMeta: Record<string, string> = {};
|
|
||||||
|
|
||||||
for (const fieldMeta of fieldMetaData) {
|
|
||||||
// Split on the first '=' only; any further '=' stays part of the value (e.g. 'label=a=b').
|
|
||||||
const [rawKey, ...valueParts] = splitPlaceholderToken(fieldMeta, '=');
|
|
||||||
|
|
||||||
if (!rawKey || valueParts.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = rawKey.trim();
|
|
||||||
const value = valueParts.join('=').trim();
|
|
||||||
|
|
||||||
rawFieldMeta[key] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return rawFieldMeta;
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Parse field type string to FieldType enum.
|
Parse field type string to FieldType enum.
|
||||||
Normalizes the input (uppercase, trim) and validates it's a valid field type.
|
Normalizes the input (uppercase, trim) and validates it's a valid field type.
|
||||||
@@ -200,169 +72,6 @@ export const parseFieldTypeFromPlaceholder = (fieldTypeString: string): FieldTyp
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDefaultFieldMetaValue = (rawFieldMeta: Record<string, string>) => {
|
|
||||||
const defaultValue = rawFieldMeta.defaultValue ?? rawFieldMeta.default ?? rawFieldMeta.selected;
|
|
||||||
|
|
||||||
return defaultValue ? normalizePlaceholderSelectionValue(defaultValue) : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseCheckboxValidationRule = (value: string): string => {
|
|
||||||
const validationRule = CHECKBOX_VALIDATION_RULE_BY_ALIAS[value];
|
|
||||||
|
|
||||||
if (!validationRule) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: `Invalid checkbox placeholder validation rule: ${value}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return validationRule;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseSelectionFieldOptions = (
|
|
||||||
rawFieldMeta: Record<string, string>,
|
|
||||||
fieldType: FieldType,
|
|
||||||
): string[] | undefined => {
|
|
||||||
const rawOptions = rawFieldMeta.options;
|
|
||||||
|
|
||||||
if (rawOptions === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedOptions = parsePlaceholderOptions(rawOptions);
|
|
||||||
|
|
||||||
if (parsedOptions.length === 0) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: `${fieldType} placeholder options must contain at least one value`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsedOptions;
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyRadioFieldOptions = (parsedFieldMeta: Record<string, unknown>, rawFieldMeta: Record<string, string>) => {
|
|
||||||
const options = parseSelectionFieldOptions(rawFieldMeta, FieldType.RADIO);
|
|
||||||
const defaultValue = getDefaultFieldMetaValue(rawFieldMeta);
|
|
||||||
|
|
||||||
if (!options && defaultValue) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: 'Radio placeholder default value requires options',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!options) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedOptionIndex = defaultValue ? options.findIndex((option) => option === defaultValue) : -1;
|
|
||||||
|
|
||||||
if (defaultValue && selectedOptionIndex === -1) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: `Radio placeholder default value "${defaultValue}" must match one of the options`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
parsedFieldMeta.values = options.map((option, index) => ({
|
|
||||||
id: index + 1,
|
|
||||||
checked: index === selectedOptionIndex,
|
|
||||||
value: option,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyCheckboxFieldOptions = (parsedFieldMeta: Record<string, unknown>, rawFieldMeta: Record<string, string>) => {
|
|
||||||
const options = parseSelectionFieldOptions(rawFieldMeta, FieldType.CHECKBOX);
|
|
||||||
const checkedValues = rawFieldMeta.checked ? parsePlaceholderOptions(rawFieldMeta.checked) : [];
|
|
||||||
|
|
||||||
if (!options && checkedValues.length > 0) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: 'Checkbox placeholder checked values require options',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!options) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const unmatchedCheckedValues = checkedValues.filter((checkedValue) => !options.includes(checkedValue));
|
|
||||||
|
|
||||||
if (unmatchedCheckedValues.length > 0) {
|
|
||||||
const unmatchedCheckedValue = unmatchedCheckedValues[0];
|
|
||||||
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: [`Checkbox placeholder checked value "${unmatchedCheckedValue}"`, 'must match one of the options'].join(
|
|
||||||
' ',
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
parsedFieldMeta.values = options.map((option, index) => ({
|
|
||||||
id: index + 1,
|
|
||||||
checked: checkedValues.includes(option),
|
|
||||||
value: option,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyDropdownFieldOptions = (parsedFieldMeta: Record<string, unknown>, rawFieldMeta: Record<string, string>) => {
|
|
||||||
const options = parseSelectionFieldOptions(rawFieldMeta, FieldType.DROPDOWN);
|
|
||||||
const defaultValue = getDefaultFieldMetaValue(rawFieldMeta);
|
|
||||||
|
|
||||||
if (!options && defaultValue) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: 'Dropdown placeholder default value requires options',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!options) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (defaultValue && !options.includes(defaultValue)) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: `Dropdown placeholder default value "${defaultValue}" must match one of the options`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
parsedFieldMeta.values = options.map((option) => ({
|
|
||||||
value: option,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (defaultValue) {
|
|
||||||
parsedFieldMeta.defaultValue = defaultValue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
Generic field metadata properties are simple properties consisting of a key and a value.
|
|
||||||
E.g. 'required=true', 'fontSize=12', 'textAlign=left'
|
|
||||||
They don't require special handling.
|
|
||||||
|
|
||||||
Special field metadata properties are complex properties consisting of a key and a value with multiple parts.
|
|
||||||
E.g. 'options=Card/Check|Bank Transfer', 'checked=Card|Check', 'selected=Bank Transfer'
|
|
||||||
They require special handling.
|
|
||||||
*/
|
|
||||||
const shouldSkipGenericFieldMetaParsing = (property: string, fieldType: FieldType): boolean => {
|
|
||||||
if (property === 'options' || property === 'default' || property === 'selected') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSelectionField =
|
|
||||||
fieldType === FieldType.CHECKBOX || fieldType === FieldType.RADIO || fieldType === FieldType.DROPDOWN;
|
|
||||||
|
|
||||||
if (!isSelectionField) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
property === 'label' ||
|
|
||||||
property === 'placeholder' ||
|
|
||||||
property === 'defaultValue' ||
|
|
||||||
(fieldType === FieldType.CHECKBOX && property === 'checked')
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Transform raw field metadata from placeholder format to schema format.
|
Transform raw field metadata from placeholder format to schema format.
|
||||||
Users should provide properly capitalized property names (e.g., readOnly, fontSize, textAlign).
|
Users should provide properly capitalized property names (e.g., readOnly, fontSize, textAlign).
|
||||||
@@ -382,7 +91,7 @@ export const parseFieldMetaFromPlaceholder = (
|
|||||||
|
|
||||||
const fieldTypeString = String(fieldType).toLowerCase();
|
const fieldTypeString = String(fieldType).toLowerCase();
|
||||||
|
|
||||||
const parsedFieldMeta: Record<string, unknown> = {
|
const parsedFieldMeta: Record<string, boolean | number | string> = {
|
||||||
type: fieldTypeString,
|
type: fieldTypeString,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -395,39 +104,24 @@ export const parseFieldMetaFromPlaceholder = (
|
|||||||
const rawFieldMetaEntries = Object.entries(rawFieldMeta);
|
const rawFieldMetaEntries = Object.entries(rawFieldMeta);
|
||||||
|
|
||||||
for (const [property, value] of rawFieldMetaEntries) {
|
for (const [property, value] of rawFieldMetaEntries) {
|
||||||
if (shouldSkipGenericFieldMetaParsing(property, fieldType)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const unescapedValue = unescapePlaceholderValue(value);
|
|
||||||
|
|
||||||
if (property === 'readOnly' || property === 'required') {
|
if (property === 'readOnly' || property === 'required') {
|
||||||
parsedFieldMeta[property] = unescapedValue.toLowerCase() === 'true';
|
parsedFieldMeta[property] = value === 'true';
|
||||||
} else if (property === 'validationRule' && fieldType === FieldType.CHECKBOX) {
|
|
||||||
parsedFieldMeta[property] = parseCheckboxValidationRule(unescapedValue);
|
|
||||||
} else if (
|
} else if (
|
||||||
property === 'fontSize' ||
|
property === 'fontSize' ||
|
||||||
property === 'maxValue' ||
|
property === 'maxValue' ||
|
||||||
property === 'minValue' ||
|
property === 'minValue' ||
|
||||||
property === 'characterLimit' ||
|
property === 'characterLimit'
|
||||||
property === 'validationLength'
|
|
||||||
) {
|
) {
|
||||||
const numValue = Number(unescapedValue);
|
const numValue = Number(value);
|
||||||
|
|
||||||
if (!Number.isNaN(numValue)) {
|
if (!Number.isNaN(numValue)) {
|
||||||
parsedFieldMeta[property] = numValue;
|
parsedFieldMeta[property] = numValue;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
parsedFieldMeta[property] = unescapedValue;
|
parsedFieldMeta[property] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match(fieldType)
|
|
||||||
.with(FieldType.RADIO, () => applyRadioFieldOptions(parsedFieldMeta, rawFieldMeta))
|
|
||||||
.with(FieldType.CHECKBOX, () => applyCheckboxFieldOptions(parsedFieldMeta, rawFieldMeta))
|
|
||||||
.with(FieldType.DROPDOWN, () => applyDropdownFieldOptions(parsedFieldMeta, rawFieldMeta))
|
|
||||||
.otherwise(() => undefined);
|
|
||||||
|
|
||||||
return parsedFieldMeta;
|
return parsedFieldMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -124,9 +124,7 @@ export const createEnvelopeRouteCaller = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Todo: Embeds - Might need to add this for client-side embeds in the future.
|
// Todo: Embeds - Might need to add this for client-side embeds in the future.
|
||||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
|
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
||||||
fileName: file.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { documentData } = await putPdfFileServerSide({
|
const { documentData } = await putPdfFileServerSide({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
|
|||||||
Reference in New Issue
Block a user