mirror of
https://github.com/documenso/documenso.git
synced 2026-08-24 23:32:29 +10:00
feat: make recipient grouping drag-only with group badges
This commit is contained in:
@@ -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,
|
>
|
||||||
})}
|
<GripVerticalIcon
|
||||||
>
|
className={cn('h-5 w-5 flex-shrink-0 opacity-40', {
|
||||||
<span {...(dragHandleProps ?? {})} data-testid="recipient-row-drag-handle">
|
'opacity-10': !dragHandleProps,
|
||||||
<GripVerticalIcon
|
})}
|
||||||
className={cn('h-5 w-5 flex-shrink-0 opacity-40', {
|
/>
|
||||||
'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}
|
||||||
|
|||||||
Reference in New Issue
Block a user