mirror of
https://github.com/documenso/documenso.git
synced 2026-09-30 08:44:39 +10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abb75e0465 | ||
|
|
a18a04d911 |
+17
-16
@@ -538,15 +538,13 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
||||
//
|
||||
// `addField` already marks a newly created field as the selected field, so this
|
||||
// makes a field placed via the palette (drag-drop) or marquee creation show its
|
||||
// resize handles immediately -- no second click needed. It also clears the canvas
|
||||
// selection when the selected field is cleared (e.g. when the author starts
|
||||
// placing another field), so the floating action toolbar can't intercept the next
|
||||
// placement click. Runs after the render loop above so the field's group exists.
|
||||
// resize handles immediately -- no second click needed. Runs after the render loop
|
||||
// above so the field's group exists.
|
||||
const selectedFormId = editorFields.selectedField?.formId ?? null;
|
||||
const isSingleCanvasSelection = selectedKonvaFieldGroups.length === 1;
|
||||
|
||||
if (selectedFormId && localPageFields.some((field) => field.formId === selectedFormId)) {
|
||||
const isAlreadySelected = isSingleCanvasSelection && selectedKonvaFieldGroups[0].id() === selectedFormId;
|
||||
const isAlreadySelected =
|
||||
liveSelectedFieldGroups.length === 1 && liveSelectedFieldGroups[0].id() === selectedFormId;
|
||||
|
||||
if (!isAlreadySelected) {
|
||||
const fieldGroupToSelect = pageLayer.current.findOne(`#${selectedFormId}`);
|
||||
@@ -555,8 +553,16 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
||||
setSelectedFields([fieldGroupToSelect], { isAutoSelect: true });
|
||||
}
|
||||
}
|
||||
} else if (selectedFormId === null && isSingleCanvasSelection) {
|
||||
setSelectedFields([]);
|
||||
} else if (
|
||||
liveSelectedFieldGroups.some(
|
||||
(fieldGroup) => !editorFields.selectedFields.some((field) => field.formId === fieldGroup.id()),
|
||||
)
|
||||
) {
|
||||
// The selection moved to another page, or the editor cleared it (for example, when
|
||||
// the author starts to place another field). Clear this page only, so the new
|
||||
// selection stays and the floating toolbar cannot intercept the next placement click.
|
||||
interactiveTransformer.current?.nodes([]);
|
||||
setSelectedKonvaFieldGroups([]);
|
||||
}
|
||||
|
||||
// Rerender the transformer
|
||||
@@ -569,6 +575,7 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
||||
overlappingFieldFormIds,
|
||||
isFieldChanging,
|
||||
editorFields.selectedField?.formId,
|
||||
editorFields.selectedFields,
|
||||
]);
|
||||
|
||||
const setSelectedFields = (nodes: Konva.Node[], options?: { isAutoSelect?: boolean }) => {
|
||||
@@ -584,16 +591,10 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
||||
interactiveTransformer.current?.nodes(fieldGroups);
|
||||
setSelectedKonvaFieldGroups(fieldGroups);
|
||||
|
||||
if (fieldGroups.length === 0 || fieldGroups.length > 1) {
|
||||
editorFields.setSelectedField(null);
|
||||
}
|
||||
editorFields.setSelectedFields(fieldGroups.map((fieldGroup) => fieldGroup.id()));
|
||||
|
||||
// Handle single field selection.
|
||||
if (fieldGroups.length === 1) {
|
||||
const fieldGroup = fieldGroups[0];
|
||||
|
||||
editorFields.setSelectedField(fieldGroup.id());
|
||||
fieldGroup.moveToTop();
|
||||
fieldGroups[0].moveToTop();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -25,13 +26,15 @@ 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 { msg, plural } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
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';
|
||||
@@ -84,6 +87,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
|
||||
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
|
||||
@@ -96,6 +100,40 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
const selectedField = useMemo(() => structuredClone(editorFields.selectedField), [editorFields.selectedField]);
|
||||
|
||||
const [copiedFields, setCopiedFields] = useState<TLocalField[]>([]);
|
||||
|
||||
useHotkeys(['ctrl+c', 'meta+c'], (event) => {
|
||||
// Keep the native copy when the user has selected text.
|
||||
if (editorFields.selectedFields.length === 0 || window.getSelection()?.toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
setCopiedFields(structuredClone(editorFields.selectedFields));
|
||||
|
||||
toast({
|
||||
title: _(
|
||||
msg({
|
||||
message: plural(editorFields.selectedFields.length, {
|
||||
one: 'Copied field',
|
||||
other: 'Copied # fields',
|
||||
}),
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
useHotkeys(['ctrl+v', 'meta+v'], (event) => {
|
||||
if (copiedFields.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// Paste the next copies offset from the last ones, so repeated pastes do not stack.
|
||||
setCopiedFields(copiedFields.map((field) => editorFields.duplicateField(field)));
|
||||
});
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -633,6 +633,115 @@ 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);
|
||||
};
|
||||
|
||||
const runMultiCopyPasteFieldFlow = async (surface: TEnvelopeEditorSurface): Promise<TCopyPasteFlowResult> => {
|
||||
const externalId = `e2e-multi-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', FIELD_A_POSITION);
|
||||
await placeFieldOnPdf(root, 'Name', FIELD_B_POSITION);
|
||||
await marqueeSelectFieldsOnCanvas(root, { x: 50, y: 100 }, { x: 260, y: 290 });
|
||||
await expect.poll(async () => getKonvaTransformerNodeCountForPage(root, 1)).toBe(2);
|
||||
|
||||
await root.keyboard.press('ControlOrMeta+c');
|
||||
await root.keyboard.press('ControlOrMeta+v');
|
||||
|
||||
await expect.poll(async () => getKonvaElementCountForPage(root, 1, '.field-group')).toBe(4);
|
||||
|
||||
// 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(4);
|
||||
|
||||
return { externalId };
|
||||
};
|
||||
|
||||
const assertMultiCopyPasteFieldPersistedInDatabase = 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 },
|
||||
});
|
||||
|
||||
const types = envelope.fields.map((field) => field.type).sort();
|
||||
expect(types).toEqual([FieldType.NAME, FieldType.NAME, FieldType.SIGNATURE, FieldType.SIGNATURE]);
|
||||
};
|
||||
|
||||
// --- Change field type flow ---
|
||||
|
||||
type TChangeFieldTypeFlowResult = {
|
||||
@@ -902,6 +1011,26 @@ 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('copy and paste multiple selected fields with keyboard shortcuts', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runMultiCopyPasteFieldFlow(surface);
|
||||
|
||||
await assertMultiCopyPasteFieldPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('place and configure all 10 field types', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runAllFieldTypesFlow(surface);
|
||||
@@ -964,6 +1093,26 @@ 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('copy and paste multiple selected fields with keyboard shortcuts', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runMultiCopyPasteFieldFlow(surface);
|
||||
|
||||
await assertMultiCopyPasteFieldPersistedInDatabase({
|
||||
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 { nanoid } from '@documenso/lib/universal/id';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { Field } from '@prisma/client';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -42,9 +42,11 @@ type EditorFieldsProps = {
|
||||
type UseEditorFieldsResponse = {
|
||||
localFields: TLocalField[];
|
||||
|
||||
// Selected field
|
||||
// Selected fields. `selectedField` is set only when exactly one field is selected.
|
||||
selectedFields: TLocalField[];
|
||||
selectedField: TLocalField | undefined;
|
||||
setSelectedField: (formId: string | null) => void;
|
||||
setSelectedFields: (formIds: string[]) => void;
|
||||
|
||||
// Field operations
|
||||
addField: (field: Omit<TLocalField, 'formId'>) => TLocalField;
|
||||
@@ -66,7 +68,7 @@ type UseEditorFieldsResponse = {
|
||||
};
|
||||
|
||||
export const useEditorFields = ({ envelope, handleFieldsUpdate }: EditorFieldsProps): UseEditorFieldsResponse => {
|
||||
const [selectedFieldFormId, setSelectedFieldFormId] = useState<string | null>(null);
|
||||
const [selectedFieldFormIds, setSelectedFieldFormIds] = useState<string[]>([]);
|
||||
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
|
||||
|
||||
const generateDefaultValues = (fields?: Field[]) => {
|
||||
@@ -113,7 +115,7 @@ export const useEditorFields = ({ envelope, handleFieldsUpdate }: EditorFieldsPr
|
||||
|
||||
const setSelectedField = (formId: string | null, bypassCheck = false) => {
|
||||
if (!formId) {
|
||||
setSelectedFieldFormId(null);
|
||||
setSelectedFieldFormIds([]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -125,11 +127,20 @@ export const useEditorFields = ({ envelope, handleFieldsUpdate }: EditorFieldsPr
|
||||
}
|
||||
|
||||
if (bypassCheck) {
|
||||
setSelectedFieldFormId(formId);
|
||||
setSelectedFieldFormIds([formId]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFieldFormId(foundField?.formId ?? null);
|
||||
setSelectedFieldFormIds(foundField ? [foundField.formId] : []);
|
||||
};
|
||||
|
||||
const setSelectedFields = (formIds: string[]) => {
|
||||
if (formIds.length === 1) {
|
||||
setSelectedField(formIds[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFieldFormIds(formIds);
|
||||
};
|
||||
|
||||
const addField = useCallback(
|
||||
@@ -262,17 +273,12 @@ export const useEditorFields = ({ envelope, handleFieldsUpdate }: EditorFieldsPr
|
||||
return envelope.recipients.find((recipient) => recipient.id === selectedRecipientId) || null;
|
||||
}, [selectedRecipientId, envelope.recipients]);
|
||||
|
||||
const selectedField = useMemo(() => {
|
||||
return localFields.find((field) => field.formId === selectedFieldFormId);
|
||||
}, [selectedFieldFormId, localFields]);
|
||||
const selectedFields = useMemo(
|
||||
() => localFields.filter((field) => selectedFieldFormIds.includes(field.formId)),
|
||||
[selectedFieldFormIds, localFields],
|
||||
);
|
||||
|
||||
/**
|
||||
* Keep the selected field form ID in sync with the local fields.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const foundField = localFields.find((field) => field.formId === selectedFieldFormId);
|
||||
setSelectedFieldFormId(foundField?.formId ?? null);
|
||||
}, [selectedFieldFormId, localFields]);
|
||||
const selectedField = selectedFieldFormIds.length === 1 ? selectedFields[0] : undefined;
|
||||
|
||||
const setSelectedRecipient = (recipientId: number | null) => {
|
||||
const foundRecipient = envelope.recipients.find((recipient) => recipient.id === recipientId);
|
||||
@@ -300,9 +306,11 @@ export const useEditorFields = ({ envelope, handleFieldsUpdate }: EditorFieldsPr
|
||||
getFieldByFormId,
|
||||
getFieldsByRecipient,
|
||||
|
||||
// Selected field
|
||||
// Selected fields
|
||||
selectedFields,
|
||||
selectedField,
|
||||
setSelectedField,
|
||||
setSelectedFields,
|
||||
|
||||
// Selected recipient
|
||||
selectedRecipient,
|
||||
|
||||
Reference in New Issue
Block a user