mirror of
https://github.com/documenso/documenso.git
synced 2026-09-29 16:24:30 +10:00
fix: keep prefilled field values editable on click
This commit is contained in:
@@ -20,10 +20,15 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
export type SignFieldNumberDialogProps = {
|
export type SignFieldNumberDialogProps = {
|
||||||
fieldMeta: TNumberFieldMeta;
|
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>(
|
export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps, string | null>(
|
||||||
({ call, fieldMeta }) => {
|
({ call, fieldMeta, defaultValue }) => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
// Needs to be inside dialog for translation purposes.
|
// 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({
|
const ZSignFieldNumberFormSchema = z.object({
|
||||||
number: createNumberFieldSchema(fieldMeta),
|
number: defaultValue ? numberSchema.or(z.literal('')) : numberSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof ZSignFieldNumberFormSchema>>({
|
const form = useForm<z.infer<typeof ZSignFieldNumberFormSchema>>({
|
||||||
resolver: zodResolver(ZSignFieldNumberFormSchema),
|
resolver: zodResolver(ZSignFieldNumberFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
number: undefined,
|
number: defaultValue,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -26,75 +26,84 @@ type TSignFieldTextFormSchema = z.infer<typeof ZSignFieldTextFormSchema>;
|
|||||||
|
|
||||||
export type SignFieldTextDialogProps = {
|
export type SignFieldTextDialogProps = {
|
||||||
fieldMeta?: TTextFieldMeta;
|
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 }) => {
|
export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, string | null>(
|
||||||
const { t } = useLingui();
|
({ call, fieldMeta, defaultValue }) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
const form = useForm<TSignFieldTextFormSchema>({
|
const form = useForm<TSignFieldTextFormSchema>({
|
||||||
resolver: zodResolver(ZSignFieldTextFormSchema),
|
resolver: zodResolver(defaultValue ? z.object({ text: z.string() }) : ZSignFieldTextFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
text: '',
|
text: defaultValue ?? '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{fieldMeta?.label || <Trans>Enter Text</Trans>}</DialogTitle>
|
<DialogTitle>{fieldMeta?.label || <Trans>Enter Text</Trans>}</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
<DialogDescription className="mt-4">
|
||||||
<Trans>Please enter a value</Trans>
|
<Trans>Please enter a value</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit((data) => call.end(data.text))}>
|
<form onSubmit={form.handleSubmit((data) => call.end(data.text))}>
|
||||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="text"
|
name="text"
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="custom-text"
|
id="custom-text"
|
||||||
placeholder={fieldMeta?.placeholder ?? t`Enter your text here`}
|
placeholder={fieldMeta?.placeholder ?? t`Enter your text here`}
|
||||||
className={cn('w-full rounded-md', {
|
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':
|
'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,
|
fieldState.error,
|
||||||
})}
|
})}
|
||||||
{...field}
|
{...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"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormControl>
|
||||||
)}
|
<FormMessage />
|
||||||
</FormItem>
|
{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>
|
<DialogFooter>
|
||||||
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
|
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
|
||||||
<Trans>Cancel</Trans>
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="submit">
|
<Button type="submit">
|
||||||
<Trans>Enter</Trans>
|
<Trans>Enter</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -21,27 +21,22 @@ export const handleNumberFieldClick = async (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (field.inserted) {
|
|
||||||
return {
|
|
||||||
type: FieldType.NUMBER,
|
|
||||||
value: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let numberToInsert = number;
|
let numberToInsert = number;
|
||||||
|
|
||||||
if (!numberToInsert) {
|
if (!numberToInsert) {
|
||||||
numberToInsert = await SignFieldNumberDialog.call({
|
numberToInsert = await SignFieldNumberDialog.call({
|
||||||
fieldMeta: field.fieldMeta,
|
fieldMeta: field.fieldMeta,
|
||||||
|
defaultValue: field.inserted ? field.customText : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!numberToInsert) {
|
if (numberToInsert === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An empty value clears the field.
|
||||||
return {
|
return {
|
||||||
type: FieldType.NUMBER,
|
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;
|
let textToInsert = text;
|
||||||
|
|
||||||
if (!textToInsert) {
|
if (!textToInsert) {
|
||||||
textToInsert = await SignFieldTextDialog.call({
|
textToInsert = await SignFieldTextDialog.call({
|
||||||
fieldMeta: field.fieldMeta,
|
fieldMeta: field.fieldMeta,
|
||||||
|
defaultValue: field.inserted ? field.customText : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!textToInsert) {
|
if (textToInsert === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An empty value clears the field.
|
||||||
return {
|
return {
|
||||||
type: FieldType.TEXT,
|
type: FieldType.TEXT,
|
||||||
value: textToInsert,
|
value: textToInsert || null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { expect, test } from '@playwright/test';
|
|||||||
import { DocumentStatus, FieldType } from '@prisma/client';
|
import { DocumentStatus, FieldType } from '@prisma/client';
|
||||||
import { DateTime } from 'luxon';
|
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]';
|
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);
|
expect(dbEnvelope.status).toBe(DocumentStatus.COMPLETED);
|
||||||
}).toPass();
|
}).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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user