fix: various envelope updates

This commit is contained in:
David Nguyen
2025-11-04 14:57:42 +11:00
parent c89ca83f44
commit 8663c8f883
31 changed files with 400 additions and 144 deletions

View File

@ -68,7 +68,7 @@ test.describe('API V2 Envelopes', () => {
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/create`, {
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenB}` },
multipart: formData,
});
@ -100,7 +100,7 @@ test.describe('API V2 Envelopes', () => {
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
}
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/create`, {
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenB}` },
multipart: formData,
});
@ -232,14 +232,14 @@ test.describe('API V2 Envelopes', () => {
}
// Should error since folder is not owned by the user.
const invalidRes = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/create`, {
const invalidRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenB}` },
multipart: formData,
});
expect(invalidRes.ok()).toBeFalsy();
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/create`, {
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
@ -378,21 +378,24 @@ test.describe('API V2 Envelopes', () => {
new File([alignmentPdf], 'field-font-alignment.pdf', { type: 'application/pdf' }),
);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/create`, {
const createEnvelopeRequest = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
const response: TCreateEnvelopeResponse = await res.json();
expect(createEnvelopeRequest.ok()).toBeTruthy();
expect(createEnvelopeRequest.status()).toBe(200);
const createdEnvelope: TGetEnvelopeResponse = await request
.get(`${baseUrl}/envelope/${response.id}`, {
headers: { Authorization: `Bearer ${tokenA}` },
})
.then(async (res) => await res.json());
const { id: createdEnvelopeId }: TCreateEnvelopeResponse = await createEnvelopeRequest.json();
const getEnvelopeRequest = await request.get(`${baseUrl}/envelope/${createdEnvelopeId}`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
const createdEnvelope: TGetEnvelopeResponse = await getEnvelopeRequest.json();
// Might as well testing access control here as well.
const unauthRequest = await request.get(`${baseUrl}/envelope/${response.id}`, {
const unauthRequest = await request.get(`${baseUrl}/envelope/${createdEnvelopeId}`, {
headers: { Authorization: `Bearer ${tokenB}` },
});

View File

@ -78,7 +78,6 @@ test.describe('Signing Certificate Tests', () => {
},
});
// Todo: Envelopes
const firstDocumentData = completedDocument.envelopeItems[0].documentData;
const completedDocumentData = await getFile(firstDocumentData);
@ -169,7 +168,6 @@ test.describe('Signing Certificate Tests', () => {
},
});
// Todo: Envelopes
const firstDocumentData = completedDocument.envelopeItems[0].documentData;
const completedDocumentData = await getFile(firstDocumentData);

View File

@ -165,10 +165,7 @@ export const useEditorFields = ({
const index = localFields.findIndex((field) => field.formId === formId);
if (index !== -1) {
update(index, {
...localFields[index],
id,
});
form.setValue(`fields.${index}.id`, id);
}
};

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import Konva from 'konva';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
@ -25,6 +25,8 @@ export function usePageRenderer(renderFunction: RenderFunction) {
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Konva.Layer | null>(null);
const [renderError, setRenderError] = useState<boolean>(false);
/**
* The raw viewport with no scaling. Basically the actual PDF size.
*/
@ -122,5 +124,7 @@ export function usePageRenderer(renderFunction: RenderFunction) {
unscaledViewport,
scaledViewport,
pageContext,
renderError,
setRenderError,
};
}

View File

@ -27,6 +27,9 @@ type EnvelopeRenderProviderValue = {
setCurrentEnvelopeItem: (envelopeItemId: string) => void;
fields: TEnvelope['fields'];
getRecipientColorKey: (recipientId: number) => TRecipientColor;
renderError: boolean;
setRenderError: (renderError: boolean) => void;
};
interface EnvelopeRenderProviderProps {
@ -74,6 +77,8 @@ export const EnvelopeRenderProvider = ({
const [currentItem, setItem] = useState<EnvelopeRenderItem | null>(null);
const [renderError, setRenderError] = useState<boolean>(false);
const envelopeItems = useMemo(
() => envelope.envelopeItems.sort((a, b) => a.order - b.order),
[envelope.envelopeItems],
@ -164,6 +169,8 @@ export const EnvelopeRenderProvider = ({
setCurrentEnvelopeItem,
fields: fields ?? [],
getRecipientColorKey,
renderError,
setRenderError,
}}
>
{children}

View File

@ -250,7 +250,7 @@ export const sendDocument = async ({
);
}
if (isValid) {
if (isValid && checkedIndices.length > 0) {
fieldsToAutoInsert.push({
fieldId,
customText: toCheckboxCustomText(checkedIndices),

View File

@ -153,6 +153,11 @@ export const createFieldHoverInteraction = ({
const hoverColor = RECIPIENT_COLOR_STYLES[options.color].baseRingHover;
fieldGroup.on('mouseover', () => {
const layer = fieldRect.getLayer();
if (!layer) {
return;
}
new Konva.Tween({
node: fieldRect,
duration: 0.3,
@ -161,6 +166,11 @@ export const createFieldHoverInteraction = ({
});
fieldGroup.on('mouseout', () => {
const layer = fieldRect.getLayer();
if (!layer) {
return;
}
new Konva.Tween({
node: fieldRect,
duration: 0.3,
@ -169,6 +179,11 @@ export const createFieldHoverInteraction = ({
});
fieldGroup.on('transformstart', () => {
const layer = fieldRect.getLayer();
if (!layer) {
return;
}
new Konva.Tween({
node: fieldRect,
duration: 0.3,
@ -177,6 +192,11 @@ export const createFieldHoverInteraction = ({
});
fieldGroup.on('transformend', () => {
const layer = fieldRect.getLayer();
if (!layer) {
return;
}
new Konva.Tween({
node: fieldRect,
duration: 0.3,

View File

@ -63,16 +63,15 @@ export const renderCheckboxFieldElement = (
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// Todo: Envelopes - check sorting more than 10
// arr.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const squares = fieldGroup
.find('.checkbox-square')
.sort((a, b) => a.id().localeCompare(b.id()));
.sort((a, b) => a.id().localeCompare(b.id(), undefined, { numeric: true }));
const checkmarks = fieldGroup
.find('.checkbox-checkmark')
.sort((a, b) => a.id().localeCompare(b.id()));
const text = fieldGroup.find('.checkbox-text').sort((a, b) => a.id().localeCompare(b.id()));
.sort((a, b) => a.id().localeCompare(b.id(), undefined, { numeric: true }));
const text = fieldGroup
.find('.checkbox-text')
.sort((a, b) => a.id().localeCompare(b.id(), undefined, { numeric: true }));
const groupedItems = squares.map((square, i) => ({
squareElement: square,

View File

@ -8,9 +8,9 @@ import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import type { TFieldMetaSchema } from '../../types/field-meta';
import { renderCheckboxFieldElement } from './render-checkbox-field';
import { renderDropdownFieldElement } from './render-dropdown-field';
import { renderGenericTextFieldElement } from './render-generic-text-field';
import { renderRadioFieldElement } from './render-radio-field';
import { renderSignatureFieldElement } from './render-signature-field';
import { renderTextFieldElement } from './render-text-field';
export const MIN_FIELD_HEIGHT_PX = 12;
export const MIN_FIELD_WIDTH_PX = 36;
@ -43,9 +43,9 @@ type RenderFieldOptions = {
*
* @default 'edit'
*
* - `edit` - The field is rendered in edit mode.
* - `sign` - The field is rendered in sign mode. No interactive elements.
* - `export` - The field is rendered in export mode. No backgrounds, interactive elements, etc.
* - `edit` - The field is rendered in editor page.
* - `sign` - The field is rendered for the signing page.
* - `export` - The field is rendered for exporting and sealing into the PDF. No backgrounds, interactive elements, etc.
*/
mode: 'edit' | 'sign' | 'export';
@ -76,10 +76,21 @@ export const renderField = ({
};
return match(field.type)
.with(FieldType.TEXT, () => renderTextFieldElement(field, options))
.with(
FieldType.INITIALS,
FieldType.NAME,
FieldType.EMAIL,
FieldType.DATE,
FieldType.TEXT,
FieldType.NUMBER,
() => renderGenericTextFieldElement(field, options),
)
.with(FieldType.CHECKBOX, () => renderCheckboxFieldElement(field, options))
.with(FieldType.RADIO, () => renderRadioFieldElement(field, options))
.with(FieldType.DROPDOWN, () => renderDropdownFieldElement(field, options))
.with(FieldType.SIGNATURE, () => renderSignatureFieldElement(field, options))
.otherwise(() => renderTextFieldElement(field, options)); // Todo: Envelopes
.with(FieldType.FREE_SIGNATURE, () => {
throw new Error('Free signature fields are not supported');
})
.exhaustive();
};

View File

@ -12,6 +12,8 @@ import {
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
import { calculateFieldPosition } from './field-renderer';
const DEFAULT_TEXT_ALIGN = 'left';
const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Text => {
const { pageWidth, pageHeight, mode = 'edit', pageLayer, translations } = options;
@ -31,8 +33,8 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
// Calculate text positioning based on alignment
const textX = 0;
const textY = 0;
let textAlign: 'left' | 'center' | 'right' = textMeta?.textAlign || 'left';
let textVerticalAlign: 'top' | 'middle' | 'bottom' = 'top';
let textAlign: 'left' | 'center' | 'right' = textMeta?.textAlign || DEFAULT_TEXT_ALIGN;
const textVerticalAlign: 'top' | 'middle' | 'bottom' = 'middle';
const textFontSize = textMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
const textPadding = 10;
@ -40,51 +42,33 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
// Handle edit mode.
if (mode === 'edit') {
textToRender = fieldTypeName;
textAlign = 'center';
textVerticalAlign = 'middle';
if (textMeta?.label) {
textToRender = textMeta.label;
} else if (textMeta?.text) {
if (textMeta?.text) {
textToRender = textMeta.text;
textAlign = textMeta.textAlign || 'center'; // Todo: Envelopes - What is the default
// Todo: Envelopes - Handle this on signatures
if (textMeta.characterLimit) {
textToRender = textToRender.slice(0, textMeta.characterLimit);
}
} else if (textMeta?.label) {
textToRender = textMeta.label;
} else {
// Show field name which is centered for the edit mode if no label/text is avaliable.
textToRender = fieldTypeName;
textAlign = 'center';
}
}
// Handle sign mode.
if (mode === 'sign' || mode === 'export') {
textToRender = fieldTypeName;
textAlign = 'center';
textVerticalAlign = 'middle';
if (textMeta?.label) {
textToRender = textMeta.label;
}
if (textMeta?.text) {
textToRender = textMeta.text;
textAlign = textMeta.textAlign || 'center'; // Todo: Envelopes - What is the default
// Todo: Envelopes - Handle this on signatures
if (textMeta.characterLimit) {
textToRender = textToRender.slice(0, textMeta.characterLimit);
if (!field.inserted) {
if (textMeta?.text) {
textToRender = textMeta.text;
} else if (textMeta?.label) {
textToRender = textMeta.label;
} else if (mode === 'sign') {
// Only show the field name in sign mode if no text/label is avaliable.
textToRender = fieldTypeName;
textAlign = 'center';
}
}
if (field.inserted) {
textToRender = field.customText;
textAlign = textMeta?.textAlign || 'center'; // Todo: Envelopes - What is the default
// Todo: Envelopes - Handle this on signatures
if (textMeta?.characterLimit) {
textToRender = textToRender.slice(0, textMeta.characterLimit);
}
}
}
@ -106,7 +90,7 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
return fieldText;
};
export const renderTextFieldElement = (
export const renderGenericTextFieldElement = (
field: FieldToRender,
options: RenderFieldElementOptions,
) => {

View File

@ -104,7 +104,6 @@ export const extractFieldInsertionValues = ({
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(fieldValue.value.toString(), numberFieldParsedMeta, true);
// Todo
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid number',
@ -127,7 +126,6 @@ export const extractFieldInsertionValues = ({
const parsedTextFieldMeta = ZTextFieldMeta.parse(field.fieldMeta);
const errors = validateTextField(fieldValue.value, parsedTextFieldMeta, true);
// Todo
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid email',
@ -189,7 +187,6 @@ export const extractFieldInsertionValues = ({
(sign) => sign.label === validationRule,
);
// Todo: Envelopes - Test this.
if (checkboxValidationRule) {
const isValid = validateCheckboxLength(
selectedValues.length,
@ -224,7 +221,6 @@ export const extractFieldInsertionValues = ({
const parsedDropdownFieldMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
const errors = validateDropdownField(fieldValue.value, parsedDropdownFieldMeta, true);
// Todo: Envelopes
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid dropdown value',

View File

@ -13,6 +13,7 @@ export const ZSetEnvelopeFieldsRequestSchema = z.object({
envelopeId: z.string(),
envelopeType: z.nativeEnum(EnvelopeType),
fields: z.array(
// Todo: Envelopes - Use strict schema for types + field meta.
z.object({
id: z
.number()

View File

@ -133,6 +133,49 @@ export const signEnvelopeFieldRoute = procedure
const insertionValues = extractFieldInsertionValues({ fieldValue, field, documentMeta });
// Early return for uninserting fields.
if (!insertionValues.inserted) {
return await prisma.$transaction(async (tx) => {
const updatedField = await tx.field.update({
where: {
id: field.id,
},
data: {
customText: '',
inserted: false,
},
});
await tx.signature.deleteMany({
where: {
fieldId: field.id,
},
});
if (recipient.role !== RecipientRole.ASSISTANT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
envelopeId: envelope.id,
user: {
name: recipient.name,
email: recipient.email,
},
requestMetadata: metadata.requestMetadata,
data: {
field: field.type,
fieldId: field.secondaryId,
},
}),
});
}
return {
signedField: updatedField,
};
});
}
const derivedRecipientActionAuth = await validateFieldAuth({
documentAuthOptions: envelope.authOptions,
recipient,

View File

@ -2,11 +2,14 @@ import React, { Suspense, lazy } from 'react';
import { type PDFDocumentProxy } from 'pdfjs-dist';
import type { PdfViewerRendererMode } from './pdf-viewer-konva';
export type LoadedPDFDocument = PDFDocumentProxy;
export type PDFViewerProps = {
className?: string;
onDocumentLoad?: () => void;
renderer: PdfViewerRendererMode;
[key: string]: unknown;
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;

View File

@ -1,6 +1,8 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import Konva from 'konva';
import { Loader } from 'lucide-react';
import { type PDFDocumentProxy } from 'pdfjs-dist';
@ -8,6 +10,7 @@ import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
export type LoadedPDFDocument = PDFDocumentProxy;
@ -29,10 +32,31 @@ const PDFLoader = () => (
</>
);
export type PdfViewerRendererMode = 'editor' | 'preview' | 'signing';
const RendererErrorMessages: Record<
PdfViewerRendererMode,
{ title: MessageDescriptor; description: MessageDescriptor }
> = {
editor: {
title: msg`Configuration Error`,
description: msg`There was an issue rendering some fields, please review the fields and try again.`,
},
preview: {
title: msg`Configuration Error`,
description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`,
},
signing: {
title: msg`Configuration Error`,
description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`,
},
};
export type PdfViewerKonvaProps = {
className?: string;
onDocumentLoad?: () => void;
customPageRenderer?: React.FunctionComponent;
renderer: PdfViewerRendererMode;
[key: string]: unknown;
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;
@ -40,11 +64,14 @@ export const PdfViewerKonva = ({
className,
onDocumentLoad,
customPageRenderer,
renderer,
...props
}: PdfViewerKonvaProps) => {
const { t } = useLingui();
const $el = useRef<HTMLDivElement>(null);
const { getPdfBuffer, currentEnvelopeItem } = useCurrentEnvelopeRender();
const { getPdfBuffer, currentEnvelopeItem, renderError } = useCurrentEnvelopeRender();
const [width, setWidth] = useState(0);
const [numPages, setNumPages] = useState(0);
@ -92,6 +119,13 @@ export const PdfViewerKonva = ({
return (
<div ref={$el} className={cn('w-full max-w-[800px]', className)} {...props}>
{renderError && (
<Alert variant="destructive" className="mb-4 max-w-[800px]">
<AlertTitle>{t(RendererErrorMessages[renderer].title)}</AlertTitle>
<AlertDescription>{t(RendererErrorMessages[renderer].description)}</AlertDescription>
</Alert>
)}
{envelopeItemFile && Konva ? (
<PDFDocument
file={envelopeItemFile}

View File

@ -66,7 +66,7 @@ export const DocumentDropzone = ({
const heading = {
document: msg`Upload Document`,
template: msg`Upload Template Document`,
envelope: msg`Envelope (beta)`,
envelope: msg`Upload Envelope`,
};
if (disabled && IS_BILLING_ENABLED()) {