mirror of
https://github.com/documenso/documenso.git
synced 2026-09-30 16:54:34 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7841c93478 |
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
@@ -26,7 +25,6 @@ import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@@ -34,7 +32,6 @@ import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
||||
import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useRevalidator, useSearchParams } from 'react-router';
|
||||
import { isDeepEqual } from 'remeda';
|
||||
import { match } from 'ts-pattern';
|
||||
@@ -87,7 +84,6 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
|
||||
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
|
||||
@@ -100,34 +96,6 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
const selectedField = useMemo(() => structuredClone(editorFields.selectedField), [editorFields.selectedField]);
|
||||
|
||||
const [copiedField, setCopiedField] = useState<TLocalField | null>(null);
|
||||
|
||||
useHotkeys(['ctrl+c', 'meta+c'], (event) => {
|
||||
// Keep the native copy when the user has selected text.
|
||||
if (!selectedField || window.getSelection()?.toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
setCopiedField(selectedField);
|
||||
|
||||
toast({
|
||||
title: _(msg`Copied field`),
|
||||
description: _(msg`Copied field to clipboard`),
|
||||
});
|
||||
});
|
||||
|
||||
useHotkeys(['ctrl+v', 'meta+v'], (event) => {
|
||||
if (!copiedField) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// Paste the next copy offset from the last one, so repeated pastes do not stack.
|
||||
setCopiedField(editorFields.duplicateField(copiedField));
|
||||
});
|
||||
|
||||
/**
|
||||
* Debounce the fields used for overlap detection so we don't recompute on every
|
||||
* small drag/resize movement, which is expensive on large field counts and can
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -633,65 +633,6 @@ const assertDuplicateDeleteFieldPersistedInDatabase = async ({
|
||||
expect(envelope.fields[0].type).toBe(FieldType.SIGNATURE);
|
||||
};
|
||||
|
||||
// --- Copy and paste fields flow ---
|
||||
|
||||
type TCopyPasteFlowResult = {
|
||||
externalId: string;
|
||||
};
|
||||
|
||||
const runCopyPasteFieldFlow = async (surface: TEnvelopeEditorSurface): Promise<TCopyPasteFlowResult> => {
|
||||
const externalId = `e2e-copy-paste-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
await setupRecipientsForFieldPlacement(surface);
|
||||
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
|
||||
|
||||
await placeFieldOnPdf(root, 'Signature', { x: 150, y: 150 });
|
||||
await selectFieldOnCanvas(root, { x: 150, y: 150 });
|
||||
|
||||
await root.keyboard.press('ControlOrMeta+c');
|
||||
await root.keyboard.press('ControlOrMeta+v');
|
||||
await root.keyboard.press('ControlOrMeta+v');
|
||||
|
||||
await expect.poll(async () => getKonvaElementCountForPage(root, 1, '.field-group')).toBe(3);
|
||||
|
||||
// Navigate away and back to persist changes.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect.poll(async () => getKonvaElementCountForPage(root, 1, '.field-group')).toBe(3);
|
||||
|
||||
return { externalId };
|
||||
};
|
||||
|
||||
const assertCopyPasteFieldPersistedInDatabase = async ({
|
||||
surface,
|
||||
externalId,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
externalId: string;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { fields: true },
|
||||
});
|
||||
|
||||
expect(envelope.fields).toHaveLength(3);
|
||||
expect(envelope.fields.every((field) => field.type === FieldType.SIGNATURE)).toBe(true);
|
||||
|
||||
// Each paste is offset from the previous one, so no two fields share a position.
|
||||
const positions = new Set(envelope.fields.map((field) => `${field.positionX}:${field.positionY}`));
|
||||
expect(positions.size).toBe(3);
|
||||
};
|
||||
|
||||
// --- Change field type flow ---
|
||||
|
||||
type TChangeFieldTypeFlowResult = {
|
||||
@@ -961,16 +902,6 @@ test.describe('document editor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('copy and paste fields with keyboard shortcuts', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runCopyPasteFieldFlow(surface);
|
||||
|
||||
await assertCopyPasteFieldPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('place and configure all 10 field types', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runAllFieldTypesFlow(surface);
|
||||
@@ -1033,16 +964,6 @@ test.describe('template editor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('copy and paste fields with keyboard shortcuts', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runCopyPasteFieldFlow(surface);
|
||||
|
||||
await assertCopyPasteFieldPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('place and configure all 10 field types', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runAllFieldTypesFlow(surface);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user