From 91f9a1c3e5d7710c1b459c7500075897578ef912 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 5 Aug 2026 14:13:22 +1000 Subject: [PATCH] fix: wip --- .../envelope-editor-recipient-form.tsx | 29 ++-- .../general/envelope-editor/recipient-row.tsx | 21 ++- .../envelope-editor/recipient-step-card.tsx | 104 +++++++++-- .../envelope-editor/recipient-step-list.tsx | 163 +++++++++--------- .../envelope-recipient-groups.spec.ts | 35 +++- .../envelope-recipients.spec.ts | 6 + .../app-tests/e2e/fixtures/envelope-editor.ts | 115 +++++++++--- .../hooks/use-editor-recipients.ts | 43 +++++ 8 files changed, 368 insertions(+), 148 deletions(-) diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx index 1c88505a5..88a562630 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx @@ -1,5 +1,8 @@ import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; -import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients'; +import { + updateEditorSigners, + ZEditorRecipientsFormSchema, +} from '@documenso/lib/client-only/hooks/use-editor-recipients'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; @@ -171,10 +174,7 @@ export const EnvelopeEditorRecipientForm = () => { const appendNormalizedSigner = (signer: (typeof watchedSigners)[number], shouldFocus = false) => { const updatedSigners = normalizeSigningOrders([...form.getValues('signers'), signer]); - form.setValue('signers', updatedSigners, { - shouldValidate: true, - shouldDirty: true, - }); + updateEditorSigners(form, updatedSigners); if (shouldFocus) { const signerIndex = updatedSigners.findIndex((updatedSigner) => updatedSigner.formId === signer.formId); @@ -204,8 +204,8 @@ export const EnvelopeEditorRecipientForm = () => { // If the only signer is the default empty signer lets just replace it with the detected recipients if (currentSigners.length === 1 && !currentSigners[0].name && !currentSigners[0].email) { - form.setValue( - 'signers', + updateEditorSigners( + form, detectedRecipients.map((recipient, index) => ({ formId: nanoid(12), name: recipient.name, @@ -214,10 +214,6 @@ export const EnvelopeEditorRecipientForm = () => { actionAuth: [], signingOrder: index + 1, })), - { - shouldValidate: true, - shouldDirty: true, - }, ); return; @@ -244,10 +240,7 @@ export const EnvelopeEditorRecipientForm = () => { nextSigningOrder += 1; } - form.setValue('signers', normalizeSigningOrders(currentSigners), { - shouldValidate: true, - shouldDirty: true, - }); + updateEditorSigners(form, normalizeSigningOrders(currentSigners)); toast({ title: plural(detectedRecipients.length, { @@ -301,10 +294,8 @@ export const EnvelopeEditorRecipientForm = () => { })), ); - form.setValue('signers', updatedSigners, { - shouldValidate: true, - shouldDirty: true, - }); + updateEditorSigners(form, updatedSigners); + form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, { shouldValidate: true, shouldDirty: true, diff --git a/apps/remix/app/components/general/envelope-editor/recipient-row.tsx b/apps/remix/app/components/general/envelope-editor/recipient-row.tsx index 37a237136..888f6e2fa 100644 --- a/apps/remix/app/components/general/envelope-editor/recipient-row.tsx +++ b/apps/remix/app/components/general/envelope-editor/recipient-row.tsx @@ -15,6 +15,7 @@ import type { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd'; import { useLingui } from '@lingui/react/macro'; import { EnvelopeType, type RecipientRole } from '@prisma/client'; import { GripVerticalIcon, TrashIcon } from 'lucide-react'; +import { memo } from 'react'; import { useFormContext } from 'react-hook-form'; type TEditorSigner = TEditorRecipientsFormSchema['signers'][number]; @@ -36,7 +37,7 @@ export type RecipientRowProps = { onSearchQueryChange: (query: string) => void; }; -export const RecipientRow = ({ +const RecipientRowInner = ({ signerIndex, signer, isSequential, @@ -77,9 +78,13 @@ export const RecipientRow = ({ ); }; + +/** + * Memoized: rows contain heavy inputs (autocomplete, role select) and would + * otherwise re-render on every drag state change, making drags feel sluggish. + * All callback props are stable (useCallback in the list) and `signer` object + * identities only change when form values actually change. + */ +export const RecipientRow = memo(RecipientRowInner); diff --git a/apps/remix/app/components/general/envelope-editor/recipient-step-card.tsx b/apps/remix/app/components/general/envelope-editor/recipient-step-card.tsx index 89c5fadcb..f9f2bbbe1 100644 --- a/apps/remix/app/components/general/envelope-editor/recipient-step-card.tsx +++ b/apps/remix/app/components/general/envelope-editor/recipient-step-card.tsx @@ -14,6 +14,23 @@ type TEditorSigner = TEditorRecipientsFormSchema['signers'][number]; export type DraggingType = 'STEP' | 'RECIPIENT' | null; +/** + * Skips the drop animation. The post-drop state update re-sorts and renumbers + * the groups anyway, so gliding to the predicted slot first makes every drop + * feel like it settles twice — snapping hands control to the real re-render + * immediately instead. + */ +const getDraggableStyle = (provided: DraggableProvided, snapshot: DraggableStateSnapshot) => { + if (!snapshot.isDropAnimating) { + return provided.draggableProps.style; + } + + return { + ...provided.draggableProps.style, + transitionDuration: '0.001s', + }; +}; + export type RecipientStepCardSharedRowProps = Pick< RecipientRowProps, | 'showAdvancedSettings' @@ -28,6 +45,7 @@ export type RecipientStepCardSharedRowProps = Pick< export type RecipientStepCardProps = { stepIndex: number; step: RecipientStep; + isLastStep: boolean; draggableProvided: DraggableProvided; draggableSnapshot: DraggableStateSnapshot; draggingType: DraggingType; @@ -40,9 +58,52 @@ export type RecipientStepCardProps = { rowProps: RecipientStepCardSharedRowProps; }; +/** + * The drop-zone strip rendered above each group card (and below the last one) + * that receives recipient-row drops. Invisible until a dragged row hovers it, + * then it shows a full-width green line marking the insertion point. + * + * Notes: + * - It lives INSIDE the step's Draggable so it shifts together with the card + * while groups are being reordered — a static strip between draggables + * would stay behind while the cards around it are displaced, making group + * drags look broken. + * - Its `droppableId` must stay STABLE while mounted (anchored to a formId, + * never a positional index): @hello-pangea/dnd does not support changing + * ids on mounted droppables/draggables, which silently breaks them. + * - `type="RECIPIENT"` already scopes it to recipient-row drags, and + * `isDropDisabled` must not be toggled based on the active drag, as + * @hello-pangea/dnd snapshots it at drag start (before state updates land). + * - It must keep a CONSTANT size: droppable geometry is captured when a drag + * starts, so resizing during the drag would leave the visible strip and the + * actual hit area in different places. Only colors may change mid-drag. + */ +const RecipientStepGap = ({ droppableId }: { droppableId: string }) => ( + + {(provided, snapshot) => ( +
+
+ {provided.placeholder} +
+ )} + +); + export const RecipientStepCard = ({ stepIndex, step, + isLastStep, draggableProvided, draggableSnapshot, draggingType, @@ -57,20 +118,23 @@ export const RecipientStepCard = ({ const isGroup = step.members.length > 1; const isCombineTarget = draggingType === 'STEP' && Boolean(draggableSnapshot.combineTargetFor); + // All droppable ids are anchored to the first member's formId (never a + // positional index) so they stay stable while cards are reordered — + // @hello-pangea/dnd does not support changing ids on mounted elements. + const stepAnchor = step.members[0].formId; + return (
- {/* - Note: `type="RECIPIENT"` already scopes this droppable to recipient-row - drags — `isDropDisabled` must not be toggled based on the active drag, - as @hello-pangea/dnd snapshots it at drag start. - */} - + + + {(droppableProvided, droppableSnapshot) => { const isJoinTarget = draggingType === 'RECIPIENT' && droppableSnapshot.isDraggingOver; const isHighlighted = isCombineTarget || isJoinTarget; @@ -80,9 +144,9 @@ export const RecipientStepCard = ({ ref={droppableProvided.innerRef} {...droppableProvided.droppableProps} data-testid="recipient-step-card" - className={cn('relative rounded-lg border px-3 pt-2 pb-1', { + className={cn('relative rounded-lg border bg-background px-3 pt-2 pb-1 transition-shadow', { 'border-primary/60 bg-primary/5': isGroup, - 'bg-widget-foreground': draggableSnapshot.isDragging, + 'bg-widget-foreground shadow-lg': draggableSnapshot.isDragging, 'border-primary ring-1 ring-primary': isHighlighted, })} > @@ -93,28 +157,31 @@ export const RecipientStepCard = ({ className="absolute -top-3 right-4 z-10 flex items-center gap-x-1 shadow-sm" > - Release to sign together + Release to group )} -
+
- + - + Group {step.order} {isGroup && ( <> - + - {step.members.length} signers · any order + {step.members.length} recipients · any order
); }; diff --git a/apps/remix/app/components/general/envelope-editor/recipient-step-list.tsx b/apps/remix/app/components/general/envelope-editor/recipient-step-list.tsx index 8a631cc04..0e3c64bee 100644 --- a/apps/remix/app/components/general/envelope-editor/recipient-step-list.tsx +++ b/apps/remix/app/components/general/envelope-editor/recipient-step-list.tsx @@ -1,5 +1,8 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; -import type { TEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients'; +import { + type TEditorRecipientsFormSchema, + updateEditorSigners, +} from '@documenso/lib/client-only/hooks/use-editor-recipients'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { extractRecipientToNewStep, @@ -13,44 +16,18 @@ import { import { canEditorRecipientBeModified, isAssistantLastSigner } from '@documenso/lib/utils/recipients'; import { trpc } from '@documenso/trpc/react'; import type { RecipientAutoCompleteOption } from '@documenso/ui/components/recipient/recipient-autocomplete-input'; -import { cn } from '@documenso/ui/lib/utils'; +import { Badge } from '@documenso/ui/primitives/badge'; import { useToast } from '@documenso/ui/primitives/use-toast'; import type { BeforeCapture, DropResult } from '@hello-pangea/dnd'; import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'; import { Trans, useLingui } from '@lingui/react/macro'; import { DocumentSigningOrder, RecipientRole } from '@prisma/client'; import { useCallback, useMemo, useState } from 'react'; - import { RecipientRow } from './recipient-row'; import { type DraggingType, RecipientStepCard } from './recipient-step-card'; type TEditorSigner = TEditorRecipientsFormSchema['signers'][number]; -// Notes: -// - `type="RECIPIENT"` already scopes these droppables to recipient-row drags, -// and `isDropDisabled` must not be toggled based on the active drag, as -// @hello-pangea/dnd snapshots it at drag start (before state updates land). -// - The gap must keep a CONSTANT size: droppable geometry is captured when a -// drag starts, so resizing during the drag would leave the visible strip and -// the actual hit area in different places. Only colors may change mid-drag. -const RecipientStepGap = ({ gapIndex, draggingType }: { gapIndex: number; draggingType: DraggingType }) => ( - - {(provided, snapshot) => ( -
- {provided.placeholder} -
- )} -
-); - export type RecipientStepListProps = { showAdvancedSettings: boolean; }; @@ -101,10 +78,7 @@ export const RecipientStepList = ({ showAdvancedSettings }: RecipientStepListPro (updatedSigners: TEditorSigner[], options: { warnWhenAssistantLast?: boolean } = {}) => { const { warnWhenAssistantLast = true } = options; - form.setValue('signers', updatedSigners, { - shouldValidate: true, - shouldDirty: true, - }); + updateEditorSigners(form, updatedSigners); if (warnWhenAssistantLast && isAssistantLastSigner(updatedSigners)) { toast({ @@ -210,9 +184,20 @@ export const RecipientStepList = ({ showAdvancedSettings }: RecipientStepListPro const currentSigners = form.getValues('signers'); + // Drag-and-drop ids are anchored to the first member's formId so they + // stay stable across reorders; resolve them back to step indexes here. + const { steps: currentSteps } = groupRecipientsBySigningOrder(currentSigners); + + const findStepIndexByAnchor = (anchorFormId: string) => + currentSteps.findIndex((step) => step.members[0]?.formId === anchorFormId); + if (result.type === 'STEP') { if (result.combine) { - const targetStepIndex = Number(result.combine.draggableId.slice('step-'.length)); + const targetStepIndex = findStepIndexByAnchor(result.combine.draggableId.slice('step-'.length)); + + if (targetStepIndex === -1) { + return; + } applySigners(mergeSteps(currentSigners, result.source.index, targetStepIndex, canSignerBeModified)); @@ -230,16 +215,30 @@ export const RecipientStepList = ({ showAdvancedSettings }: RecipientStepListPro const formId = result.draggableId.slice('recipient-'.length); const { droppableId } = result.destination; - if (droppableId.startsWith('gap-')) { - const gapIndex = Number(droppableId.slice('gap-'.length)); + if (droppableId === 'gap-end') { + applySigners(extractRecipientToNewStep(currentSigners, formId, currentSteps.length, canSignerBeModified)); - applySigners(extractRecipientToNewStep(currentSigners, formId, gapIndex, canSignerBeModified)); + return; + } + + if (droppableId.startsWith('gap-')) { + const insertStepIndex = findStepIndexByAnchor(droppableId.slice('gap-'.length)); + + if (insertStepIndex === -1) { + return; + } + + applySigners(extractRecipientToNewStep(currentSigners, formId, insertStepIndex, canSignerBeModified)); return; } if (droppableId.startsWith('step-members-')) { - const targetStepIndex = Number(droppableId.slice('step-members-'.length)); + const targetStepIndex = findStepIndexByAnchor(droppableId.slice('step-members-'.length)); + + if (targetStepIndex === -1) { + return; + } applySigners(moveRecipientToStep(currentSigners, formId, targetStepIndex, canSignerBeModified)); } @@ -260,8 +259,8 @@ export const RecipientStepList = ({ showAdvancedSettings }: RecipientStepListPro return (
- {!showAdvancedSettings && ( -
+ {!showAdvancedSettings && !isSequential && ( +
Email @@ -298,57 +297,61 @@ export const RecipientStepList = ({ showAdvancedSettings }: RecipientStepListPro const isStepLocked = step.members.some((member) => !canSignerBeModified(member)); return ( -
- - - - {(draggableProvided, draggableSnapshot) => ( - - )} - -
+ + {(draggableProvided, draggableSnapshot) => ( + + )} + ); })} - - {provided.placeholder}
)} - {ccRecipients.map((signer) => ( -
- + {ccRecipients.length > 0 && ( +
+ + Receives Copy + + + {ccRecipients.map((signer) => ( +
+ +
+ ))}
- ))} + )} )}
diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-groups.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-groups.spec.ts index 484829609..f358e3c6e 100644 --- a/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-groups.spec.ts +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-groups.spec.ts @@ -5,6 +5,9 @@ import { clickAddSignerButton, dragGroupCardOntoCard, dragRecipientRowToGap, + getRecipientEmailInputs, + getRecipientStepCards, + moveGroupCardUp, openDocumentEnvelopeEditor, openTemplateEnvelopeEditor, setRecipientEmail, @@ -55,7 +58,7 @@ const runGroupingFlow = async (surface: TEnvelopeEditorSurface) => { // Drag carol's card onto bob's card to merge them into one group. await dragGroupCardOntoCard(root, 2, 1); - await expect(root.getByText('2 signers · any order')).toBeVisible(); + await expect(root.getByText('2 recipients · any order')).toBeVisible(); await expect(root.getByTestId('ungroup-step-button')).toBeVisible(); await expect(root.getByText('Group 3', { exact: true })).not.toBeVisible(); @@ -67,12 +70,12 @@ const runGroupingFlow = async (surface: TEnvelopeEditorSurface) => { // Groups survive a reload (grouped normalization on load). await root.reload(); - await expect(root.getByText('2 signers · any order')).toBeVisible(); + await expect(root.getByText('2 recipients · any order')).toBeVisible(); // Ungroup dissolves back into sequential groups. await root.getByTestId('ungroup-step-button').click(); - await expect(root.getByText('2 signers · any order')).not.toBeVisible(); + await expect(root.getByText('2 recipients · any order')).not.toBeVisible(); await expect(root.getByText('Group 3', { exact: true })).toBeVisible(); await expectRecipientOrders(surface, [ @@ -97,6 +100,32 @@ test.describe('document editor', () => { await runGroupingFlow(surface); }); + + test('documents: reordered group cards can still be dragged', async ({ page }) => { + const surface = await openDocumentEnvelopeEditor(page); + const { root } = surface; + + await setRecipientEmail(root, 0, 'alice@example.com'); + await clickAddSignerButton(root); + await setRecipientEmail(root, 1, 'bob@example.com'); + + await toggleSigningOrder(root, true); + await expect(getRecipientStepCards(root)).toHaveCount(2); + + // Move bob's card into position 1. + await moveGroupCardUp(root, 1); + + await expect(getRecipientEmailInputs(root).nth(0)).toHaveValue('bob@example.com'); + await expect(getRecipientEmailInputs(root).nth(1)).toHaveValue('alice@example.com'); + + // Regression: after a reorder, the card moved into position 2 must still + // be draggable — positional drag-and-drop ids used to go stale on mounted + // cards, silently killing their drag handles. Prove it by completing a + // merge with the repositioned card. + await dragGroupCardOntoCard(root, 1, 0); + + await expect(root.getByText('2 recipients · any order')).toBeVisible(); + }); }); test.describe('template editor', () => { diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-recipients.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipients.spec.ts index eaed260ea..881d2be60 100644 --- a/packages/app-tests/e2e/envelope-editor-v2/envelope-recipients.spec.ts +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipients.spec.ts @@ -112,6 +112,12 @@ const runRecipientFlow = async (surface: TEnvelopeEditorSurface): Promise { + if (!(await hasBecomeActive())) { + return false; + } + + await root.waitForTimeout(150); + + if (!(await hasBecomeActive())) { + return false; + } + + await root.mouse.up(); + + return true; + }; + + let hasDropped = false; + + // Crawl-and-drop: approach from above and inch downward through the + // corridor. Captured drop-target geometry can drift a few pixels from the + // live layout for small targets, so a slow traversal is the reliable way to + // hit them. const crawlX = targetBox.x + targetBox.width / 2 - itemOffsetX; const crawlStartY = Math.min(targetBox.y + targetBox.height / 2 - itemOffsetY - 140, maxCursorY); await root.mouse.move(crawlX, crawlStartY, { steps: 15 }); await root.waitForTimeout(150); - let hasDropped = false; - for (let step = 1; step <= 80; step += 1) { - if (await hasBecomeActive()) { - await root.mouse.up(); - + if (await confirmAndDrop()) { hasDropped = true; break; @@ -446,16 +462,65 @@ export const getStepDragHandles = (root: Page) => root.locator('[data-testid="st export const getRecipientRowDragHandles = (root: Page) => root.locator('[data-testid="recipient-row-drag-handle"]'); /** - * Drags a whole group card onto another card's centre, merging the two groups. + * Drags a whole group card onto another card, merging the two groups. + * + * Uses @hello-pangea/dnd's keyboard drag mode: mouse-emulated combines are + * unreliable because approaching a card traverses its reorder edge, which + * displaces the target away from the cursor. Keyboard drags step through + * positions (including combine states) deterministically. */ export const dragGroupCardOntoCard = async (root: Page, sourceCardIndex: number, targetCardIndex: number) => { - await dragHandleToTarget( - root, - getStepDragHandles(root).nth(sourceCardIndex), - getRecipientStepCards(root).nth(targetCardIndex), - // The combine/join highlight on the target card. - { activeClass: 'ring-primary' }, - ); + const handle = getStepDragHandles(root).nth(sourceCardIndex); + const target = getRecipientStepCards(root).nth(targetCardIndex); + + await handle.scrollIntoViewIfNeeded(); + await handle.focus(); + + // Lift. + await root.keyboard.press('Space'); + await root.waitForTimeout(250); + + const direction = targetCardIndex < sourceCardIndex ? 'ArrowUp' : 'ArrowDown'; + + for (let press = 0; press < 4; press += 1) { + await root.keyboard.press(direction); + await root.waitForTimeout(250); + + const targetClassName = await target.getAttribute('class'); + + if (targetClassName?.includes('ring-primary')) { + // Drop while the target reports the combine state. + await root.keyboard.press('Space'); + await root.waitForTimeout(400); + + return; + } + } + + await root.keyboard.press('Escape'); + + throw new Error('Combine drag did not reach the target card'); +}; + +/** + * Moves a group card one position up via keyboard drag. With combining + * enabled, the first ArrowUp enters the combine state with the card above and + * the second moves above it. + */ +export const moveGroupCardUp = async (root: Page, cardIndex: number) => { + const handle = getStepDragHandles(root).nth(cardIndex); + + await handle.scrollIntoViewIfNeeded(); + await handle.focus(); + + await root.keyboard.press('Space'); + await root.waitForTimeout(250); + await root.keyboard.press('ArrowUp'); + await root.waitForTimeout(250); + await root.keyboard.press('ArrowUp'); + await root.waitForTimeout(250); + await root.keyboard.press('Space'); + await root.waitForTimeout(400); }; /** @@ -467,8 +532,8 @@ export const dragRecipientRowToGap = async (root: Page, rowIndex: number, gapInd root, getRecipientRowDragHandles(root).nth(rowIndex), getRecipientStepGaps(root).nth(gapIndex), - // The drag-over highlight on the gap drop-zone. - { activeClass: 'border-primary' }, + // The marker class applied to a gap drop-zone while dragged over. + { activeClass: 'gap-active' }, ); }; diff --git a/packages/lib/client-only/hooks/use-editor-recipients.ts b/packages/lib/client-only/hooks/use-editor-recipients.ts index 88a0f1238..d889956da 100644 --- a/packages/lib/client-only/hooks/use-editor-recipients.ts +++ b/packages/lib/client-only/hooks/use-editor-recipients.ts @@ -88,6 +88,49 @@ export const ZEditorRecipientsFormSchema = z export type TEditorRecipientsFormSchema = z.infer; +/** + * Replaces the signers array while keeping controlled inputs in sync. + * + * Rows are rendered with stable `formId` keys (required for drag and drop), + * so react-hook-form `Controller`s never remount and their leaf + * subscriptions are NOT re-notified by a root-level array `setValue`. Any + * value that changes while a signer keeps its index (e.g. a role change) + * must be leaf-set first so the controlled input actually re-renders. + */ +export const updateEditorSigners = ( + form: UseFormReturn, + updatedSigners: TEditorRecipientsFormSchema['signers'], +) => { + const previousSigners = form.getValues('signers'); + + updatedSigners.forEach((signer, index) => { + const previousSigner = previousSigners[index]; + + // Only slot-stable signers need leaf notifications — moved signers get a + // new field name and re-subscribe with fresh values on their own. + if (!previousSigner || previousSigner.formId !== signer.formId) { + return; + } + + if (previousSigner.role !== signer.role) { + form.setValue(`signers.${index}.role`, signer.role, { shouldDirty: true }); + } + + if (previousSigner.email !== signer.email) { + form.setValue(`signers.${index}.email`, signer.email, { shouldDirty: true }); + } + + if (previousSigner.name !== signer.name) { + form.setValue(`signers.${index}.name`, signer.name, { shouldDirty: true }); + } + }); + + form.setValue('signers', updatedSigners, { + shouldValidate: true, + shouldDirty: true, + }); +}; + type EditorRecipientsProps = { envelope: TEditorEnvelope; };