feat: add ability to change field recipient (#2194)

## Description

Add ability to change recipients for a given field.

<img width="336" height="224" alt="image"
src="https://github.com/user-attachments/assets/c122672d-68ab-4652-9c76-1a1bc874c16a"
/>

<img width="716" height="465" alt="image"
src="https://github.com/user-attachments/assets/a8e8636c-c780-4d56-910b-5522161b12d3"
/>
This commit is contained in:
David Nguyen
2025-11-15 00:46:33 +11:00
committed by GitHub
parent 5811716d12
commit dabd2564cd
4 changed files with 400 additions and 35 deletions

View File

@ -5,7 +5,7 @@ import type { FieldType } from '@prisma/client';
import Konva from 'konva'; import Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node'; import type { KonvaEventObject } from 'konva/lib/Node';
import type { Transformer } from 'konva/lib/shapes/Transformer'; import type { Transformer } from 'konva/lib/shapes/Transformer';
import { CopyPlusIcon, SquareStackIcon, TrashIcon } from 'lucide-react'; import { CopyPlusIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide-react';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields'; import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer'; import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
@ -20,8 +20,10 @@ import {
import { renderField } from '@documenso/lib/universal/field-renderer/render-field'; import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields'; import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients'; import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { CommandDialog } from '@documenso/ui/primitives/command';
import { fieldButtonList } from './envelope-editor-fields-drag-drop'; import { fieldButtonList } from './envelope-editor-fields-drag-drop';
import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector';
export default function EnvelopeEditorFieldsPageRenderer() { export default function EnvelopeEditorFieldsPageRenderer() {
const { t, i18n } = useLingui(); const { t, i18n } = useLingui();
@ -468,6 +470,18 @@ export default function EnvelopeEditorFieldsPageRenderer() {
setSelectedFields([]); setSelectedFields([]);
}; };
const changeSelectedFieldsRecipients = (recipientId: number) => {
const fields = selectedKonvaFieldGroups
.map((field) => editorFields.getFieldByFormId(field.id()))
.filter((field) => field !== undefined);
for (const field of fields) {
if (field.recipientId !== recipientId) {
editorFields.updateFieldByFormId(field.formId, { recipientId, id: undefined });
}
}
};
const duplicatedSelectedFields = () => { const duplicatedSelectedFields = () => {
const fields = selectedKonvaFieldGroups const fields = selectedKonvaFieldGroups
.map((field) => editorFields.getFieldByFormId(field.id())) .map((field) => editorFields.getFieldByFormId(field.id()))
@ -552,7 +566,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
{selectedKonvaFieldGroups.length > 0 && {selectedKonvaFieldGroups.length > 0 &&
interactiveTransformer.current && interactiveTransformer.current &&
!isFieldChanging && ( !isFieldChanging && (
<div <FieldActionButtons
handleDuplicateSelectedFields={duplicatedSelectedFields}
handleDuplicateSelectedFieldsOnAllPages={duplicatedSelectedFieldsOnAllPages}
handleDeleteSelectedFields={deletedSelectedFields}
handleChangeRecipient={changeSelectedFieldsRecipients}
selectedFieldFormId={selectedKonvaFieldGroups.map((field) => field.id())}
style={{ style={{
position: 'absolute', position: 'absolute',
top: top:
@ -569,35 +588,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
pointerEvents: 'auto', pointerEvents: 'auto',
zIndex: 50, zIndex: 50,
}} }}
className="group flex items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5" />
>
<button
title={t`Duplicate`}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={() => duplicatedSelectedFields()}
onTouchEnd={() => duplicatedSelectedFields()}
>
<CopyPlusIcon className="h-3 w-3" />
</button>
<button
title={t`Duplicate on all pages`}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={() => duplicatedSelectedFieldsOnAllPages()}
onTouchEnd={() => duplicatedSelectedFieldsOnAllPages()}
>
<SquareStackIcon className="h-3 w-3" />
</button>
<button
title={t`Remove`}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={() => deletedSelectedFields()}
onTouchEnd={() => deletedSelectedFields()}
>
<TrashIcon className="h-3 w-3" />
</button>
</div>
)} )}
{pendingFieldCreation && ( {pendingFieldCreation && (
@ -644,3 +635,119 @@ export default function EnvelopeEditorFieldsPageRenderer() {
</div> </div>
); );
} }
type FieldActionButtonsProps = React.HTMLAttributes<HTMLDivElement> & {
handleDuplicateSelectedFields: () => void;
handleDuplicateSelectedFieldsOnAllPages: () => void;
handleDeleteSelectedFields: () => void;
handleChangeRecipient: (recipientId: number) => void;
selectedFieldFormId: string[];
};
const FieldActionButtons = ({
handleDuplicateSelectedFields,
handleDuplicateSelectedFieldsOnAllPages,
handleDeleteSelectedFields,
handleChangeRecipient,
selectedFieldFormId,
...props
}: FieldActionButtonsProps) => {
const { t } = useLingui();
const [showRecipientSelector, setShowRecipientSelector] = useState(false);
const { editorFields, envelope } = useCurrentEnvelopeEditor();
/**
* Decide the preselected recipient in the command input.
*
* If all fields belong to the same recipient then use that recipient as the default.
*
* Otherwise show the placeholder.
*/
const preselectedRecipient = useMemo(() => {
if (selectedFieldFormId.length === 0) {
return null;
}
const fields = editorFields.localFields.filter((field) =>
selectedFieldFormId.includes(field.formId),
);
const recipient = envelope.recipients.find(
(recipient) => recipient.id === fields[0].recipientId,
);
if (!recipient) {
return null;
}
const isRecipientsSame = fields.every((field) => field.recipientId === recipient.id);
if (isRecipientsSame) {
return recipient;
}
return null;
}, [editorFields.localFields]);
return (
<div className="flex flex-col items-center" {...props}>
<div className="group flex w-fit items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5">
<button
title={t`Change Recipient`}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={() => setShowRecipientSelector(true)}
onTouchEnd={() => setShowRecipientSelector(true)}
>
<UserCircleIcon className="h-3 w-3" />
</button>
<button
title={t`Duplicate`}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={handleDuplicateSelectedFields}
onTouchEnd={handleDuplicateSelectedFields}
>
<CopyPlusIcon className="h-3 w-3" />
</button>
<button
title={t`Duplicate on all pages`}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={handleDuplicateSelectedFieldsOnAllPages}
onTouchEnd={handleDuplicateSelectedFieldsOnAllPages}
>
<SquareStackIcon className="h-3 w-3" />
</button>
<button
title={t`Remove`}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={handleDeleteSelectedFields}
onTouchEnd={handleDeleteSelectedFields}
>
<TrashIcon className="h-3 w-3" />
</button>
</div>
<CommandDialog
position="start"
open={showRecipientSelector}
onOpenChange={setShowRecipientSelector}
>
<EnvelopeRecipientSelectorCommand
placeholder={t`Select a recipient`}
selectedRecipient={preselectedRecipient}
onSelectedRecipientChange={(recipient) => {
editorFields.setSelectedRecipient(recipient.id);
handleChangeRecipient(recipient.id);
setShowRecipientSelector(false);
}}
recipients={envelope.recipients}
fields={envelope.fields}
/>
</CommandDialog>
</div>
);
};

View File

@ -29,7 +29,6 @@ import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animat
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Separator } from '@documenso/ui/primitives/separator'; import { Separator } from '@documenso/ui/primitives/separator';
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form'; import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
@ -45,6 +44,7 @@ import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop'; import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
import { EnvelopeRendererFileSelector } from './envelope-file-selector'; import { EnvelopeRendererFileSelector } from './envelope-file-selector';
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
const EnvelopeEditorFieldsPageRenderer = lazy( const EnvelopeEditorFieldsPageRenderer = lazy(
async () => import('./envelope-editor-fields-page-renderer'), async () => import('./envelope-editor-fields-page-renderer'),
@ -164,12 +164,13 @@ export const EnvelopeEditorFieldsPage = () => {
<Trans>Selected Recipient</Trans> <Trans>Selected Recipient</Trans>
</h3> </h3>
<RecipientSelector <EnvelopeRecipientSelector
selectedRecipient={editorFields.selectedRecipient} selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) => onSelectedRecipientChange={(recipient) =>
editorFields.setSelectedRecipient(recipient.id) editorFields.setSelectedRecipient(recipient.id)
} }
recipients={envelope.recipients} recipients={envelope.recipients}
fields={envelope.fields}
className="w-full" className="w-full"
align="end" align="end"
/> />

View File

@ -0,0 +1,252 @@
import { useCallback, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client';
import { RecipientRole, SendStatus } from '@prisma/client';
import { Check, ChevronsUpDown, Info } from 'lucide-react';
import { sortBy } from 'remeda';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export interface EnvelopeRecipientSelectorProps {
className?: string;
selectedRecipient: Recipient | null;
onSelectedRecipientChange: (recipient: Recipient) => void;
recipients: Recipient[];
fields: Field[];
align?: 'center' | 'end' | 'start';
}
export const EnvelopeRecipientSelector = ({
className,
selectedRecipient,
onSelectedRecipientChange,
recipients,
fields,
align = 'start',
}: EnvelopeRecipientSelectorProps) => {
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
return (
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
className={cn(
'bg-background text-muted-foreground hover:text-foreground justify-between font-normal',
getRecipientColorStyles(
Math.max(
recipients.findIndex((r) => r.id === selectedRecipient?.id),
0,
),
).comboxBoxTrigger,
className,
)}
>
{selectedRecipient?.email && (
<span className="flex-1 truncate text-left">
{selectedRecipient?.name} ({selectedRecipient?.email})
</span>
)}
{!selectedRecipient?.email && (
<span className="flex-1 truncate text-left">{selectedRecipient?.email}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align={align}>
<EnvelopeRecipientSelectorCommand
fields={fields}
selectedRecipient={selectedRecipient}
onSelectedRecipientChange={(recipient) => {
onSelectedRecipientChange(recipient);
setShowRecipientsSelector(false);
}}
recipients={recipients}
/>
</PopoverContent>
</Popover>
);
};
interface EnvelopeRecipientSelectorCommandProps {
className?: string;
selectedRecipient: Recipient | null;
onSelectedRecipientChange: (recipient: Recipient) => void;
recipients: Recipient[];
fields: Field[];
placeholder?: string;
}
export const EnvelopeRecipientSelectorCommand = ({
className,
selectedRecipient,
onSelectedRecipientChange,
recipients,
fields,
placeholder,
}: EnvelopeRecipientSelectorCommandProps) => {
const { t } = useLingui();
const recipientsByRole = useCallback(() => {
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
CC: [],
VIEWER: [],
SIGNER: [],
APPROVER: [],
ASSISTANT: [],
};
recipients.forEach((recipient) => {
recipientsByRole[recipient.role].push(recipient);
});
return recipientsByRole;
}, [recipients]);
const recipientsByRoleToDisplay = useCallback(() => {
return Object.entries(recipientsByRole())
.filter(
([role]) =>
role !== RecipientRole.CC &&
role !== RecipientRole.VIEWER &&
role !== RecipientRole.ASSISTANT,
)
.map(
([role, roleRecipients]) =>
[
role,
sortBy(
roleRecipients,
[(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'],
[(r) => r.id, 'asc'],
),
] as [RecipientRole, Recipient[]],
);
}, [recipientsByRole]);
const isRecipientDisabled = useCallback(
(recipientId: number) => {
const recipient = recipients.find((r) => r.id === recipientId);
const recipientFields = fields.filter((f) => f.recipientId === recipientId);
return !recipient || !canRecipientFieldsBeModified(recipient, recipientFields);
},
[fields, recipients],
);
return (
<Command
value={selectedRecipient ? selectedRecipient.id.toString() : undefined}
className={className}
>
<CommandInput placeholder={placeholder} />
<CommandEmpty>
<span className="text-muted-foreground inline-block px-4">
<Trans>No recipient matching this description was found.</Trans>
</span>
</CommandEmpty>
{recipientsByRoleToDisplay().map(([role, roleRecipients], roleIndex) => (
<CommandGroup key={roleIndex}>
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
{t(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
</div>
{roleRecipients.length === 0 && (
<div
key={`${role}-empty`}
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
>
<Trans>No recipients with this role</Trans>
</div>
)}
{roleRecipients.map((recipient) => (
<CommandItem
key={recipient.id}
className={cn(
'px-2 last:mb-1 [&:not(:first-child)]:mt-1',
getRecipientColorStyles(
Math.max(
recipients.findIndex((r) => r.id === recipient.id),
0,
),
).comboxBoxItem,
{
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
'cursor-not-allowed': isRecipientDisabled(recipient.id),
},
)}
onSelect={() => {
if (!isRecipientDisabled(recipient.id)) {
onSelectedRecipientChange(recipient);
}
}}
>
<span
className={cn('text-foreground/70 truncate', {
'text-foreground/80': recipient.id === selectedRecipient?.id,
'opacity-50': isRecipientDisabled(recipient.id),
})}
>
{recipient.name && (
<span title={`${recipient.name} (${recipient.email})`}>
{recipient.name} ({recipient.email})
</span>
)}
{!recipient.name && <span title={recipient.email}>{recipient.email}</span>}
</span>
<div className="ml-auto flex items-center justify-center">
{!isRecipientDisabled(recipient.id) ? (
<Check
aria-hidden={recipient.id !== selectedRecipient?.id}
className={cn('h-4 w-4 flex-shrink-0', {
'opacity-0': recipient.id !== selectedRecipient?.id,
'opacity-100': recipient.id === selectedRecipient?.id,
})}
/>
) : (
<Tooltip>
<TooltipTrigger disabled={false}>
<Info className="z-50 ml-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<Trans>
This document has already been sent to this recipient. You can no longer
edit this recipient.
</Trans>
</TooltipContent>
</Tooltip>
)}
</div>
</CommandItem>
))}
</CommandGroup>
))}
</Command>
);
};

View File

@ -170,10 +170,15 @@ export const useEditorFields = ({
); );
const setFieldId = (formId: string, id: number) => { const setFieldId = (formId: string, id: number) => {
const index = localFields.findIndex((field) => field.formId === formId); const { fields } = form.getValues();
const index = fields.findIndex((field) => field.formId === formId);
if (index !== -1) { if (index !== -1) {
form.setValue(`fields.${index}.id`, id); update(index, {
...fields[index],
id,
});
} }
}; };