feat: make recipient grouping drag-only with group badges

This commit is contained in:
David Nguyen
2026-08-04 20:11:19 +10:00
parent f47c9a7402
commit 6a1236cac2
3 changed files with 34 additions and 122 deletions
@@ -11,7 +11,6 @@ import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipien
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form'; import { FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import type { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd'; import type { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { EnvelopeType, type RecipientRole } from '@prisma/client'; import { EnvelopeType, type RecipientRole } from '@prisma/client';
@@ -23,9 +22,7 @@ type TEditorSigner = TEditorRecipientsFormSchema['signers'][number];
export type RecipientRowProps = { export type RecipientRowProps = {
signerIndex: number; signerIndex: number;
signer: TEditorSigner; signer: TEditorSigner;
stepCount: number;
isSequential: boolean; isSequential: boolean;
isGrouped: boolean;
isInputDisabled: boolean; isInputDisabled: boolean;
canBeModified: boolean; canBeModified: boolean;
isRemoveDisabled: boolean; isRemoveDisabled: boolean;
@@ -33,7 +30,6 @@ export type RecipientRowProps = {
dragHandleProps?: DraggableProvidedDragHandleProps | null; dragHandleProps?: DraggableProvidedDragHandleProps | null;
recipientSuggestions: RecipientAutoCompleteOption[]; recipientSuggestions: RecipientAutoCompleteOption[];
isLoadingSuggestions: boolean; isLoadingSuggestions: boolean;
onSigningOrderChange: (signerIndex: number, value: string) => void;
onRoleChange: (signerIndex: number, role: RecipientRole) => void; onRoleChange: (signerIndex: number, role: RecipientRole) => void;
onRemove: (signerIndex: number) => void; onRemove: (signerIndex: number) => void;
onAutoCompleteSelect: (signerIndex: number, suggestion: RecipientAutoCompleteOption) => void; onAutoCompleteSelect: (signerIndex: number, suggestion: RecipientAutoCompleteOption) => void;
@@ -43,9 +39,7 @@ export type RecipientRowProps = {
export const RecipientRow = ({ export const RecipientRow = ({
signerIndex, signerIndex,
signer, signer,
stepCount,
isSequential, isSequential,
isGrouped,
isInputDisabled, isInputDisabled,
canBeModified, canBeModified,
isRemoveDisabled, isRemoveDisabled,
@@ -53,7 +47,6 @@ export const RecipientRow = ({
dragHandleProps, dragHandleProps,
recipientSuggestions, recipientSuggestions,
isLoadingSuggestions, isLoadingSuggestions,
onSigningOrderChange,
onRoleChange, onRoleChange,
onRemove, onRemove,
onAutoCompleteSelect, onAutoCompleteSelect,
@@ -81,52 +74,19 @@ export const RecipientRow = ({
<fieldset data-native-id={signer.id} disabled={isSubmitting || !canBeModified} className="py-1"> <fieldset data-native-id={signer.id} disabled={isSubmitting || !canBeModified} className="py-1">
<div className="flex flex-row items-center gap-x-2"> <div className="flex flex-row items-center gap-x-2">
{isSequential && !isCcRecipient(signer) && ( {isSequential && !isCcRecipient(signer) && (
<FormField <span
control={form.control} {...(dragHandleProps ?? {})}
name={`signers.${signerIndex}.signingOrder`} data-testid="recipient-row-drag-handle"
render={({ field }) => ( className={cn('mt-auto flex h-10 flex-shrink-0 items-center', {
<FormItem 'mb-6': rowErrors,
className={cn('mt-auto flex items-center gap-x-1 space-y-0', {
'mb-6': rowErrors && !rowErrors.signingOrder,
})} })}
> >
<span {...(dragHandleProps ?? {})} data-testid="recipient-row-drag-handle">
<GripVerticalIcon <GripVerticalIcon
className={cn('h-5 w-5 flex-shrink-0 opacity-40', { className={cn('h-5 w-5 flex-shrink-0 opacity-40', {
'opacity-10': !dragHandleProps, 'opacity-10': !dragHandleProps,
})} })}
/> />
</span> </span>
<FormControl>
<Input
type="number"
min={1}
max={stepCount + 1}
data-testid="signing-order-input"
className={cn(
'w-10 text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
{
'border-primary/50 bg-primary/5': isGrouped,
},
)}
{...field}
onChange={(e) => {
field.onChange(e);
onSigningOrderChange(signerIndex, e.target.value);
}}
onBlur={(e) => {
field.onBlur();
onSigningOrderChange(signerIndex, e.target.value);
}}
disabled={isFieldDisabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)} )}
<FormField <FormField
@@ -16,11 +16,9 @@ export type DraggingType = 'STEP' | 'RECIPIENT' | null;
export type RecipientStepCardSharedRowProps = Pick< export type RecipientStepCardSharedRowProps = Pick<
RecipientRowProps, RecipientRowProps,
| 'stepCount'
| 'showAdvancedSettings' | 'showAdvancedSettings'
| 'recipientSuggestions' | 'recipientSuggestions'
| 'isLoadingSuggestions' | 'isLoadingSuggestions'
| 'onSigningOrderChange'
| 'onRoleChange' | 'onRoleChange'
| 'onRemove' | 'onRemove'
| 'onAutoCompleteSelect' | 'onAutoCompleteSelect'
@@ -67,11 +65,12 @@ export const RecipientStepCard = ({
'pointer-events-none': draggableSnapshot.isDragging, 'pointer-events-none': draggableSnapshot.isDragging,
})} })}
> >
<Droppable {/*
droppableId={`step-members-${stepIndex}`} Note: `type="RECIPIENT"` already scopes this droppable to recipient-row
type="RECIPIENT" drags — `isDropDisabled` must not be toggled based on the active drag,
isDropDisabled={draggingType !== 'RECIPIENT'} as @hello-pangea/dnd snapshots it at drag start.
> */}
<Droppable droppableId={`step-members-${stepIndex}`} type="RECIPIENT">
{(droppableProvided, droppableSnapshot) => { {(droppableProvided, droppableSnapshot) => {
const isJoinTarget = draggingType === 'RECIPIENT' && droppableSnapshot.isDraggingOver; const isJoinTarget = draggingType === 'RECIPIENT' && droppableSnapshot.isDraggingOver;
const isHighlighted = isCombineTarget || isJoinTarget; const isHighlighted = isCombineTarget || isJoinTarget;
@@ -108,7 +107,7 @@ export const RecipientStepCard = ({
</span> </span>
<Badge variant="neutral" size="small"> <Badge variant="neutral" size="small">
<Trans>Step {step.order}</Trans> <Trans>Group {step.order}</Trans>
</Badge> </Badge>
{isGroup && ( {isGroup && (
@@ -156,7 +155,6 @@ export const RecipientStepCard = ({
signerIndex={signerIndex} signerIndex={signerIndex}
signer={member} signer={member}
isSequential={true} isSequential={true}
isGrouped={isGroup}
isInputDisabled={memberSnapshot.isDragging || draggableSnapshot.isDragging} isInputDisabled={memberSnapshot.isDragging || draggableSnapshot.isDragging}
canBeModified={canBeModified} canBeModified={canBeModified}
isRemoveDisabled={isRemoveDisabled} isRemoveDisabled={isRemoveDisabled}
@@ -10,7 +10,7 @@ import {
reorderStep, reorderStep,
ungroupStep, ungroupStep,
} from '@documenso/lib/utils/recipient-groups'; } from '@documenso/lib/utils/recipient-groups';
import { canEditorRecipientBeModified, isAssistantLastSigner, isCcRecipient } from '@documenso/lib/utils/recipients'; import { canEditorRecipientBeModified, isAssistantLastSigner } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { RecipientAutoCompleteOption } from '@documenso/ui/components/recipient/recipient-autocomplete-input'; import type { RecipientAutoCompleteOption } from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@@ -26,22 +26,26 @@ import { type DraggingType, RecipientStepCard } from './recipient-step-card';
type TEditorSigner = TEditorRecipientsFormSchema['signers'][number]; 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 }) => ( const RecipientStepGap = ({ gapIndex, draggingType }: { gapIndex: number; draggingType: DraggingType }) => (
<Droppable droppableId={`gap-${gapIndex}`} type="RECIPIENT" isDropDisabled={draggingType !== 'RECIPIENT'}> <Droppable droppableId={`gap-${gapIndex}`} type="RECIPIENT">
{(provided, snapshot) => ( {(provided, snapshot) => (
<div <div
ref={provided.innerRef} ref={provided.innerRef}
{...provided.droppableProps} {...provided.droppableProps}
data-testid="recipient-step-gap" data-testid="recipient-step-gap"
className={cn( className={cn('h-6 rounded-md transition-colors', {
'rounded-md transition-all', 'border border-dashed': draggingType === 'RECIPIENT',
draggingType === 'RECIPIENT' ? 'my-1 min-h-10 border border-dashed' : 'h-2',
{
'border-primary bg-primary/10': snapshot.isDraggingOver, 'border-primary bg-primary/10': snapshot.isDraggingOver,
}, })}
)}
> >
<div className="hidden">{provided.placeholder}</div> {provided.placeholder}
</div> </div>
)} )}
</Droppable> </Droppable>
@@ -81,7 +85,6 @@ export const RecipientStepList = ({ showAdvancedSettings }: RecipientStepListPro
const { steps, ccRecipients } = useMemo(() => groupRecipientsBySigningOrder(watchedSigners), [watchedSigners]); const { steps, ccRecipients } = useMemo(() => groupRecipientsBySigningOrder(watchedSigners), [watchedSigners]);
const stepCount = steps.length;
const isRemoveDisabled = watchedSigners.length === 1; const isRemoveDisabled = watchedSigners.length === 1;
const flatIndexByFormId = useMemo( const flatIndexByFormId = useMemo(
@@ -115,51 +118,6 @@ export const RecipientStepList = ({ showAdvancedSettings }: RecipientStepListPro
[form, t, toast], [form, t, toast],
); );
const handleSigningOrderChange = useCallback(
(signerIndex: number, newOrderString: string) => {
const trimmedOrderString = newOrderString.trim();
if (!trimmedOrderString) {
return;
}
const newOrder = Number(trimmedOrderString);
if (!Number.isInteger(newOrder) || newOrder < 1) {
return;
}
const currentSigners = form.getValues('signers');
const signer = currentSigners[signerIndex];
if (!signer || isCcRecipient(signer)) {
return;
}
const { steps: currentSteps } = groupRecipientsBySigningOrder(currentSigners);
const currentStepIndex = currentSteps.findIndex((step) =>
step.members.some((member) => member.formId === signer.formId),
);
const targetStepIndex = newOrder - 1;
if (targetStepIndex === currentStepIndex) {
return;
}
// Typing an existing step number joins that step's group; an
// out-of-bounds number extracts the recipient to a standalone step at
// the end.
const updatedSigners =
targetStepIndex >= currentSteps.length
? extractRecipientToNewStep(currentSigners, signer.formId, currentSteps.length, canSignerBeModified)
: moveRecipientToStep(currentSigners, signer.formId, targetStepIndex, canSignerBeModified);
applySigners(updatedSigners, { warnWhenAssistantLast: signer.role === RecipientRole.ASSISTANT });
},
[form, canSignerBeModified, applySigners],
);
const handleRoleChange = useCallback( const handleRoleChange = useCallback(
(signerIndex: number, role: RecipientRole) => { (signerIndex: number, role: RecipientRole) => {
const currentSigners = form.getValues('signers'); const currentSigners = form.getValues('signers');
@@ -291,11 +249,9 @@ export const RecipientStepList = ({ showAdvancedSettings }: RecipientStepListPro
); );
const sharedRowProps = { const sharedRowProps = {
stepCount,
showAdvancedSettings, showAdvancedSettings,
recipientSuggestions, recipientSuggestions,
isLoadingSuggestions: isLoading, isLoadingSuggestions: isLoading,
onSigningOrderChange: handleSigningOrderChange,
onRoleChange: handleRoleChange, onRoleChange: handleRoleChange,
onRemove: handleRemove, onRemove: handleRemove,
onAutoCompleteSelect: handleAutoCompleteSelect, onAutoCompleteSelect: handleAutoCompleteSelect,
@@ -324,7 +280,6 @@ export const RecipientStepList = ({ showAdvancedSettings }: RecipientStepListPro
signerIndex={index} signerIndex={index}
signer={signer} signer={signer}
isSequential={false} isSequential={false}
isGrouped={false}
isInputDisabled={false} isInputDisabled={false}
canBeModified={canSignerBeModified(signer)} canBeModified={canSignerBeModified(signer)}
isRemoveDisabled={isRemoveDisabled} isRemoveDisabled={isRemoveDisabled}
@@ -386,7 +341,6 @@ export const RecipientStepList = ({ showAdvancedSettings }: RecipientStepListPro
signerIndex={flatIndexByFormId.get(signer.formId) ?? -1} signerIndex={flatIndexByFormId.get(signer.formId) ?? -1}
signer={signer} signer={signer}
isSequential={true} isSequential={true}
isGrouped={false}
isInputDisabled={false} isInputDisabled={false}
canBeModified={canSignerBeModified(signer)} canBeModified={canSignerBeModified(signer)}
isRemoveDisabled={isRemoveDisabled} isRemoveDisabled={isRemoveDisabled}