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 7573d5fb4..a5676dc60 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 @@ -14,13 +14,22 @@ import { import { renderField } from '@documenso/lib/universal/field-renderer/render-field'; import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields'; import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients'; -import { CommandDialog } from '@documenso/ui/primitives/command'; +import { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@documenso/ui/primitives/command'; +import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types'; import { useLingui } from '@lingui/react/macro'; import type { FieldType } from '@prisma/client'; import Konva from 'konva'; import type { KonvaEventObject } from 'konva/lib/Node'; import type { Transformer } from 'konva/lib/shapes/Transformer'; -import { CopyPlusIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide-react'; +import { CopyPlusIcon, ShapesIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { fieldButtonList } from './envelope-editor-fields-drag-drop'; @@ -470,6 +479,22 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR } }; + const changeSelectedFieldsType = (type: FieldType) => { + const fields = selectedKonvaFieldGroups + .map((field) => editorFields.getFieldByFormId(field.id())) + .filter((field) => field !== undefined); + + for (const field of fields) { + if (field.type !== type) { + editorFields.updateFieldByFormId(field.formId, { + type, + fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[type]), + id: undefined, + }); + } + } + }; + const duplicatedSelectedFields = () => { const fields = selectedKonvaFieldGroups .map((field) => editorFields.getFieldByFormId(field.id())) @@ -554,6 +579,7 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR handleDuplicateSelectedFieldsOnAllPages={duplicatedSelectedFieldsOnAllPages} handleDeleteSelectedFields={deletedSelectedFields} handleChangeRecipient={changeSelectedFieldsRecipients} + handleChangeFieldType={changeSelectedFieldsType} selectedFieldFormId={selectedKonvaFieldGroups.map((field) => field.id())} style={{ position: 'absolute', @@ -602,6 +628,7 @@ type FieldActionButtonsProps = React.HTMLAttributes & { handleDuplicateSelectedFieldsOnAllPages: () => void; handleDeleteSelectedFields: () => void; handleChangeRecipient: (recipientId: number) => void; + handleChangeFieldType: (type: FieldType) => void; selectedFieldFormId: string[]; }; @@ -610,15 +637,40 @@ const FieldActionButtons = ({ handleDuplicateSelectedFieldsOnAllPages, handleDeleteSelectedFields, handleChangeRecipient, + handleChangeFieldType, selectedFieldFormId, ...props }: FieldActionButtonsProps) => { const { t } = useLingui(); const [showRecipientSelector, setShowRecipientSelector] = useState(false); + const [showFieldTypeSelector, setShowFieldTypeSelector] = useState(false); const { editorFields, envelope } = useCurrentEnvelopeEditor(); + /** + * Decide the preselected field type in the command input. + * + * If all fields share the same type, use that as the default selection. + * Otherwise show no preselection. + */ + const preselectedFieldType = useMemo(() => { + if (selectedFieldFormId.length === 0) { + return null; + } + + const fields = editorFields.localFields.filter((field) => selectedFieldFormId.includes(field.formId)); + + if (fields.length === 0) { + return null; + } + + const firstType = fields[0].type; + const isTypesSame = fields.every((field) => field.type === firstType); + + return isTypesSame ? firstType : null; + }, [editorFields.localFields, selectedFieldFormId]); + /** * Decide the preselected recipient in the command input. * @@ -656,6 +708,7 @@ const FieldActionButtons = ({
+ +
); }; 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 64aaaa232..b4130a358 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 @@ -621,6 +621,145 @@ const assertDuplicateDeleteFieldPersistedInDatabase = async ({ expect(envelope.fields[0].type).toBe(FieldType.SIGNATURE); }; +// --- Change field type flow --- + +type TChangeFieldTypeFlowResult = { + externalId: string; +}; + +const FIELD_A_POSITION = { x: 150, y: 150 }; +const FIELD_B_POSITION = { x: 150, y: 250 }; + +const changeFieldTypeViaToolbar = async (root: Page, newTypeLabel: FieldButtonName) => { + await expect(root.locator('button[title="Change Field Type"]')).toBeVisible(); + await root.locator('button[title="Change Field Type"]').click(); + + // The CommandDialog uses role="option" for items; sidebar palette buttons use role="button". + const option = root.getByRole('option', { name: newTypeLabel, exact: true }); + await expect(option).toBeVisible(); + await option.click(); + + // Wait for the CommandDialog to close (selection persists so the toolbar remains). + await expect(root.getByRole('dialog')).toHaveCount(0); +}; + +/** + * Multi-select fields on the konva canvas by drawing a marquee selection rectangle. + * + * The editor's stage mousedown/mousemove/mouseup handlers create a Konva selection + * rectangle when the user drags on empty stage area. All field groups that intersect + * the rectangle are selected at once. This is the canonical multi-select gesture. + */ +const marqueeSelectFieldsOnCanvas = async ( + root: Page, + start: { x: number; y: number }, + end: { x: number; y: number }, +) => { + const canvas = root.locator('.konva-container canvas').first(); + await expect(canvas).toBeVisible(); + + const box = await canvas.boundingBox(); + + if (!box) { + throw new Error('Canvas bounding box not available for marquee selection.'); + } + + // The marquee gesture must start on empty stage (not on a field) and pass through + // intermediate points so the editor's mousemove handler can grow the rectangle. + await root.mouse.move(box.x + start.x, box.y + start.y); + await root.mouse.down(); + await root.mouse.move(box.x + (start.x + end.x) / 2, box.y + (start.y + end.y) / 2, { steps: 5 }); + await root.mouse.move(box.x + end.x, box.y + end.y, { steps: 5 }); + await root.mouse.up(); +}; + +const runChangeFieldTypeFlow = async (surface: TEnvelopeEditorSurface): Promise => { + const externalId = `e2e-change-type-${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 two fields of different types: Signature (A) and Name (B). + await placeFieldOnPdf(root, 'Signature', FIELD_A_POSITION); + await placeFieldOnPdf(root, 'Name', FIELD_B_POSITION); + let fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group'); + expect(fieldCount).toBe(2); + + // --- Phase 1: single field type change --- + // Select field A (Signature) and change it to Text via the toolbar. + await selectFieldOnCanvas(root, FIELD_A_POSITION); + await changeFieldTypeViaToolbar(root, 'Text'); + + // Field count must remain stable -- changing type doesn't add/remove fields. + fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group'); + expect(fieldCount).toBe(2); + + // Navigate away and back to verify the change is persisted in local state. + await clickEnvelopeEditorStep(root, 'upload'); + await clickEnvelopeEditorStep(root, 'addFields'); + fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group'); + expect(fieldCount).toBe(2); + + // --- Phase 2: multi-field type change --- + // Use a marquee drag-selection rectangle to capture both fields at once. + // Fields are at (150, 150) and (150, 250) with default dims ~90x30; drag from + // (50, 100) to (260, 290) encloses both with margin. + await marqueeSelectFieldsOnCanvas(root, { x: 50, y: 100 }, { x: 260, y: 290 }); + + // With mixed-type selection (Text + Name), change both to Date. + await changeFieldTypeViaToolbar(root, 'Date'); + + fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group'); + expect(fieldCount).toBe(2); + + // Navigate away and back to verify persistence. + await clickEnvelopeEditorStep(root, 'upload'); + await clickEnvelopeEditorStep(root, 'addFields'); + fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group'); + expect(fieldCount).toBe(2); + + return { externalId }; +}; + +const assertChangeFieldTypePersistedInDatabase = 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 }, + }); + + // Started with Signature + Name, then both were converted to Date. + // Use sorted .map() in the assertion so any failure prints which types were found. + const actualTypes = envelope.fields.map((field) => field.type).sort(); + const expectedTypes = [FieldType.DATE, FieldType.DATE]; + + expect(envelope.fields).toHaveLength(2); + expect(actualTypes).toEqual(expectedTypes); + + // Each field's meta must have been reset to the new type's defaults. + const actualMetaTypes = envelope.fields.map((field) => getFieldMetaType(field.fieldMeta)).sort(); + expect(actualMetaTypes).toEqual(['date', 'date']); +}; + // --- Test describe blocks --- test.describe('document editor', () => { @@ -663,6 +802,16 @@ test.describe('document editor', () => { ...result, }); }); + + test('change field type via canvas action toolbar (single and multi-select)', async ({ page }) => { + const surface = await openDocumentEnvelopeEditor(page); + const result = await runChangeFieldTypeFlow(surface); + + await assertChangeFieldTypePersistedInDatabase({ + surface, + ...result, + }); + }); }); test.describe('template editor', () => { @@ -705,6 +854,16 @@ test.describe('template editor', () => { ...result, }); }); + + test('change field type via canvas action toolbar (single and multi-select)', async ({ page }) => { + const surface = await openTemplateEnvelopeEditor(page); + const result = await runChangeFieldTypeFlow(surface); + + await assertChangeFieldTypePersistedInDatabase({ + surface, + ...result, + }); + }); }); test.describe('embedded create', () => { @@ -767,6 +926,21 @@ test.describe('embedded create', () => { ...result, }); }); + + test('change field type via canvas action toolbar (single and multi-select)', async ({ page }) => { + const surface = await openEmbeddedEnvelopeEditor(page, { + envelopeType: 'DOCUMENT', + tokenNamePrefix: 'e2e-embed-change-type', + }); + const result = await runChangeFieldTypeFlow(surface); + + await persistEmbeddedEnvelope(surface); + + await assertChangeFieldTypePersistedInDatabase({ + surface, + ...result, + }); + }); }); test.describe('embedded edit', () => { @@ -833,4 +1007,20 @@ test.describe('embedded edit', () => { ...result, }); }); + + test('change field type via canvas action toolbar (single and multi-select)', async ({ page }) => { + const surface = await openEmbeddedEnvelopeEditor(page, { + envelopeType: 'TEMPLATE', + mode: 'edit', + tokenNamePrefix: 'e2e-embed-change-type', + }); + const result = await runChangeFieldTypeFlow(surface); + + await persistEmbeddedEnvelope(surface); + + await assertChangeFieldTypePersistedInDatabase({ + surface, + ...result, + }); + }); });