mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
feat: allow changing field types (#2873)
This commit is contained in:
+103
-2
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user