mirror of
https://github.com/documenso/documenso.git
synced 2026-08-24 23:32:29 +10:00
248 lines
9.4 KiB
TypeScript
248 lines
9.4 KiB
TypeScript
import type { TEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
|
|
import type { RecipientStep } from '@documenso/lib/utils/recipient-groups';
|
|
import { cn } from '@documenso/ui/lib/utils';
|
|
import { Badge } from '@documenso/ui/primitives/badge';
|
|
import { Button } from '@documenso/ui/primitives/button';
|
|
import type { DraggableProvided, DraggableStateSnapshot } from '@hello-pangea/dnd';
|
|
import { Draggable, Droppable } from '@hello-pangea/dnd';
|
|
import { Trans } from '@lingui/react/macro';
|
|
import { GripVerticalIcon, Users2Icon } from 'lucide-react';
|
|
|
|
import { RecipientRow, type RecipientRowProps } from './recipient-row';
|
|
|
|
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'
|
|
| 'recipientSuggestions'
|
|
| 'isLoadingSuggestions'
|
|
| 'onRoleChange'
|
|
| 'onRemove'
|
|
| 'onAutoCompleteSelect'
|
|
| 'onSearchQueryChange'
|
|
>;
|
|
|
|
export type RecipientStepCardProps = {
|
|
stepIndex: number;
|
|
step: RecipientStep<TEditorSigner>;
|
|
isLastStep: boolean;
|
|
draggableProvided: DraggableProvided;
|
|
draggableSnapshot: DraggableStateSnapshot;
|
|
draggingType: DraggingType;
|
|
isStepLocked: boolean;
|
|
isRemoveDisabled: boolean;
|
|
flatIndexByFormId: Map<string, number>;
|
|
canSignerBeModified: (signer: TEditorSigner) => boolean;
|
|
isSubmitting: boolean;
|
|
onUngroup: (stepIndex: number) => void;
|
|
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,
|
|
isStepLocked,
|
|
isRemoveDisabled,
|
|
flatIndexByFormId,
|
|
canSignerBeModified,
|
|
isSubmitting,
|
|
onUngroup,
|
|
rowProps,
|
|
}: RecipientStepCardProps) => {
|
|
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}
|
|
style={getDraggableStyle(draggableProvided, draggableSnapshot)}
|
|
className={cn({
|
|
'pointer-events-none': draggableSnapshot.isDragging,
|
|
})}
|
|
>
|
|
<RecipientStepGap droppableId={`gap-${stepAnchor}`} />
|
|
|
|
<Droppable droppableId={`step-members-${stepAnchor}`} type="RECIPIENT">
|
|
{(droppableProvided, droppableSnapshot) => {
|
|
const isJoinTarget = draggingType === 'RECIPIENT' && droppableSnapshot.isDraggingOver;
|
|
const isHighlighted = isCombineTarget || isJoinTarget;
|
|
|
|
return (
|
|
<div
|
|
ref={droppableProvided.innerRef}
|
|
{...droppableProvided.droppableProps}
|
|
data-testid="recipient-step-card"
|
|
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 shadow-lg': draggableSnapshot.isDragging,
|
|
'border-primary ring-1 ring-primary': isHighlighted,
|
|
})}
|
|
>
|
|
{isHighlighted && (
|
|
<Badge
|
|
variant="default"
|
|
size="small"
|
|
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 group</Trans>
|
|
</Badge>
|
|
)}
|
|
|
|
<div className="flex flex-row items-center gap-x-1">
|
|
<span
|
|
{...(draggableProvided.dragHandleProps ?? {})}
|
|
data-testid="step-drag-handle"
|
|
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-60" />
|
|
</span>
|
|
|
|
<Badge variant={isGroup ? 'default' : 'neutral'} size="small">
|
|
<Trans>Group {step.order}</Trans>
|
|
</Badge>
|
|
|
|
{isGroup && (
|
|
<>
|
|
<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} recipients · any order</Trans>
|
|
</span>
|
|
|
|
<Button
|
|
type="button"
|
|
variant="link"
|
|
size="sm"
|
|
data-testid="ungroup-step-button"
|
|
className="ml-auto h-auto p-0 text-xs"
|
|
disabled={isStepLocked || isSubmitting}
|
|
onClick={() => onUngroup(stepIndex)}
|
|
>
|
|
<Trans>Ungroup</Trans>
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{step.members.map((member, memberIndex) => {
|
|
const signerIndex = flatIndexByFormId.get(member.formId) ?? -1;
|
|
const canBeModified = canSignerBeModified(member);
|
|
|
|
return (
|
|
<Draggable
|
|
key={member.formId}
|
|
draggableId={`recipient-${member.formId}`}
|
|
index={memberIndex}
|
|
isDragDisabled={isSubmitting || !canBeModified}
|
|
>
|
|
{(memberProvided, memberSnapshot) => (
|
|
<div
|
|
ref={memberProvided.innerRef}
|
|
{...memberProvided.draggableProps}
|
|
style={getDraggableStyle(memberProvided, memberSnapshot)}
|
|
className={cn({
|
|
'rounded-md bg-widget-foreground shadow-lg': memberSnapshot.isDragging,
|
|
})}
|
|
>
|
|
<RecipientRow
|
|
signerIndex={signerIndex}
|
|
signer={member}
|
|
isSequential={true}
|
|
isInputDisabled={memberSnapshot.isDragging || draggableSnapshot.isDragging}
|
|
canBeModified={canBeModified}
|
|
isRemoveDisabled={isRemoveDisabled}
|
|
dragHandleProps={memberProvided.dragHandleProps}
|
|
{...rowProps}
|
|
/>
|
|
</div>
|
|
)}
|
|
</Draggable>
|
|
);
|
|
})}
|
|
|
|
{droppableProvided.placeholder}
|
|
</div>
|
|
);
|
|
}}
|
|
</Droppable>
|
|
|
|
{isLastStep && <RecipientStepGap droppableId="gap-end" />}
|
|
</div>
|
|
);
|
|
};
|