mirror of
https://github.com/documenso/documenso.git
synced 2026-07-24 00:43:40 +10:00
242 lines
8.4 KiB
TypeScript
242 lines
8.4 KiB
TypeScript
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
|
import type { TEnvelopeRecipientLite } from '@documenso/lib/types/recipient';
|
|
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';
|
|
import type { I18n } from '@lingui/core';
|
|
import { msg } from '@lingui/core/macro';
|
|
import { Trans, useLingui } from '@lingui/react/macro';
|
|
import type { Field } from '@prisma/client';
|
|
import { RecipientRole, SendStatus } from '@prisma/client';
|
|
import { Check, ChevronsUpDown, Info } from 'lucide-react';
|
|
import { useCallback, useState } from 'react';
|
|
import { sortBy } from 'remeda';
|
|
|
|
export interface EnvelopeRecipientSelectorProps {
|
|
className?: string;
|
|
selectedRecipient: TEnvelopeRecipientLite | null;
|
|
onSelectedRecipientChange: (recipient: TEnvelopeRecipientLite) => void;
|
|
recipients: TEnvelopeRecipientLite[];
|
|
fields: Field[];
|
|
align?: 'center' | 'end' | 'start';
|
|
}
|
|
|
|
export const EnvelopeRecipientSelector = ({
|
|
className,
|
|
selectedRecipient,
|
|
onSelectedRecipientChange,
|
|
recipients,
|
|
fields,
|
|
align = 'start',
|
|
}: EnvelopeRecipientSelectorProps) => {
|
|
const { i18n } = useLingui();
|
|
|
|
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
|
|
|
|
const getRecipientLabel = useCallback(
|
|
(recipient: TEnvelopeRecipientLite) => extractRecipientLabel(recipient, recipients, i18n),
|
|
[recipients],
|
|
);
|
|
|
|
return (
|
|
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
role="combobox"
|
|
className={cn(
|
|
'justify-between bg-background font-normal text-muted-foreground hover:text-foreground',
|
|
getRecipientColorStyles(recipients.findIndex((r) => r.id === selectedRecipient?.id)).comboBoxTrigger,
|
|
className,
|
|
)}
|
|
>
|
|
{selectedRecipient && (
|
|
<span className="flex-1 truncate text-left">{getRecipientLabel(selectedRecipient)}</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: TEnvelopeRecipientLite | null;
|
|
onSelectedRecipientChange: (recipient: TEnvelopeRecipientLite) => void;
|
|
recipients: TEnvelopeRecipientLite[];
|
|
fields: Field[];
|
|
placeholder?: string;
|
|
}
|
|
|
|
export const EnvelopeRecipientSelectorCommand = ({
|
|
className,
|
|
selectedRecipient,
|
|
onSelectedRecipientChange,
|
|
recipients,
|
|
fields,
|
|
placeholder,
|
|
}: EnvelopeRecipientSelectorCommandProps) => {
|
|
const { t, i18n } = useLingui();
|
|
|
|
const recipientsByRole = useCallback(() => {
|
|
const recipientsByRole: Record<RecipientRole, TEnvelopeRecipientLite[]> = {
|
|
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, TEnvelopeRecipientLite[]],
|
|
);
|
|
}, [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],
|
|
);
|
|
|
|
const getRecipientLabel = useCallback(
|
|
(recipient: TEnvelopeRecipientLite) => extractRecipientLabel(recipient, recipients, i18n),
|
|
[recipients],
|
|
);
|
|
|
|
return (
|
|
<Command value={selectedRecipient ? selectedRecipient.id.toString() : undefined} className={className}>
|
|
<CommandInput placeholder={placeholder} />
|
|
|
|
<CommandEmpty>
|
|
<span className="inline-block px-4 text-muted-foreground">
|
|
<Trans>No recipient matching this description was found.</Trans>
|
|
</span>
|
|
</CommandEmpty>
|
|
|
|
{recipientsByRoleToDisplay().map(([role, roleRecipients], roleIndex) => (
|
|
<CommandGroup key={roleIndex}>
|
|
<div className="mt-2 mb-1 ml-2 font-medium text-muted-foreground text-xs">
|
|
{t(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
|
|
</div>
|
|
|
|
{roleRecipients.length === 0 && (
|
|
<div key={`${role}-empty`} className="px-4 pt-2.5 pb-4 text-center text-muted-foreground/80 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(recipients.findIndex((r) => r.id === recipient.id)).comboBoxItem,
|
|
{
|
|
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
|
|
'cursor-not-allowed': isRecipientDisabled(recipient.id),
|
|
},
|
|
)}
|
|
onSelect={() => {
|
|
if (!isRecipientDisabled(recipient.id)) {
|
|
onSelectedRecipientChange(recipient);
|
|
}
|
|
}}
|
|
>
|
|
<span
|
|
className={cn('truncate text-foreground/70', {
|
|
'text-foreground/80': recipient.id === selectedRecipient?.id,
|
|
'opacity-50': isRecipientDisabled(recipient.id),
|
|
})}
|
|
>
|
|
{getRecipientLabel(recipient)}
|
|
</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="max-w-xs text-muted-foreground">
|
|
<Trans>
|
|
This document has already been sent to this recipient. You can no longer edit this recipient.
|
|
</Trans>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
))}
|
|
</Command>
|
|
);
|
|
};
|
|
|
|
const extractRecipientLabel = (recipient: TEnvelopeRecipientLite, recipients: TEnvelopeRecipientLite[], i18n: I18n) => {
|
|
if (recipient.name && recipient.email) {
|
|
return `${recipient.name} (${recipient.email})`;
|
|
}
|
|
|
|
if (recipient.name) {
|
|
return recipient.name;
|
|
}
|
|
|
|
if (recipient.email) {
|
|
return recipient.email;
|
|
}
|
|
|
|
// Since objects are basically pointers we can use `indexOf` rather than `findIndex`
|
|
const index = recipients.indexOf(recipient);
|
|
|
|
return i18n._(msg`Recipient ${index + 1}`);
|
|
};
|