This commit is contained in:
David Nguyen
2026-08-05 14:13:22 +10:00
parent 7cdc423c42
commit 91f9a1c3e5
8 changed files with 368 additions and 148 deletions
@@ -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,
@@ -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 = ({
<span
{...(dragHandleProps ?? {})}
data-testid="recipient-row-drag-handle"
className={cn('mt-auto flex h-10 flex-shrink-0 items-center', {
'mb-6': rowErrors,
})}
className={cn(
'mt-auto -ml-1.5 flex h-10 w-8 flex-shrink-0 cursor-grab items-center justify-center rounded-md hover:bg-foreground/5 active:cursor-grabbing',
{
'mb-6': rowErrors,
'cursor-default hover:bg-transparent': !dragHandleProps,
},
)}
>
<GripVerticalIcon
className={cn('h-5 w-5 flex-shrink-0 opacity-40', {
@@ -218,3 +223,11 @@ export const RecipientRow = ({
</fieldset>
);
};
/**
* 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);
@@ -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<TEditorSigner>;
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 }) => (
<Droppable droppableId={droppableId} type="RECIPIENT">
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
data-testid="recipient-step-gap"
className={cn('flex h-6 items-center', {
'gap-active': snapshot.isDraggingOver,
})}
>
<div
className={cn('h-[3px] w-full rounded-full bg-primary opacity-0 transition-opacity duration-100', {
'opacity-100': snapshot.isDraggingOver,
})}
/>
{provided.placeholder}
</div>
)}
</Droppable>
);
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 (
<div
ref={draggableProvided.innerRef}
{...draggableProvided.draggableProps}
className={cn('py-1', {
style={getDraggableStyle(draggableProvided, draggableSnapshot)}
className={cn({
'pointer-events-none': draggableSnapshot.isDragging,
})}
>
{/*
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.
*/}
<Droppable droppableId={`step-members-${stepIndex}`} type="RECIPIENT">
<RecipientStepGap droppableId={`gap-${stepAnchor}`} />
<Droppable droppableId={`step-members-${stepAnchor}`} type="RECIPIENT">
{(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"
>
<Users2Icon className="h-3 w-3" />
<Trans>Release to sign together</Trans>
<Trans>Release to group</Trans>
</Badge>
)}
<div className="flex flex-row items-center gap-x-2">
<div className="flex flex-row items-center gap-x-1">
<span
{...(draggableProvided.dragHandleProps ?? {})}
data-testid="step-drag-handle"
className={cn({ 'pointer-events-none opacity-30': isStepLocked })}
className={cn(
'-my-1 -ml-1.5 flex h-8 w-8 flex-shrink-0 cursor-grab items-center justify-center rounded-md hover:bg-foreground/5 active:cursor-grabbing',
{ 'pointer-events-none opacity-30': isStepLocked },
)}
>
<GripVerticalIcon className="h-4 w-4 opacity-40" />
<GripVerticalIcon className="h-4 w-4 opacity-60" />
</span>
<Badge variant="neutral" size="small">
<Badge variant={isGroup ? 'default' : 'neutral'} size="small">
<Trans>Group {step.order}</Trans>
</Badge>
{isGroup && (
<>
<span className="flex items-center gap-x-1.5 text-muted-foreground text-xs">
<span className="ml-1 flex items-center gap-x-1.5 text-green-700 text-xs dark:text-green-400">
<Users2Icon className="h-3.5 w-3.5" />
<Trans>{step.members.length} signers · any order</Trans>
<Trans>{step.members.length} recipients · any order</Trans>
</span>
<Button
@@ -147,8 +214,9 @@ export const RecipientStepCard = ({
<div
ref={memberProvided.innerRef}
{...memberProvided.draggableProps}
style={getDraggableStyle(memberProvided, memberSnapshot)}
className={cn({
'rounded-md bg-widget-foreground': memberSnapshot.isDragging,
'rounded-md bg-widget-foreground shadow-lg': memberSnapshot.isDragging,
})}
>
<RecipientRow
@@ -172,6 +240,8 @@ export const RecipientStepCard = ({
);
}}
</Droppable>
{isLastStep && <RecipientStepGap droppableId="gap-end" />}
</div>
);
};
@@ -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 }) => (
<Droppable droppableId={`gap-${gapIndex}`} type="RECIPIENT">
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
data-testid="recipient-step-gap"
className={cn('h-6 rounded-md transition-colors', {
'border border-dashed': draggingType === 'RECIPIENT',
'border-primary bg-primary/10': snapshot.isDraggingOver,
})}
>
{provided.placeholder}
</div>
)}
</Droppable>
);
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 (
<div>
{!showAdvancedSettings && (
<div className={cn('mb-1 flex flex-row gap-x-2 text-sm', { 'pl-[4.75rem]': isSequential })}>
{!showAdvancedSettings && !isSequential && (
<div className="mb-1 flex flex-row gap-x-2 text-sm">
<span className="w-full">
<Trans>Email</Trans>
</span>
@@ -298,57 +297,61 @@ export const RecipientStepList = ({ showAdvancedSettings }: RecipientStepListPro
const isStepLocked = step.members.some((member) => !canSignerBeModified(member));
return (
<div key={`step-fragment-${step.members[0].formId}`}>
<RecipientStepGap gapIndex={stepIndex} draggingType={draggingType} />
<Draggable
draggableId={`step-${stepIndex}`}
index={stepIndex}
isDragDisabled={isSubmitting || isStepLocked}
>
{(draggableProvided, draggableSnapshot) => (
<RecipientStepCard
stepIndex={stepIndex}
step={step}
draggableProvided={draggableProvided}
draggableSnapshot={draggableSnapshot}
draggingType={draggingType}
isStepLocked={isStepLocked}
isRemoveDisabled={isRemoveDisabled}
flatIndexByFormId={flatIndexByFormId}
canSignerBeModified={canSignerBeModified}
isSubmitting={isSubmitting}
onUngroup={handleUngroup}
rowProps={sharedRowProps}
/>
)}
</Draggable>
</div>
<Draggable
key={`step-${step.members[0].formId}`}
draggableId={`step-${step.members[0].formId}`}
index={stepIndex}
isDragDisabled={isSubmitting || isStepLocked}
>
{(draggableProvided, draggableSnapshot) => (
<RecipientStepCard
stepIndex={stepIndex}
step={step}
isLastStep={stepIndex === steps.length - 1}
draggableProvided={draggableProvided}
draggableSnapshot={draggableSnapshot}
draggingType={draggingType}
isStepLocked={isStepLocked}
isRemoveDisabled={isRemoveDisabled}
flatIndexByFormId={flatIndexByFormId}
canSignerBeModified={canSignerBeModified}
isSubmitting={isSubmitting}
onUngroup={handleUngroup}
rowProps={sharedRowProps}
/>
)}
</Draggable>
);
})}
<RecipientStepGap gapIndex={steps.length} draggingType={draggingType} />
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
{ccRecipients.map((signer) => (
<div key={signer.formId} className="my-1 rounded-lg border px-3 py-1">
<RecipientRow
signerIndex={flatIndexByFormId.get(signer.formId) ?? -1}
signer={signer}
isSequential={true}
isInputDisabled={false}
canBeModified={canSignerBeModified(signer)}
isRemoveDisabled={isRemoveDisabled}
dragHandleProps={null}
{...sharedRowProps}
/>
{ccRecipients.length > 0 && (
<div className="my-1 rounded-lg border px-3 py-1.5">
<Badge variant="neutral" size="small">
<Trans>Receives Copy</Trans>
</Badge>
{ccRecipients.map((signer) => (
<div key={signer.formId} className="my-1">
<RecipientRow
signerIndex={flatIndexByFormId.get(signer.formId) ?? -1}
signer={signer}
isSequential={true}
isInputDisabled={false}
canBeModified={canSignerBeModified(signer)}
isRemoveDisabled={isRemoveDisabled}
dragHandleProps={null}
{...sharedRowProps}
/>
</div>
))}
</div>
))}
)}
</>
)}
</div>