Compare commits

..
Author SHA1 Message Date
ephraimduncan 7841c93478 fix: keep prefilled field values editable on click 2026-09-26 01:06:20 +00:00
11 changed files with 184 additions and 105 deletions
@@ -122,7 +122,7 @@ export const EnvelopeItemEditDialog = ({
toast({
title: t`Failed to read file`,
description: t`The file is not a valid PDF or is password protected.`,
description: t`The file is not a valid PDF.`,
variant: 'destructive',
});
}
@@ -20,10 +20,15 @@ import { z } from 'zod';
export type SignFieldNumberDialogProps = {
fieldMeta: TNumberFieldMeta;
/**
* The current value of the field. When set, an empty submit is allowed so the signer can clear the field.
*/
defaultValue?: string;
};
export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps, string | null>(
({ call, fieldMeta }) => {
({ call, fieldMeta, defaultValue }) => {
const { t } = useLingui();
// Needs to be inside dialog for translation purposes.
@@ -83,14 +88,16 @@ export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps,
});
};
const numberSchema = createNumberFieldSchema(fieldMeta);
const ZSignFieldNumberFormSchema = z.object({
number: createNumberFieldSchema(fieldMeta),
number: defaultValue ? numberSchema.or(z.literal('')) : numberSchema,
});
const form = useForm<z.infer<typeof ZSignFieldNumberFormSchema>>({
resolver: zodResolver(ZSignFieldNumberFormSchema),
defaultValues: {
number: undefined,
number: defaultValue,
},
});
@@ -26,75 +26,84 @@ type TSignFieldTextFormSchema = z.infer<typeof ZSignFieldTextFormSchema>;
export type SignFieldTextDialogProps = {
fieldMeta?: TTextFieldMeta;
/**
* The current value of the field. When set, an empty submit is allowed so the signer can clear the field.
*/
defaultValue?: string;
};
export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, string | null>(({ call, fieldMeta }) => {
const { t } = useLingui();
export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, string | null>(
({ call, fieldMeta, defaultValue }) => {
const { t } = useLingui();
const form = useForm<TSignFieldTextFormSchema>({
resolver: zodResolver(ZSignFieldTextFormSchema),
defaultValues: {
text: '',
},
});
const form = useForm<TSignFieldTextFormSchema>({
resolver: zodResolver(defaultValue ? z.object({ text: z.string() }) : ZSignFieldTextFormSchema),
defaultValues: {
text: defaultValue ?? '',
},
});
return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{fieldMeta?.label || <Trans>Enter Text</Trans>}</DialogTitle>
return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{fieldMeta?.label || <Trans>Enter Text</Trans>}</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Please enter a value</Trans>
</DialogDescription>
</DialogHeader>
<DialogDescription className="mt-4">
<Trans>Please enter a value</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => call.end(data.text))}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="text"
render={({ field, fieldState }) => (
<FormItem>
<FormControl>
<Textarea
id="custom-text"
placeholder={fieldMeta?.placeholder ?? t`Enter your text here`}
className={cn('w-full rounded-md', {
'border-2 border-red-300 text-left ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
fieldState.error,
})}
{...field}
/>
</FormControl>
<FormMessage />
{fieldMeta?.characterLimit !== undefined && fieldMeta?.characterLimit > 0 && !fieldState.error && (
<div className="text-muted-foreground text-sm">
<Plural
value={fieldMeta?.characterLimit - (field.value?.length ?? 0)}
one="# character remaining"
other="# characters remaining"
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => call.end(data.text))}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="text"
render={({ field, fieldState }) => (
<FormItem>
<FormControl>
<Textarea
id="custom-text"
placeholder={fieldMeta?.placeholder ?? t`Enter your text here`}
className={cn('w-full rounded-md', {
'border-2 border-red-300 text-left ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
fieldState.error,
})}
{...field}
/>
</div>
)}
</FormItem>
)}
/>
</FormControl>
<FormMessage />
{fieldMeta?.characterLimit !== undefined &&
fieldMeta?.characterLimit > 0 &&
!fieldState.error && (
<div className="text-muted-foreground text-sm">
<Plural
value={fieldMeta?.characterLimit - (field.value?.length ?? 0)}
one="# character remaining"
other="# characters remaining"
/>
</div>
)}
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
<Trans>Cancel</Trans>
</Button>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit">
<Trans>Enter</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
});
<Button type="submit">
<Trans>Enter</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
},
);
@@ -21,27 +21,22 @@ export const handleNumberFieldClick = async (
});
}
if (field.inserted) {
return {
type: FieldType.NUMBER,
value: null,
};
}
let numberToInsert = number;
if (!numberToInsert) {
numberToInsert = await SignFieldNumberDialog.call({
fieldMeta: field.fieldMeta,
defaultValue: field.inserted ? field.customText : undefined,
});
}
if (!numberToInsert) {
if (numberToInsert === null) {
return null;
}
// An empty value clears the field.
return {
type: FieldType.NUMBER,
value: numberToInsert,
value: numberToInsert || null,
};
};
@@ -21,27 +21,22 @@ export const handleTextFieldClick = async (
});
}
if (field.inserted) {
return {
type: FieldType.TEXT,
value: null,
};
}
let textToInsert = text;
if (!textToInsert) {
textToInsert = await SignFieldTextDialog.call({
fieldMeta: field.fieldMeta,
defaultValue: field.inserted ? field.customText : undefined,
});
}
if (!textToInsert) {
if (textToInsert === null) {
return null;
}
// An empty value clears the field.
return {
type: FieldType.TEXT,
value: textToInsert,
value: textToInsert || null,
};
};
+1 -1
View File
@@ -92,7 +92,7 @@ export const getUploadErrorMessage = (code: string): ToastMessageDescriptor => {
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with('INVALID_DOCUMENT_FILE', () => ({
title: msg`Error`,
description: msg`The file is not a valid PDF or is password protected.`,
description: msg`You cannot upload encrypted PDFs.`,
}))
.with(AppErrorCode.LIMIT_EXCEEDED, () => ({
title: msg`Error`,
+1 -1
View File
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.19.0"
"version": "2.18.0"
}
+7 -7
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "2.19.0",
"version": "2.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "2.19.0",
"version": "2.18.0",
"hasInstallScript": true,
"workspaces": [
"apps/*",
@@ -15,7 +15,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "5.0.48",
"@documenso/prisma": "*",
"@libpdf/core": "^0.5.1",
"@libpdf/core": "^0.4.2",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@marsidev/react-turnstile": "^1.5.0",
@@ -193,7 +193,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "2.19.0",
"version": "2.18.0",
"dependencies": {
"@cantoo/pdf-lib": "^2.5.3",
"@documenso/api": "*",
@@ -4480,9 +4480,9 @@
"license": "MIT"
},
"node_modules/@libpdf/core": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.5.1.tgz",
"integrity": "sha512-q+y4AEk9ngqyC1pdX/hNScnfh0ROr4vSiqX4C9CmuBzhtuVukbfTesXCaGvHZ3pksi8AJYDPGQ/0HzSeHZfi3A==",
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.2.tgz",
"integrity": "sha512-lbkIqLDZCCxjLpiC+8/Xvaru/ME7iVVoihl9tLqbp/CDUWZNF0q3u7s2tBJB9wRW/SzUWID6YPFvBWws770hrQ==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^2.2.0",
+2 -2
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.19.0",
"version": "2.18.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -106,7 +106,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "5.0.48",
"@documenso/prisma": "*",
"@libpdf/core": "^0.5.1",
"@libpdf/core": "^0.4.2",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@prisma/extension-read-replicas": "^0.4.1",
@@ -5,7 +5,7 @@ import { expect, test } from '@playwright/test';
import { DocumentStatus, FieldType } from '@prisma/client';
import { DateTime } from 'luxon';
import { apiSeedPendingDocument } from '../fixtures/api-seeds';
import { apiSeedPendingDocument, apiSeedTemplate } from '../fixtures/api-seeds';
const PDF_PAGE_SELECTOR = 'img[data-page-number]';
@@ -253,4 +253,81 @@ test.describe('V2 envelope field insertion during signing', () => {
expect(dbEnvelope.status).toBe(DocumentStatus.COMPLETED);
}).toPass();
});
test('clicking a prefilled text field opens it for editing instead of clearing it', async ({ page, request }) => {
const { envelope: template, token: apiToken } = await apiSeedTemplate(request, {
recipients: [{ email: 'prefill-signer@test.documenso.com', name: 'Prefill Signer' }],
fieldsPerRecipient: [
[
{
type: FieldType.TEXT,
page: 1,
positionX: 10,
positionY: 10,
width: 20,
height: 5,
fieldMeta: { type: 'text', label: 'Mailing Address' },
},
{ type: FieldType.SIGNATURE, page: 1, positionX: 10, positionY: 30, width: 20, height: 5 },
],
],
});
const [templateRecipient] = template.recipients;
const templateField = template.fields.find((field) => field.type === FieldType.TEXT);
if (!templateField) {
throw new Error('Text field not found');
}
const formData = new FormData();
formData.append(
'payload',
JSON.stringify({
envelopeId: template.id,
recipients: [{ id: templateRecipient.id, email: templateRecipient.email, name: templateRecipient.name }],
prefillFields: [{ id: templateField.id, type: 'text', label: 'Mailing Address', value: '144 Plymouth' }],
distributeDocument: true,
}),
);
const useResponse = await request.post('/api/v2/envelope/use', {
headers: { Authorization: `Bearer ${apiToken}` },
multipart: formData,
});
expect(useResponse.ok(), await useResponse.text()).toBeTruthy();
const { id: envelopeId } = await useResponse.json();
const recipient = await prisma.recipient.findFirstOrThrow({ where: { envelopeId } });
await page.goto(`/sign/${recipient.token}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
const canvas = page.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible({ timeout: 30_000 });
const canvasBox = await canvas.boundingBox();
if (!canvasBox) {
throw new Error('Canvas bounding box not found');
}
await canvas.click({ position: { x: 0.2 * canvasBox.width, y: 0.125 * canvasBox.height } });
const textInput = page.getByRole('dialog').getByRole('textbox');
await expect(textInput).toHaveValue('144 Plymouth');
await textInput.fill('145 Plymouth');
await page.getByRole('button', { name: 'Enter' }).click();
await expect(async () => {
const field = await prisma.field.findFirstOrThrow({ where: { envelopeId, type: FieldType.TEXT } });
expect(field.inserted).toBe(true);
expect(field.customText).toBe('145 Plymouth');
}).toPass();
});
});
@@ -9,18 +9,14 @@ export const normalizePdf = async (pdf: Buffer, options: { flattenForm?: boolean
console.error(`PDF normalization error: ${e.message}`);
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'The document is not a valid PDF or is password protected',
message: 'The document is not a valid PDF',
});
});
if (pdfDoc.isEncrypted) {
if (!pdfDoc.isAuthenticated) {
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'The document is password protected',
});
}
pdfDoc.removeProtection({ ignorePermissions: true });
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'The document is encrypted',
});
}
pdfDoc.flattenLayers();