diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx index 2dba30c18..b30026f16 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx @@ -5,7 +5,7 @@ import type { FieldType } from '@prisma/client'; import Konva from 'konva'; import type { KonvaEventObject } from 'konva/lib/Node'; 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 { 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 { getClientSideFieldTranslations } from '@documenso/lib/utils/fields'; import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients'; +import { CommandDialog } from '@documenso/ui/primitives/command'; import { fieldButtonList } from './envelope-editor-fields-drag-drop'; +import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector'; export default function EnvelopeEditorFieldsPageRenderer() { const { t, i18n } = useLingui(); @@ -468,6 +470,18 @@ export default function EnvelopeEditorFieldsPageRenderer() { 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 fields = selectedKonvaFieldGroups .map((field) => editorFields.getFieldByFormId(field.id())) @@ -552,7 +566,12 @@ export default function EnvelopeEditorFieldsPageRenderer() { {selectedKonvaFieldGroups.length > 0 && interactiveTransformer.current && !isFieldChanging && ( -
field.id())} style={{ position: 'absolute', top: @@ -569,35 +588,7 @@ export default function EnvelopeEditorFieldsPageRenderer() { pointerEvents: 'auto', zIndex: 50, }} - className="group flex items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5" - > - - - - - -
+ /> )} {pendingFieldCreation && ( @@ -644,3 +635,119 @@ export default function EnvelopeEditorFieldsPageRenderer() { ); } + +type FieldActionButtonsProps = React.HTMLAttributes & { + 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 ( +
+
+ + + + + + + +
+ + + { + editorFields.setSelectedRecipient(recipient.id); + handleChangeRecipient(recipient.id); + setShowRecipientSelector(false); + }} + recipients={envelope.recipients} + fields={envelope.fields} + /> + +
+ ); +}; diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx index ceb8e072a..e4b3bf82f 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx @@ -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 { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; -import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector'; import { Separator } from '@documenso/ui/primitives/separator'; 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 { EnvelopeRendererFileSelector } from './envelope-file-selector'; +import { EnvelopeRecipientSelector } from './envelope-recipient-selector'; const EnvelopeEditorFieldsPageRenderer = lazy( async () => import('./envelope-editor-fields-page-renderer'), @@ -164,12 +164,13 @@ export const EnvelopeEditorFieldsPage = () => { Selected Recipient - editorFields.setSelectedRecipient(recipient.id) } recipients={envelope.recipients} + fields={envelope.fields} className="w-full" align="end" /> diff --git a/apps/remix/app/components/general/envelope-editor/envelope-recipient-selector.tsx b/apps/remix/app/components/general/envelope-editor/envelope-recipient-selector.tsx new file mode 100644 index 000000000..5ebe602c9 --- /dev/null +++ b/apps/remix/app/components/general/envelope-editor/envelope-recipient-selector.tsx @@ -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 ( + + + + + + + { + onSelectedRecipientChange(recipient); + setShowRecipientsSelector(false); + }} + recipients={recipients} + /> + + + ); +}; + +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 = { + 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 ( + + + + + + No recipient matching this description was found. + + + + {recipientsByRoleToDisplay().map(([role, roleRecipients], roleIndex) => ( + +
+ {t(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)} +
+ + {roleRecipients.length === 0 && ( +
+ No recipients with this role +
+ )} + + {roleRecipients.map((recipient) => ( + 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); + } + }} + > + + {recipient.name && ( + + {recipient.name} ({recipient.email}) + + )} + + {!recipient.name && {recipient.email}} + + +
+ {!isRecipientDisabled(recipient.id) ? ( + + ) : ( + + + + + + + + This document has already been sent to this recipient. You can no longer + edit this recipient. + + + + )} +
+
+ ))} +
+ ))} +
+ ); +}; diff --git a/packages/lib/client-only/hooks/use-editor-fields.ts b/packages/lib/client-only/hooks/use-editor-fields.ts index 921699f2b..6b0b37776 100644 --- a/packages/lib/client-only/hooks/use-editor-fields.ts +++ b/packages/lib/client-only/hooks/use-editor-fields.ts @@ -170,10 +170,15 @@ export const useEditorFields = ({ ); 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) { - form.setValue(`fields.${index}.id`, id); + update(index, { + ...fields[index], + id, + }); } };