mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 09:25:08 +10:00
feat: add field multiselect (#3031)
This commit is contained in:
+24
-33
@@ -244,10 +244,26 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
|||||||
fieldGroup.off('transformend');
|
fieldGroup.off('transformend');
|
||||||
fieldGroup.off('dragend');
|
fieldGroup.off('dragend');
|
||||||
|
|
||||||
// Set up field selection.
|
// Set up field selection. Shift + click toggles this field in/out of the current
|
||||||
fieldGroup.on('click', () => {
|
// 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();
|
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();
|
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) => {
|
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) {
|
if (selectionRectangle.visible() && selectionRectangle.width() > 0 && selectionRectangle.height() > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If empty area clicked, remove all selections
|
// If empty area clicked, remove all selections.
|
||||||
if (e.target === stage.current) {
|
if (e.target === stage.current) {
|
||||||
setSelectedFields([]);
|
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,
|
type TEnvelopeEditorSurface,
|
||||||
} from '../fixtures/envelope-editor';
|
} from '../fixtures/envelope-editor';
|
||||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||||
import { getKonvaElementCountForPage } from '../fixtures/konva';
|
import { getKonvaElementCountForPage, getKonvaTransformerNodeCountForPage } from '../fixtures/konva';
|
||||||
|
|
||||||
type TFieldFlowResult = {
|
type TFieldFlowResult = {
|
||||||
externalId: string;
|
externalId: string;
|
||||||
@@ -99,6 +99,17 @@ const selectFieldOnCanvas = async (root: Page, position: { x: number; y: number
|
|||||||
await canvas.click({ position, force: true });
|
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 runAddAndPersistSignatureTextFields = async (surface: TEnvelopeEditorSurface): Promise<TFieldFlowResult> => {
|
||||||
const externalId = `e2e-fields-${nanoid()}`;
|
const externalId = `e2e-fields-${nanoid()}`;
|
||||||
|
|
||||||
@@ -761,9 +772,106 @@ const assertChangeFieldTypePersistedInDatabase = async ({
|
|||||||
expect(actualMetaTypes).toEqual(['date', 'date']);
|
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 blocks ---
|
||||||
|
|
||||||
test.describe('document editor', () => {
|
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 }) => {
|
test('add and persist signature/text fields', async ({ page }) => {
|
||||||
const surface = await openDocumentEnvelopeEditor(page);
|
const surface = await openDocumentEnvelopeEditor(page);
|
||||||
const result = await runAddAndPersistSignatureTextFields(surface);
|
const result = await runAddAndPersistSignatureTextFields(surface);
|
||||||
@@ -816,6 +924,16 @@ test.describe('document editor', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('template 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 }) => {
|
test('add and persist signature/text fields', async ({ page }) => {
|
||||||
const surface = await openTemplateEnvelopeEditor(page);
|
const surface = await openTemplateEnvelopeEditor(page);
|
||||||
const result = await runAddAndPersistSignatureTextFields(surface);
|
const result = await runAddAndPersistSignatureTextFields(surface);
|
||||||
@@ -868,6 +986,21 @@ test.describe('template editor', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('embedded create', () => {
|
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 }) => {
|
test('add and persist signature/text fields', async ({ page }) => {
|
||||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||||
envelopeType: 'DOCUMENT',
|
envelopeType: 'DOCUMENT',
|
||||||
@@ -945,6 +1078,22 @@ test.describe('embedded create', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('embedded edit', () => {
|
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 }) => {
|
test('add and persist signature/text fields', async ({ page }) => {
|
||||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||||
envelopeType: 'TEMPLATE',
|
envelopeType: 'TEMPLATE',
|
||||||
|
|||||||
@@ -16,3 +16,35 @@ export const getKonvaElementCountForPage = async (page: Page, pageNumber: number
|
|||||||
{ pageNumber, elementSelector },
|
{ 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