From cf78dc9d103e3c8463635758eff27d23924f64ba Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 4 Aug 2026 17:41:18 +1000 Subject: [PATCH] feat: add envelope editor recipient step list with group drag and drop --- .../envelope-editor/recipient-step-list.tsx | 402 ++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 apps/remix/app/components/general/envelope-editor/recipient-step-list.tsx 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 new file mode 100644 index 000000000..c934e3bbf --- /dev/null +++ b/apps/remix/app/components/general/envelope-editor/recipient-step-list.tsx @@ -0,0 +1,402 @@ +import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; +import type { TEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients'; +import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; +import { + extractRecipientToNewStep, + groupRecipientsBySigningOrder, + mergeSteps, + moveRecipientToStep, + normalizeGroupedSigningOrders, + reorderStep, + ungroupStep, +} from '@documenso/lib/utils/recipient-groups'; +import { canEditorRecipientBeModified, isAssistantLastSigner, isCcRecipient } 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 { 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]; + +const RecipientStepGap = ({ gapIndex, draggingType }: { gapIndex: number; draggingType: DraggingType }) => ( + + {(provided, snapshot) => ( +
+
{provided.placeholder}
+
+ )} +
+); + +export type RecipientStepListProps = { + showAdvancedSettings: boolean; +}; + +export const RecipientStepList = ({ showAdvancedSettings }: RecipientStepListProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const { envelope, editorRecipients, isEmbedded } = useCurrentEnvelopeEditor(); + const { form } = editorRecipients; + + const [draggingType, setDraggingType] = useState(null); + const [recipientSearchQuery, setRecipientSearchQuery] = useState(''); + + const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500); + + const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery( + { + query: debouncedRecipientSearchQuery, + }, + { + enabled: debouncedRecipientSearchQuery.length > 1 && !isEmbedded, + retry: false, + }, + ); + + const recipientSuggestions = recipientSuggestionsData?.results || []; + + const watchedSigners = form.watch('signers'); + const isSequential = form.watch('signingOrder') === DocumentSigningOrder.SEQUENTIAL; + const { isSubmitting } = form.formState; + + const { steps, ccRecipients } = useMemo(() => groupRecipientsBySigningOrder(watchedSigners), [watchedSigners]); + + const stepCount = steps.length; + const isRemoveDisabled = watchedSigners.length === 1; + + const flatIndexByFormId = useMemo( + () => new Map(watchedSigners.map((signer, index) => [signer.formId, index])), + [watchedSigners], + ); + + const canSignerBeModified = useCallback( + (signer: TEditorSigner) => canEditorRecipientBeModified(envelope, signer.id), + [envelope], + ); + + const applySigners = useCallback( + (updatedSigners: TEditorSigner[], options: { warnWhenAssistantLast?: boolean } = {}) => { + const { warnWhenAssistantLast = true } = options; + + form.setValue('signers', updatedSigners, { + shouldValidate: true, + shouldDirty: true, + }); + + if (warnWhenAssistantLast && isAssistantLastSigner(updatedSigners)) { + toast({ + title: t`Warning: Assistant as last signer`, + description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`, + }); + } + + void form.trigger('signers'); + }, + [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( + (signerIndex: number, role: RecipientRole) => { + const currentSigners = form.getValues('signers'); + const signingOrder = form.getValues('signingOrder'); + + if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) { + form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, { + shouldValidate: true, + shouldDirty: true, + }); + + toast({ + title: t`Signing order is enabled.`, + description: t`You cannot add assistants when signing order is disabled.`, + variant: 'destructive', + }); + + return; + } + + const updatedSigners = normalizeGroupedSigningOrders( + currentSigners.map((signer, index) => ({ + ...signer, + role: index === signerIndex ? role : signer.role, + })), + canSignerBeModified, + ); + + applySigners(updatedSigners, { warnWhenAssistantLast: role === RecipientRole.ASSISTANT }); + }, + [form, toast, t, canSignerBeModified, applySigners], + ); + + const handleRemove = useCallback( + (signerIndex: number) => { + const signer = form.getValues('signers')[signerIndex]; + + if (!signer) { + return; + } + + if (!canSignerBeModified(signer)) { + toast({ + title: t`Cannot remove signer`, + description: t`This signer has already signed the document.`, + variant: 'destructive', + }); + + return; + } + + const updatedSigners = normalizeGroupedSigningOrders( + form.getValues('signers').filter((s) => s.formId !== signer.formId), + canSignerBeModified, + ); + + applySigners(updatedSigners, { warnWhenAssistantLast: false }); + }, + [form, toast, t, canSignerBeModified, applySigners], + ); + + const handleUngroup = useCallback( + (stepIndex: number) => { + applySigners(ungroupStep(form.getValues('signers'), stepIndex, canSignerBeModified)); + }, + [form, canSignerBeModified, applySigners], + ); + + const handleAutoCompleteSelect = useCallback( + (signerIndex: number, suggestion: RecipientAutoCompleteOption) => { + form.setValue(`signers.${signerIndex}.email`, suggestion.email, { + shouldValidate: true, + shouldDirty: true, + }); + form.setValue(`signers.${signerIndex}.name`, suggestion.name || '', { + shouldValidate: true, + shouldDirty: true, + }); + }, + [form], + ); + + const onBeforeCapture = useCallback((before: BeforeCapture) => { + setDraggingType(before.draggableId.startsWith('step-') ? 'STEP' : 'RECIPIENT'); + }, []); + + const onDragEnd = useCallback( + (result: DropResult) => { + setDraggingType(null); + + const currentSigners = form.getValues('signers'); + + if (result.type === 'STEP') { + if (result.combine) { + const targetStepIndex = Number(result.combine.draggableId.slice('step-'.length)); + + applySigners(mergeSteps(currentSigners, result.source.index, targetStepIndex, canSignerBeModified)); + + return; + } + + if (result.destination) { + applySigners(reorderStep(currentSigners, result.source.index, result.destination.index, canSignerBeModified)); + } + + return; + } + + if (result.type === 'RECIPIENT' && result.destination) { + const formId = result.draggableId.slice('recipient-'.length); + const { droppableId } = result.destination; + + if (droppableId.startsWith('gap-')) { + const gapIndex = Number(droppableId.slice('gap-'.length)); + + applySigners(extractRecipientToNewStep(currentSigners, formId, gapIndex, canSignerBeModified)); + + return; + } + + if (droppableId.startsWith('step-members-')) { + const targetStepIndex = Number(droppableId.slice('step-members-'.length)); + + applySigners(moveRecipientToStep(currentSigners, formId, targetStepIndex, canSignerBeModified)); + } + } + }, + [form, canSignerBeModified, applySigners], + ); + + const sharedRowProps = { + stepCount, + showAdvancedSettings, + recipientSuggestions, + isLoadingSuggestions: isLoading, + onSigningOrderChange: handleSigningOrderChange, + onRoleChange: handleRoleChange, + onRemove: handleRemove, + onAutoCompleteSelect: handleAutoCompleteSelect, + onSearchQueryChange: setRecipientSearchQuery, + }; + + return ( +
+ {!showAdvancedSettings && ( +
+ + Email + + + Name + + +
+ )} + + {!isSequential ? ( +
+ {watchedSigners.map((signer, index) => ( + + ))} +
+ ) : ( + <> + + + {(provided) => ( +
+ {steps.map((step, stepIndex) => { + const isStepLocked = step.members.some((member) => !canSignerBeModified(member)); + + return ( +
+ + + + {(draggableProvided, draggableSnapshot) => ( + + )} + +
+ ); + })} + + + + {provided.placeholder} +
+ )} +
+
+ + {ccRecipients.map((signer) => ( +
+ +
+ ))} + + )} +
+ ); +};