mirror of
https://github.com/documenso/documenso.git
synced 2026-06-25 22:02:17 +10:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ee5e441b9 |
+24
-33
@@ -237,10 +237,26 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
||||
fieldGroup.off('transformend');
|
||||
fieldGroup.off('dragend');
|
||||
|
||||
// Set up field selection.
|
||||
fieldGroup.on('click', () => {
|
||||
// Set up field selection. Shift + click toggles this field in/out of the current
|
||||
// multi-selection, so fields can be added to a group by clicking them --
|
||||
// complementing marquee drag-selection. A plain click (no modifier) selects just
|
||||
// this field.
|
||||
fieldGroup.on('click', (event) => {
|
||||
removePendingField();
|
||||
setSelectedFields([fieldGroup]);
|
||||
|
||||
const isMultiSelectModifier = event.evt.shiftKey;
|
||||
|
||||
if (isMultiSelectModifier) {
|
||||
const currentNodes = interactiveTransformer.current?.nodes() ?? [];
|
||||
const isAlreadySelected = currentNodes.includes(fieldGroup);
|
||||
|
||||
setSelectedFields(
|
||||
isAlreadySelected ? currentNodes.filter((node) => node !== fieldGroup) : [...currentNodes, fieldGroup],
|
||||
);
|
||||
} else {
|
||||
setSelectedFields([fieldGroup]);
|
||||
}
|
||||
|
||||
pageLayer.current?.batchDraw();
|
||||
});
|
||||
|
||||
@@ -445,43 +461,18 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
||||
}
|
||||
});
|
||||
|
||||
// Clicks should select/deselect shapes
|
||||
// Clicking empty stage area clears the selection. Field clicks -- including
|
||||
// Shift+click multi-select -- are handled by each field group's own click
|
||||
// handler in `unsafeRenderFieldOnLayer`.
|
||||
currentStage.on('click tap', (e) => {
|
||||
// if we are selecting with rect, do nothing
|
||||
// If we are selecting with the marquee rectangle, do nothing.
|
||||
if (selectionRectangle.visible() && selectionRectangle.width() > 0 && selectionRectangle.height() > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If empty area clicked, remove all selections
|
||||
// If empty area clicked, remove all selections.
|
||||
if (e.target === stage.current) {
|
||||
setSelectedFields([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Do nothing if field not clicked, or if field is not editable
|
||||
if (!e.target.hasName('field-group') || e.target.draggable() === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// do we pressed shift or ctrl?
|
||||
const metaPressed = e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey;
|
||||
const isSelected = transformer.nodes().indexOf(e.target) >= 0;
|
||||
|
||||
if (!metaPressed && !isSelected) {
|
||||
// if no key pressed and the node is not selected
|
||||
// select just one
|
||||
setSelectedFields([e.target]);
|
||||
} else if (metaPressed && isSelected) {
|
||||
// if we pressed keys and node was selected
|
||||
// we need to remove it from selection:
|
||||
const nodes = transformer.nodes().slice(); // use slice to have new copy of array
|
||||
// remove node from array
|
||||
nodes.splice(nodes.indexOf(e.target), 1);
|
||||
setSelectedFields(nodes);
|
||||
} else if (metaPressed && !isSelected) {
|
||||
// add the node into selection
|
||||
const nodes = transformer.nodes().concat([e.target]);
|
||||
setSelectedFields(nodes);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
type TEnvelopeEditorSurface,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
import { getKonvaElementCountForPage } from '../fixtures/konva';
|
||||
import { getKonvaElementCountForPage, getKonvaTransformerNodeCountForPage } from '../fixtures/konva';
|
||||
|
||||
type TFieldFlowResult = {
|
||||
externalId: string;
|
||||
@@ -98,6 +98,17 @@ const selectFieldOnCanvas = async (root: Page, position: { x: number; y: number
|
||||
await canvas.click({ position, force: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* Shift+click a field on the canvas to toggle it in/out of the current multi-selection.
|
||||
*/
|
||||
const shiftClickFieldOnCanvas = async (root: Page, position: { x: number; y: number }) => {
|
||||
const canvas = root.locator('.konva-container canvas').first();
|
||||
await expect(canvas).toBeVisible();
|
||||
await root.waitForTimeout(300);
|
||||
// Use force:true to bypass any floating action toolbar buttons that may intercept clicks.
|
||||
await canvas.click({ position, modifiers: ['Shift'], force: true });
|
||||
};
|
||||
|
||||
const runAddAndPersistSignatureTextFields = async (surface: TEnvelopeEditorSurface): Promise<TFieldFlowResult> => {
|
||||
const externalId = `e2e-fields-${nanoid()}`;
|
||||
|
||||
@@ -760,9 +771,106 @@ const assertChangeFieldTypePersistedInDatabase = async ({
|
||||
expect(actualMetaTypes).toEqual(['date', 'date']);
|
||||
};
|
||||
|
||||
// --- Shift+click multi-select flow ---
|
||||
|
||||
type TShiftClickFlowResult = {
|
||||
externalId: string;
|
||||
};
|
||||
|
||||
const SHIFT_CLICK_FIELD_POSITIONS = {
|
||||
signature: { x: 150, y: 120 },
|
||||
text: { x: 150, y: 260 },
|
||||
name: { x: 150, y: 400 },
|
||||
};
|
||||
|
||||
const runShiftClickMultiSelectFlow = async (surface: TEnvelopeEditorSurface): Promise<TShiftClickFlowResult> => {
|
||||
const externalId = `e2e-shift-click-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
if (surface.isEmbedded && !surface.envelopeId) {
|
||||
await addEnvelopeItemPdf(root, 'embedded-fields.pdf');
|
||||
}
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
await setupRecipientsForFieldPlacement(surface);
|
||||
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
|
||||
|
||||
// Place three fields, spaced far enough apart that their action toolbars don't
|
||||
// overlap a neighbouring field's click target.
|
||||
await placeFieldOnPdf(root, 'Signature', SHIFT_CLICK_FIELD_POSITIONS.signature);
|
||||
await placeFieldOnPdf(root, 'Text', SHIFT_CLICK_FIELD_POSITIONS.text);
|
||||
await placeFieldOnPdf(root, 'Name', SHIFT_CLICK_FIELD_POSITIONS.name);
|
||||
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(3);
|
||||
|
||||
// A plain click selects exactly one field.
|
||||
await selectFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
|
||||
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
|
||||
|
||||
// Shift+click a second field ADDS it to the selection (the new behaviour).
|
||||
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.text);
|
||||
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(2);
|
||||
|
||||
// Shift+click an already-selected field REMOVES it from the selection.
|
||||
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
|
||||
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
|
||||
|
||||
// Shift+click it again RE-ADDS it, leaving Signature + Text selected and Name excluded.
|
||||
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
|
||||
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(2);
|
||||
|
||||
// Delete the two selected fields via the floating action toolbar. Only the
|
||||
// un-selected Name field should remain -- proving the multi-selection contained
|
||||
// exactly the two Shift-clicked fields.
|
||||
await expect(root.locator('button[title="Remove"]')).toBeVisible();
|
||||
await root.locator('button[title="Remove"]').click();
|
||||
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(1);
|
||||
|
||||
// Navigate away and back to verify persistence.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(1);
|
||||
|
||||
return { externalId };
|
||||
};
|
||||
|
||||
const assertShiftClickMultiSelectPersistedInDatabase = 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 },
|
||||
});
|
||||
|
||||
// Signature + Text were multi-selected via Shift+click and deleted; only Name remains.
|
||||
expect(envelope.fields).toHaveLength(1);
|
||||
expect(envelope.fields[0].type).toBe(FieldType.NAME);
|
||||
};
|
||||
|
||||
// --- Test describe blocks ---
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('shift+click adds and removes fields from the selection', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runShiftClickMultiSelectFlow(surface);
|
||||
|
||||
await assertShiftClickMultiSelectPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runAddAndPersistSignatureTextFields(surface);
|
||||
@@ -815,6 +923,16 @@ test.describe('document editor', () => {
|
||||
});
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('shift+click adds and removes fields from the selection', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runShiftClickMultiSelectFlow(surface);
|
||||
|
||||
await assertShiftClickMultiSelectPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runAddAndPersistSignatureTextFields(surface);
|
||||
@@ -867,6 +985,21 @@ test.describe('template editor', () => {
|
||||
});
|
||||
|
||||
test.describe('embedded create', () => {
|
||||
test('shift+click adds and removes fields from the selection', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-shift-click',
|
||||
});
|
||||
const result = await runShiftClickMultiSelectFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertShiftClickMultiSelectPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
@@ -944,6 +1077,22 @@ test.describe('embedded create', () => {
|
||||
});
|
||||
|
||||
test.describe('embedded edit', () => {
|
||||
test('shift+click adds and removes fields from the selection', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-shift-click',
|
||||
});
|
||||
const result = await runShiftClickMultiSelectFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertShiftClickMultiSelectPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
|
||||
@@ -16,3 +16,35 @@ export const getKonvaElementCountForPage = async (page: Page, pageNumber: number
|
||||
{ pageNumber, elementSelector },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns how many field groups are currently attached to the page's Konva
|
||||
* transformer, i.e. the size of the active canvas selection. Used to assert
|
||||
* multi-select behaviour (marquee drag and Shift+click).
|
||||
*/
|
||||
export const getKonvaTransformerNodeCountForPage = async (page: Page, pageNumber: number) => {
|
||||
await page.locator('.konva-container canvas').first().waitFor({ state: 'visible' });
|
||||
|
||||
return await page.evaluate(
|
||||
({ pageNumber }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva;
|
||||
|
||||
const stage = konva.stages.find((stage) => stage.attrs.id === `page-${pageNumber}`);
|
||||
|
||||
if (!stage) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const transformer = stage.find('Transformer')[0];
|
||||
|
||||
if (!transformer) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return (transformer as Konva.Transformer).nodes().length;
|
||||
},
|
||||
{ pageNumber },
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user