feat: allow changing field types (#2873)

This commit is contained in:
David Nguyen
2026-06-09 13:48:40 +10:00
committed by GitHub
parent 90462bf414
commit d5c6cf4ad5
2 changed files with 293 additions and 2 deletions
@@ -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<HTMLDivElement> & {
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 = ({
<div className="flex flex-col items-center" {...props}>
<div className="group flex w-fit items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5">
<button
type="button"
title={t`Change Recipient`}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={() => setShowRecipientSelector(true)}
@@ -665,6 +718,17 @@ const FieldActionButtons = ({
</button>
<button
type="button"
title={t`Change Field Type`}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={() => setShowFieldTypeSelector(true)}
onTouchEnd={() => setShowFieldTypeSelector(true)}
>
<ShapesIcon className="h-3 w-3" />
</button>
<button
type="button"
title={t`Duplicate`}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={handleDuplicateSelectedFields}
@@ -674,6 +738,7 @@ const FieldActionButtons = ({
</button>
<button
type="button"
title={t`Duplicate on all pages`}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={handleDuplicateSelectedFieldsOnAllPages}
@@ -683,6 +748,7 @@ const FieldActionButtons = ({
</button>
<button
type="button"
title={t`Remove`}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={handleDeleteSelectedFields}
@@ -705,6 +771,41 @@ const FieldActionButtons = ({
fields={envelope.fields}
/>
</CommandDialog>
<CommandDialog position="start" open={showFieldTypeSelector} onOpenChange={setShowFieldTypeSelector}>
<Command defaultValue={preselectedFieldType ? t(FRIENDLY_FIELD_TYPE[preselectedFieldType]) : undefined}>
<CommandInput placeholder={t`Select a field type`} />
<CommandList>
<CommandEmpty>
<span className="inline-block px-4 text-muted-foreground">
{t`No field type matching this description was found.`}
</span>
</CommandEmpty>
<CommandGroup>
{fieldButtonList.map((field) => {
const FieldIcon = field.icon;
const label = t(FRIENDLY_FIELD_TYPE[field.type]);
return (
<CommandItem
key={field.type}
className="px-2"
onSelect={() => {
handleChangeFieldType(field.type);
setShowFieldTypeSelector(false);
}}
>
<FieldIcon className="mr-2 h-4 w-4" />
<span className="truncate">{label}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
</div>
);
};
@@ -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<TChangeFieldTypeFlowResult> => {
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,
});
});
});