From 97835b8dbb2ca24670c8a410972d949c982c8f61 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Sun, 28 Jun 2026 15:08:11 +1000 Subject: [PATCH] feat: add field multiselect (#3031) --- .../envelope-editor-fields-page-renderer.tsx | 57 +++---- .../envelope-fields.spec.ts | 151 +++++++++++++++++- packages/app-tests/e2e/fixtures/konva.ts | 32 ++++ 3 files changed, 206 insertions(+), 34 deletions(-) diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx index 5cbd4addf..698f83651 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx @@ -244,10 +244,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(); }); @@ -452,43 +468,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); } }); diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-fields.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-fields.spec.ts index f9c3515d4..0a4c3ab73 100644 --- a/packages/app-tests/e2e/envelope-editor-v2/envelope-fields.spec.ts +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-fields.spec.ts @@ -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; @@ -99,6 +99,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 => { const externalId = `e2e-fields-${nanoid()}`; @@ -761,9 +772,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 => { + 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); @@ -816,6 +924,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); @@ -868,6 +986,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', @@ -945,6 +1078,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', diff --git a/packages/app-tests/e2e/fixtures/konva.ts b/packages/app-tests/e2e/fixtures/konva.ts index 316eb46f4..87973ab97 100644 --- a/packages/app-tests/e2e/fixtures/konva.ts +++ b/packages/app-tests/e2e/fixtures/konva.ts @@ -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 }, + ); +};