Compare commits

..
40 changed files with 2464 additions and 2483 deletions
@@ -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`.
+1 -1
View File
@@ -1,3 +1,3 @@
legacy-peer-deps = true
prefer-dedupe = true
min-release-age = 7
# min-release-age = 7
@@ -109,37 +109,6 @@ You can customize fields by adding options after the recipient identifier:
| `maxValue` | Number | Maximum allowed value |
| `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
```
@@ -147,10 +116,6 @@ If an option needs a literal delimiter, escape it with a backslash:
{{number, r1, minValue=0, maxValue=100, value=50}}
{{name, r1, fontSize=14}}
{{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">
+1
View File
@@ -10,6 +10,7 @@
"postinstall": "fumadocs-mdx"
},
"dependencies": {
"@radix-ui/react-tabs": "^1.1.13",
"fumadocs-core": "16.5.0",
"fumadocs-mdx": "14.2.6",
"fumadocs-ui": "16.5.0",
+1 -1
View File
@@ -16,7 +16,7 @@
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19.2.17",
"@types/react": "18.3.27",
"typescript": "5.6.2"
}
}
@@ -19,7 +19,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { keepPreviousData } from '@tanstack/react-query';
import { defaultFilter as commandScore } from 'cmdk';
import { commandScore } from 'cmdk/dist/command-score';
import {
ArrowLeftIcon,
CheckIcon,
@@ -146,17 +146,14 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
{isLoading && <DocumentSigningFieldsLoader />}
{!field.inserted && (
<RadioGroup
value={selectedOption}
onValueChange={(value) => handleSelectItem(value)}
className="z-10 my-0.5 gap-y-1"
>
<RadioGroup onValueChange={(value) => handleSelectItem(value)} className="z-10 my-0.5 gap-y-1">
{values?.map((item, index) => (
<div key={index} className="flex items-center">
<RadioGroupItem
className="h-3 w-3 shrink-0"
value={item.value}
id={`option-${field.id}-${item.id}`}
checked={item.checked}
disabled={isReadOnly}
/>
{!item.value.includes('empty-value-') && item.value && (
@@ -170,13 +167,14 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
)}
{field.inserted && (
<RadioGroup value={field.customText ?? ''} className="my-0.5 gap-y-1">
<RadioGroup className="my-0.5 gap-y-1">
{values?.map((item, index) => (
<div key={index} className="flex items-center">
<RadioGroupItem
className="h-3 w-3"
value={item.value}
id={`option-${field.id}-${item.id}`}
checked={item.value === field.customText}
disabled={isReadOnly}
/>
{!item.value.includes('empty-value-') && item.value && (
@@ -215,7 +215,7 @@ export default function PDFViewer({
type VirtualizedPageListProps = {
scrollParentRef: ScrollTarget;
constraintRef: React.RefObject<HTMLDivElement | null>;
constraintRef: React.RefObject<HTMLDivElement>;
pages: PageMeta[];
numPages: number;
pdf: pdfjsLib.PDFDocumentProxy;
-21
View File
@@ -31,26 +31,6 @@ function initPosthog() {
}
}
/**
* Surfaces hydration recoveries (React 19 discards the server HTML and
* re-renders on the client instead of dying) so we can track how often
* extensions/early clicks interfere with hydration in the wild.
*/
function onRecoverableError(error: unknown, errorInfo: { componentStack?: string }) {
console.error('[hydration] recovered from error', error, errorInfo.componentStack);
if (extractPostHogConfig()) {
void import('posthog-js').then(({ default: posthog }) => {
if (posthog.__loaded) {
posthog.capture('$hydration_recoverable_error', {
message: error instanceof Error ? error.message : String(error),
componentStack: errorInfo.componentStack,
});
}
});
}
}
async function main() {
const locale = detect(fromHtmlTag('lang')) || 'en';
@@ -64,7 +44,6 @@ async function main() {
<HydratedRouter />
</I18nProvider>
</StrictMode>,
{ onRecoverableError },
);
});
+4 -4
View File
@@ -57,9 +57,9 @@
"papaparse": "^5.5.3",
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
"react": "^19.2.7",
"react": "^18",
"react-call": "^1.8.1",
"react-dom": "^19.2.7",
"react-dom": "^18",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.66.1",
"react-hotkeys-hook": "^4.6.2",
@@ -93,8 +93,8 @@
"@types/luxon": "^3.7.1",
"@types/node": "^20",
"@types/papaparse": "^5.5.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/react": "18.3.27",
"@types/react-dom": "^18",
"@types/ua-parser-js": "^0.7.39",
"cross-env": "^10.1.0",
"esbuild": "^0.27.0",
+1 -1
View File
@@ -121,7 +121,7 @@ export default defineConfig({
'nodemailer',
/playwright/,
'@playwright/browser-chromium',
'@documenso/skia-canvas',
'skia-canvas',
],
},
},
+2284 -1385
View File
File diff suppressed because it is too large Load Diff
+4 -39
View File
@@ -48,17 +48,14 @@
},
"devDependencies": {
"@biomejs/biome": "2.4.8",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@commitlint/cli": "^20.1.0",
"@commitlint/config-conventional": "^20.0.0",
"@datadog/pprof": "^5.13.5",
"@documenso/skia-canvas": "^3.0.8-documenso.3",
"@lingui/cli": "^5.6.0",
"@prisma/client": "^6.19.0",
"@trpc/client": "11.17.0",
"@trpc/react-query": "11.17.0",
"@trpc/server": "11.17.0",
"@trpc/client": "11.8.1",
"@trpc/react-query": "11.8.1",
"@trpc/server": "11.8.1",
"@ts-rest/core": "^3.52.1",
"@ts-rest/open-api": "^3.52.1",
"@ts-rest/serverless": "^3.52.1",
@@ -95,41 +92,13 @@
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@prisma/extension-read-replicas": "^0.4.1",
"@radix-ui/react-accordion": "^1.2.16",
"@radix-ui/react-alert-dialog": "^1.1.19",
"@radix-ui/react-aspect-ratio": "^1.1.11",
"@radix-ui/react-avatar": "^1.2.2",
"@radix-ui/react-checkbox": "^1.3.7",
"@radix-ui/react-collapsible": "^1.1.16",
"@radix-ui/react-context-menu": "^2.3.3",
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-dropdown-menu": "^2.1.20",
"@radix-ui/react-hover-card": "^1.1.19",
"@radix-ui/react-label": "^2.1.11",
"@radix-ui/react-menubar": "^1.1.20",
"@radix-ui/react-navigation-menu": "^1.2.18",
"@radix-ui/react-popover": "^1.1.19",
"@radix-ui/react-progress": "^1.1.12",
"@radix-ui/react-radio-group": "^1.4.3",
"@radix-ui/react-scroll-area": "^1.2.14",
"@radix-ui/react-select": "^2.3.3",
"@radix-ui/react-separator": "^1.1.11",
"@radix-ui/react-slider": "^1.4.3",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.3",
"@radix-ui/react-tabs": "^1.1.17",
"@radix-ui/react-toast": "^1.2.19",
"@radix-ui/react-toggle": "^1.1.14",
"@radix-ui/react-toggle-group": "^1.1.15",
"@radix-ui/react-tooltip": "^1.2.12",
"ai": "^5.0.104",
"cron-parser": "^5.5.0",
"fflate": "^0.8.3",
"luxon": "^3.7.2",
"patch-package": "^8.0.1",
"posthog-node": "4.18.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react": "^18",
"typescript": "5.6.2",
"@marsidev/react-turnstile": "^1.5.0",
"zod": "^3.25.76"
@@ -138,10 +107,6 @@
"lodash": "4.18.1",
"pdfjs-dist": "5.4.296",
"postcss": "^8.5.19",
"react": "$react",
"react-dom": "$react-dom",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"typescript": "5.6.2",
"zod": "$zod",
"fumadocs-mdx": {
+1 -1
View File
@@ -19,7 +19,7 @@
"arctic": "^3.7.0",
"hono": "^4.12.14",
"luxon": "^3.7.2",
"react": "^19.2.7",
"react": "^18",
"ts-pattern": "^5.9.0",
"zod": "^3.25.76"
}
+17 -19
View File
@@ -1,19 +1,17 @@
export {
Body,
Button,
Column,
Container,
Font,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Row,
render,
Section,
Tailwind,
Text,
} from 'react-email';
export { Body } from '@react-email/body';
export { Button } from '@react-email/button';
export { Column } from '@react-email/column';
export { Container } from '@react-email/container';
export { Font } from '@react-email/font';
export { Head } from '@react-email/head';
export { Heading } from '@react-email/heading';
export { Hr } from '@react-email/hr';
export { Html } from '@react-email/html';
export { Img } from '@react-email/img';
export { Link } from '@react-email/link';
export { Preview } from '@react-email/preview';
export { render } from '@react-email/render';
export { Row } from '@react-email/row';
export { Section } from '@react-email/section';
export { Tailwind } from '@react-email/tailwind';
export { Text } from '@react-email/text';
+20 -2
View File
@@ -19,9 +19,27 @@
"dependencies": {
"@documenso/nodemailer-resend": "5.0.0",
"@documenso/tailwind-config": "*",
"@react-email/render": "2.1.0",
"@react-email/body": "0.2.0",
"@react-email/button": "0.2.0",
"@react-email/code-block": "0.2.0",
"@react-email/code-inline": "0.0.5",
"@react-email/column": "0.0.13",
"@react-email/container": "0.0.15",
"@react-email/font": "0.0.9",
"@react-email/head": "0.0.12",
"@react-email/heading": "0.0.15",
"@react-email/hr": "0.0.11",
"@react-email/html": "0.0.11",
"@react-email/img": "0.0.11",
"@react-email/link": "0.0.12",
"@react-email/preview": "0.0.13",
"@react-email/render": "2.0.0",
"@react-email/row": "0.0.12",
"@react-email/section": "0.0.16",
"@react-email/tailwind": "^2.0.1",
"@react-email/text": "0.1.5",
"nodemailer": "^9.0.0",
"react-email": "^6.9.0",
"react-email": "^5.0.6",
"resend": "^6.5.2"
},
"devDependencies": {
@@ -8,7 +8,7 @@ type SaveRequest<T, R> = {
export const useAutoSave = <T, R = void>(onSave: (data: T) => Promise<R>, options: { delay?: number } = {}) => {
const { delay = 2000 } = options;
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const saveTimeoutRef = useRef<NodeJS.Timeout>();
const saveQueueRef = useRef<SaveRequest<T, R>[]>([]);
const isProcessingRef = useRef(false);
@@ -15,7 +15,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useLingui } from '@lingui/react/macro';
import { EnvelopeType, Prisma, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import type React from 'react';
import { createContext, useCallback, useContext, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import type { TDocumentEmailSettings } from '../../types/document-email';
@@ -107,39 +107,7 @@ export const EnvelopeEditorProvider = ({
const [_searchParams, setSearchParams] = useSearchParams();
/**
* The envelope is kept in a ref-backed external store instead of useState so
* that async consumers (debounced autosave callbacks, flushAutosave, resetForms)
* can synchronously read the latest value via `getEnvelope`.
*
* React subscribes to the store through useSyncExternalStore, keeping renders in
* sync without maintaining a separate copy of the state.
*/
const envelopeStoreRef = useRef(initialEnvelope);
const envelopeStoreSubscribersRef = useRef(new Set<() => void>());
const subscribeToEnvelopeStore = useCallback((onStoreChange: () => void) => {
envelopeStoreSubscribersRef.current.add(onStoreChange);
return () => {
envelopeStoreSubscribersRef.current.delete(onStoreChange);
};
}, []);
const getEnvelope = useCallback(() => envelopeStoreRef.current, []);
const setEnvelope = useCallback((action: React.SetStateAction<TEditorEnvelope>) => {
const next = typeof action === 'function' ? action(envelopeStoreRef.current) : action;
envelopeStoreRef.current = next;
for (const onStoreChange of envelopeStoreSubscribersRef.current) {
onStoreChange();
}
}, []);
const envelope = useSyncExternalStore(subscribeToEnvelopeStore, getEnvelope, getEnvelope);
const [envelope, _setEnvelope] = useState(initialEnvelope);
const [autosaveError, setAutosaveError] = useState<boolean>(false);
const isCscMode = IS_INSTANCE_CSC_MODE();
@@ -167,6 +135,8 @@ export const EnvelopeEditorProvider = ({
};
}, [isCscMode, providedEditorConfig]);
const envelopeRef = useRef(initialEnvelope);
const externalFlushCallbacksRef = useRef<Map<string, () => Promise<void>>>(new Map());
const pendingMutationsRef = useRef<Set<Promise<unknown>>>(new Set());
@@ -186,6 +156,14 @@ export const EnvelopeEditorProvider = ({
});
}, []);
const setEnvelope: typeof _setEnvelope = (action) => {
_setEnvelope((prev) => {
const next = typeof action === 'function' ? action(prev) : action;
envelopeRef.current = next;
return next;
});
};
const isEmbedded = editorConfig.embedded !== undefined;
const editorFields = useEditorFields({
@@ -214,18 +192,16 @@ export const EnvelopeEditorProvider = ({
try {
let recipients: TEditorEnvelope['recipients'] = [];
const currentEnvelope = getEnvelope();
if (!isEmbedded) {
const response = await setRecipientsMutation.mutateAsync({
envelopeId: currentEnvelope.id,
envelopeType: currentEnvelope.type,
envelopeId: envelope.id,
envelopeType: envelope.type,
recipients: localRecipients,
});
recipients = response.data;
} else {
recipients = mapLocalRecipientsToRecipients({ envelope: currentEnvelope, localRecipients });
recipients = mapLocalRecipientsToRecipients({ envelope, localRecipients });
}
setEnvelope((prev) => ({
@@ -235,7 +211,9 @@ export const EnvelopeEditorProvider = ({
}));
// Reset the local fields to ensure deleted recipient fields are removed.
editorFields.resetForm(getEnvelope().fields);
editorFields.resetForm(
envelope.fields.filter((field) => recipients.some((recipient) => recipient.id === field.recipientId)),
);
setAutosaveError(false);
} catch (err) {
@@ -270,18 +248,16 @@ export const EnvelopeEditorProvider = ({
try {
let fields: TSetEnvelopeFieldsResponse['data'] = [];
const currentEnvelope = getEnvelope();
if (!isEmbedded) {
const response = await setFieldsMutation.mutateAsync({
envelopeId: currentEnvelope.id,
envelopeType: currentEnvelope.type,
envelopeId: envelope.id,
envelopeType: envelope.type,
fields: localFields,
});
fields = response.data;
} else {
fields = mapLocalFieldsToFields({ envelope: currentEnvelope, localFields });
fields = mapLocalFieldsToFields({ envelope, localFields });
}
setEnvelope((prev) => ({
@@ -333,7 +309,7 @@ export const EnvelopeEditorProvider = ({
try {
const response = !isEmbedded
? await updateEnvelopeMutation.mutateAsync({
envelopeId: getEnvelope().id,
envelopeId: envelope.id,
data,
meta,
})
@@ -491,14 +467,12 @@ export const EnvelopeEditorProvider = ({
};
const resetForms = () => {
const currentEnvelope = getEnvelope();
editorRecipients.resetForm({
recipients: currentEnvelope.recipients,
documentMeta: currentEnvelope.documentMeta,
recipients: envelopeRef.current.recipients,
documentMeta: envelopeRef.current.documentMeta,
});
editorFields.resetForm(currentEnvelope.fields);
editorFields.resetForm(envelopeRef.current.fields);
};
const flushAutosave = async (): Promise<TEditorEnvelope> => {
@@ -514,7 +488,7 @@ export const EnvelopeEditorProvider = ({
await Promise.allSettled(Array.from(pendingMutationsRef.current));
}
return getEnvelope();
return envelopeRef.current;
};
return (
+2 -2
View File
@@ -29,7 +29,6 @@
"@documenso/email": "*",
"@documenso/prisma": "*",
"@documenso/signing": "*",
"@documenso/skia-canvas": "^3.0.8-documenso.3",
"@lingui/core": "^5.6.0",
"@lingui/macro": "^5.6.0",
"@lingui/react": "^5.6.0",
@@ -65,9 +64,10 @@
"postcss-selector-parser": "^7.1.4",
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
"react": "^19.2.7",
"react": "^18",
"remeda": "^2.32.0",
"sharp": "0.34.5",
"skia-canvas": "^3.0.8",
"stripe": "^12.18.0",
"ts-pattern": "^5.9.0",
"zod": "^3.25.76"
+1 -1
View File
@@ -1,6 +1,6 @@
import { Canvas, Image, Path2D } from '@documenso/skia-canvas';
import pMap from 'p-map';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import { Canvas, Image, Path2D } from 'skia-canvas';
// @ts-expect-error napi-rs/canvas satisfies the requirements
globalThis.Path2D = Path2D;
@@ -57,10 +57,7 @@ export const UNSAFE_createEnvelopeItems = async ({
flattenForm: envelope.type !== 'TEMPLATE',
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
envelopeId: envelope.id,
fileName: file.name,
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { documentData } = await putPdfFileServerSide({
name: file.name,
@@ -85,10 +85,7 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
flattenForm: envelope.type !== 'TEMPLATE',
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
envelopeId: envelope.id,
fileName: data.file.name,
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
// Upload the new PDF and get a new DocumentData record.
const { documentData: newDocumentData, filePageCount } = await putPdfFileServerSide({
@@ -2,9 +2,8 @@
* !: This is a workaround to fix the memory leak in the skia-canvas library.
* !: Internals are ported from the original `konva/skia-backend.js` file.
*/
import { Canvas, DOMMatrix, Image, Path2D } from '@documenso/skia-canvas';
import { Konva } from 'konva/lib/_CoreInternals';
import { Canvas, DOMMatrix, Image, Path2D } from 'skia-canvas';
// @ts-expect-error skia-canvas satisfies the requirements
global.DOMMatrix = DOMMatrix;
@@ -38,6 +37,6 @@ Konva.Util.createImageElement = () => {
return node as unknown as HTMLImageElement;
};
Konva._renderBackend = '@documenso/skia-canvas';
Konva._renderBackend = 'skia-canvas';
export default Konva;
@@ -1,15 +1,8 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { type TFieldAndMeta, ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { logger } from '@documenso/lib/utils/logger';
import { PDF, rgb } from '@libpdf/core';
import type { FieldType, Recipient } from '@prisma/client';
import {
parseFieldMetaFromPlaceholder,
parseFieldTypeFromPlaceholder,
parsePlaceholderData,
parseRawFieldMetaFromPlaceholder,
} from './helpers';
import { parseFieldMetaFromPlaceholder, parseFieldTypeFromPlaceholder } from './helpers';
const PLACEHOLDER_REGEX = /\{\{([^}]+)\}\}/g;
const DEFAULT_FIELD_HEIGHT_PERCENT = 2;
@@ -68,15 +61,7 @@ export type FieldToCreate = TFieldAndMeta & {
height: number;
};
type ExtractPlaceholdersLogContext = {
envelopeId?: string;
fileName?: string;
};
export const extractPlaceholdersFromPDF = async (
pdf: Buffer,
logContext?: ExtractPlaceholdersLogContext,
): Promise<PlaceholderInfo[]> => {
export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<PlaceholderInfo[]> => {
const pdfDoc = await PDF.load(new Uint8Array(pdf));
const placeholders: PlaceholderInfo[] = [];
@@ -100,7 +85,7 @@ export const extractPlaceholdersFromPDF = async (
continue;
}
const placeholderData = parsePlaceholderData(innerMatch[1]);
const placeholderData = innerMatch[1].split(',').map((property) => property.trim());
const [fieldTypeString, recipientOrMeta, ...fieldMetaData] = placeholderData;
let fieldType: FieldType;
@@ -124,51 +109,14 @@ export const extractPlaceholdersFromPDF = async (
const recipient = recipientOrMeta;
/*
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;
const rawFieldMeta = Object.fromEntries(fieldMetaData.map((property) => property.split('=')));
try {
const rawFieldMeta = parseRawFieldMetaFromPlaceholder(fieldMetaData);
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
const parsedFieldAndMeta = ZEnvelopeFieldAndMetaSchema.safeParse({
type: fieldType,
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;
}
const fieldAndMeta: TFieldAndMeta = ZEnvelopeFieldAndMetaSchema.parse({
type: fieldType,
fieldMeta: parsedFieldMeta,
});
/*
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 (
pdf: Buffer,
logContext?: ExtractPlaceholdersLogContext,
): Promise<{ cleanedPdf: Buffer; placeholders: PlaceholderInfo[] }> => {
const placeholders = await extractPlaceholdersFromPDF(pdf, logContext);
const placeholders = await extractPlaceholdersFromPDF(pdf);
if (placeholders.length === 0) {
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' }],
});
});
});
});
+6 -312
View File
@@ -1,8 +1,8 @@
import path from 'node:path';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FontLibrary } from '@documenso/skia-canvas';
import type { Recipient } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { FontLibrary } from 'skia-canvas';
import { match } from 'ts-pattern';
/**
@@ -45,134 +45,6 @@ type RecipientPlaceholderInfo = {
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.
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.
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 parsedFieldMeta: Record<string, unknown> = {
const parsedFieldMeta: Record<string, boolean | number | string> = {
type: fieldTypeString,
};
@@ -395,39 +104,24 @@ export const parseFieldMetaFromPlaceholder = (
const rawFieldMetaEntries = Object.entries(rawFieldMeta);
for (const [property, value] of rawFieldMetaEntries) {
if (shouldSkipGenericFieldMetaParsing(property, fieldType)) {
continue;
}
const unescapedValue = unescapePlaceholderValue(value);
if (property === 'readOnly' || property === 'required') {
parsedFieldMeta[property] = unescapedValue.toLowerCase() === 'true';
} else if (property === 'validationRule' && fieldType === FieldType.CHECKBOX) {
parsedFieldMeta[property] = parseCheckboxValidationRule(unescapedValue);
parsedFieldMeta[property] = value === 'true';
} else if (
property === 'fontSize' ||
property === 'maxValue' ||
property === 'minValue' ||
property === 'characterLimit' ||
property === 'validationLength'
property === 'characterLimit'
) {
const numValue = Number(unescapedValue);
const numValue = Number(value);
if (!Number.isNaN(numValue)) {
parsedFieldMeta[property] = numValue;
}
} 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;
};
@@ -2,8 +2,8 @@
import '../konva/skia-backend';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import type { Canvas } from '@documenso/skia-canvas';
import Konva from 'konva';
import type { Canvas } from 'skia-canvas';
import { renderField } from '../../universal/field-renderer/render-field';
import { ensureFontLibrary } from './helpers';
@@ -1,16 +1,14 @@
// sort-imports-ignore
import '../konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import type { Canvas } from '@documenso/skia-canvas';
import { Image as SkiaImage } from '@documenso/skia-canvas';
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { DocumentMeta, Envelope, RecipientRole } from '@prisma/client';
import Konva from 'konva';
import 'konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import type { DateTimeFormatOptions } from 'luxon';
import { DateTime } from 'luxon';
import type { Canvas } from 'skia-canvas';
import { Image as SkiaImage } from 'skia-canvas';
import { match, P } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
@@ -1,16 +1,14 @@
// sort-imports-ignore
import '../konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import type { Canvas } from '@documenso/skia-canvas';
import { Image as SkiaImage } from '@documenso/skia-canvas';
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { Field, RecipientRole, Signature } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import Konva from 'konva';
import 'konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import { DateTime } from 'luxon';
import type { Canvas } from 'skia-canvas';
import { Image as SkiaImage } from 'skia-canvas';
import { UAParser } from 'ua-parser-js';
import { renderSVG } from 'uqr';
@@ -14,7 +14,7 @@ let SkiaImage: any;
void (async () => {
if (typeof window === 'undefined') {
const mod = await import('@documenso/skia-canvas');
const mod = await import('skia-canvas');
SkiaImage = mod.Image;
}
})();
+3 -3
View File
@@ -12,9 +12,9 @@
"@documenso/prisma": "*",
"@simplewebauthn/server": "^13.2.2",
"@tanstack/react-query": "5.90.10",
"@trpc/client": "11.17.0",
"@trpc/react-query": "11.17.0",
"@trpc/server": "11.17.0",
"@trpc/client": "11.8.1",
"@trpc/react-query": "11.8.1",
"@trpc/server": "11.8.1",
"@ts-rest/core": "^3.52.1",
"formidable": "^3.5.4",
"luxon": "^3.7.2",
@@ -8,6 +8,7 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { EnvelopeType } from '@prisma/client';
import type { Logger } from 'pino';
import { match, P } from 'ts-pattern';
import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf';
import { authenticatedProcedure } from '../trpc';
@@ -124,9 +125,7 @@ export const createEnvelopeRouteCaller = async ({
});
// Todo: Embeds - Might need to add this for client-side embeds in the future.
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
fileName: file.name,
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { documentData } = await putPdfFileServerSide({
name: file.name,
@@ -150,19 +149,11 @@ export const createEnvelopeRouteCaller = async ({
accessAuth: recipient.accessAuth,
actionAuth: recipient.actionAuth,
fields: recipient.fields?.map((field) => {
let documentDataId: string | undefined;
if (typeof field.identifier === 'string') {
documentDataId = envelopeItems.find((item) => item.title === field.identifier)?.documentDataId;
}
if (typeof field.identifier === 'number') {
documentDataId = envelopeItems.at(field.identifier)?.documentDataId;
}
if (field.identifier === undefined) {
documentDataId = envelopeItems.at(0)?.documentDataId;
}
const documentDataId = match(field.identifier)
.with(P.string, (title) => envelopeItems.find((item) => item.title === title)?.documentDataId)
.with(P.number, (index) => envelopeItems.at(index)?.documentDataId)
.with(undefined, () => envelopeItems.at(0)?.documentDataId)
.exhaustive();
if (!documentDataId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
@@ -39,8 +39,8 @@ export const signEnvelopeFieldRoute = procedure
const field = await prisma.field.findFirst({
where: {
id: fieldId,
recipient: {
...(recipient.role === RecipientRole.ASSISTANT
recipient:
recipient.role === RecipientRole.ASSISTANT
? {
signingStatus: {
not: SigningStatus.SIGNED,
@@ -52,8 +52,7 @@ export const signEnvelopeFieldRoute = procedure
}
: {
id: recipient.id,
}),
},
},
},
include: {
envelope: {
@@ -6,6 +6,7 @@ import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { EnvelopeType } from '@prisma/client';
import { match, P } from 'ts-pattern';
import { authenticatedProcedure } from '../trpc';
import { useEnvelopeMeta, ZUseEnvelopeRequestSchema, ZUseEnvelopeResponseSchema } from './use-envelope.types';
@@ -87,20 +88,11 @@ export const useEnvelopeRoute = authenticatedProcedure
// Map custom document data using identifiers
const customDocumentDataMapped = customDocumentData?.map((mapping) => {
let documentDataId: string | undefined;
// Find the uploaded file by identifier
if (typeof mapping.identifier === 'string') {
documentDataId = uploadedFiles.find((file) => file.name === mapping.identifier)?.documentDataId;
}
if (typeof mapping.identifier === 'number') {
documentDataId = uploadedFiles.at(mapping.identifier)?.documentDataId;
}
if (mapping.identifier === undefined) {
documentDataId = uploadedFiles.at(0)?.documentDataId;
}
const documentDataId = match(mapping.identifier)
.with(P.string, (name) => uploadedFiles.find((file) => file.name === name)?.documentDataId)
.with(P.number, (index) => uploadedFiles.at(index)?.documentDataId)
.exhaustive();
if (!documentDataId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
+1 -1
View File
@@ -35,7 +35,7 @@ export const SigningCard3D = ({ className, name, signature, signingCelebrationIm
const [trackMouse, setTrackMouse] = useState(false);
const timeoutRef = useRef<number | undefined>(undefined);
const timeoutRef = useRef<number | undefined>();
const cardX = useMotionValue(0);
const cardY = useMotionValue(0);
+34 -7
View File
@@ -18,32 +18,59 @@
"@documenso/tailwind-config": "*",
"@documenso/tsconfig": "*",
"@types/luxon": "^3.7.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"react": "^19.2.7",
"@types/react": "18.3.27",
"@types/react-dom": "^18",
"react": "^18",
"typescript": "5.6.2"
},
"dependencies": {
"@documenso/lib": "*",
"@hello-pangea/dnd": "^18.0.1",
"@hello-pangea/dnd": "^16.6.0",
"@hookform/resolvers": "^3",
"@lingui/macro": "^5.6.0",
"@lingui/react": "^5.6.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@scure/base": "^1.2.6",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^1.2.1",
"cmdk": "^1.1.1",
"cmdk": "^0.2.1",
"colord": "^2.9.3",
"framer-motion": "^12.43.0",
"lucide-react": "^0.554.0",
"luxon": "^3.7.2",
"pdfjs-dist": "5.4.296",
"perfect-freehand": "^1.2.2",
"react": "^19.2.7",
"react": "^18",
"react-colorful": "^5.6.1",
"react-day-picker": "^8.10.1",
"react-dom": "^19.2.7",
"react-dom": "^18",
"react-hook-form": "^7.66.1",
"react-rnd": "^10.5.2",
"remeda": "^2.32.0",
+1 -3
View File
@@ -145,9 +145,7 @@ const CommandItem = React.forwardRef<
<CommandPrimitive.Item
ref={ref}
className={cn(
// cmdk 1.x always renders data-disabled="true|false", so the variant must
// check the value (bare data-[disabled] matches attribute presence).
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
@@ -103,10 +103,15 @@ export const FieldContent = ({ field, documentMeta }: FieldIconProps) => {
) {
return (
<div className="flex flex-col gap-y-2 py-0.5">
<RadioGroup value={field.customText ?? ''} className="gap-y-1">
<RadioGroup className="gap-y-1">
{field.fieldMeta.values.map((item, index) => (
<div key={index} className="flex items-center">
<RadioGroupItem className="pointer-events-none h-3 w-3" value={item.value} id={`option-${index}`} />
<RadioGroupItem
className="pointer-events-none h-3 w-3"
value={item.value}
id={`option-${index}`}
checked={item.value === field.customText}
/>
{item.value && (
<Label htmlFor={`option-${index}`} className="ml-1.5 font-normal text-foreground text-xs">
{item.value}
@@ -1,6 +1,5 @@
import type { RecipientRole } from '@prisma/client';
import { BadgeCheck, Copy, Eye, PencilLine, User } from 'lucide-react';
import type { JSX } from 'react';
export const ROLE_ICONS: Record<RecipientRole, JSX.Element> = {
SIGNER: <PencilLine className="h-4 w-4" />,
@@ -13,7 +13,7 @@ import { getSvgPathFromStroke } from './helper';
import { Point } from './point';
import { SignaturePadColorPicker } from './signature-pad-color-picker';
const checkSignatureValidity = (element: RefObject<HTMLCanvasElement | null>) => {
const checkSignatureValidity = (element: RefObject<HTMLCanvasElement>) => {
if (!element.current) {
return false;
}