From efbe94aea81f61b319ba9507345c7d31d9e75fda Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 6 Nov 2024 21:34:06 +0900 Subject: [PATCH] feat: add signing link copy (#1449) --- .../[id]/document-page-view-dropdown.tsx | 18 +++ .../[id]/document-page-view-recipients.tsx | 106 +++++++----- .../documents/[id]/document-page-view.tsx | 5 + .../documents/data-table-action-dropdown.tsx | 18 ++- .../document-recipient-link-copy-dialog.tsx | 151 ++++++++++++++++++ packages/lib/utils/recipients.ts | 4 + .../ui/components/common/copy-text-button.tsx | 82 ++++++++++ 7 files changed, 341 insertions(+), 43 deletions(-) create mode 100644 apps/web/src/components/document/document-recipient-link-copy-dialog.tsx create mode 100644 packages/ui/components/common/copy-text-button.tsx diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx index ee86c17c5..a5852b40e 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx @@ -33,6 +33,8 @@ import { } from '@documenso/ui/primitives/dropdown-menu'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog'; + import { ResendDocumentActionItem } from '../_action-items/resend-document'; import { DeleteDocumentDialog } from '../delete-document-dialog'; import { DuplicateDocumentDialog } from '../duplicate-document-dialog'; @@ -62,6 +64,7 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro const isOwner = document.User.id === session.user.id; const isDraft = document.status === DocumentStatus.DRAFT; + const isPending = document.status === DocumentStatus.PENDING; const isDeleted = document.deletedAt !== null; const isComplete = document.status === DocumentStatus.COMPLETED; const isCurrentTeamDocument = team && document.team?.url === team.url; @@ -145,6 +148,21 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro Share + {canManageDocument && ( + e.preventDefault()} + > + + Signing Links + + } + /> + )} + { const { _ } = useLingui(); + const { toast } = useToast(); const recipients = document.Recipient; @@ -68,53 +74,69 @@ export const DocumentPageViewRecipients = ({ } /> - {document.status !== DocumentStatus.DRAFT && - recipient.signingStatus === SigningStatus.SIGNED && ( - - {match(recipient.role) - .with(RecipientRole.APPROVER, () => ( - <> - - Approved - - )) - .with(RecipientRole.CC, () => - document.status === DocumentStatus.COMPLETED ? ( - <> - - Sent - - ) : ( +
+ {document.status !== DocumentStatus.DRAFT && + recipient.signingStatus === SigningStatus.SIGNED && ( + + {match(recipient.role) + .with(RecipientRole.APPROVER, () => ( <> - Ready + Approved - ), - ) + )) + .with(RecipientRole.CC, () => + document.status === DocumentStatus.COMPLETED ? ( + <> + + Sent + + ) : ( + <> + + Ready + + ), + ) - .with(RecipientRole.SIGNER, () => ( - <> - - Signed - - )) - .with(RecipientRole.VIEWER, () => ( - <> - - Viewed - - )) - .exhaustive()} - - )} + .with(RecipientRole.SIGNER, () => ( + <> + + Signed + + )) + .with(RecipientRole.VIEWER, () => ( + <> + + Viewed + + )) + .exhaustive()} + + )} - {document.status !== DocumentStatus.DRAFT && - recipient.signingStatus === SigningStatus.NOT_SIGNED && ( - - - Pending - - )} + {document.status !== DocumentStatus.DRAFT && + recipient.signingStatus === SigningStatus.NOT_SIGNED && ( + + + Pending + + )} + + {document.status === DocumentStatus.PENDING && + recipient.signingStatus === SigningStatus.NOT_SIGNED && + recipient.role !== RecipientRole.CC && ( + { + toast({ + title: _(msg`Copied to clipboard`), + description: _(msg`The signing link has been copied to your clipboard.`), + }); + }} + /> + )} +
))} diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx index c827899a9..d4af4ee62 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx @@ -26,6 +26,7 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; import { DocumentHistorySheet } from '~/components/document/document-history-sheet'; import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields'; +import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog'; import { DocumentStatus as DocumentStatusComponent, FRIENDLY_STATUS_MAP, @@ -134,6 +135,10 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) return (
+ {document.status === DocumentStatus.PENDING && ( + + )} + Documents diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index a4595f2fd..4e76f4ef0 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -37,6 +37,8 @@ import { } from '@documenso/ui/primitives/dropdown-menu'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog'; + import { ResendDocumentActionItem } from './_action-items/resend-document'; import { DeleteDocumentDialog } from './delete-document-dialog'; import { DuplicateDocumentDialog } from './duplicate-document-dialog'; @@ -69,7 +71,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr const isOwner = row.User.id === session.user.id; // const isRecipient = !!recipient; const isDraft = row.status === DocumentStatus.DRAFT; - // const isPending = row.status === DocumentStatus.PENDING; + const isPending = row.status === DocumentStatus.PENDING; const isComplete = row.status === DocumentStatus.COMPLETED; // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const isCurrentTeamDocument = team && row.team?.url === team.url; @@ -191,6 +193,20 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr Share + {canManageDocument && ( + e.preventDefault()}> +
+ + Signing Links +
+ + } + /> + )} + { + const { _ } = useLingui(); + const { toast } = useToast(); + + const [, copy] = useCopyToClipboard(); + + const searchParams = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const [open, setOpen] = useState(false); + + const actionSearchParam = searchParams?.get('action'); + + const onBulkCopy = async () => { + const generatedString = recipients + .filter((recipient) => recipient.role !== RecipientRole.CC) + .map((recipient) => `${recipient.email}\n${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`) + .join('\n\n'); + + await copy(generatedString).then(() => { + toast({ + title: _(msg`Copied to clipboard`), + description: _(msg`All signing links have been copied to your clipboard.`), + }); + }); + }; + + useEffect(() => { + if (actionSearchParam === 'view-signing-links') { + setOpen(true); + updateSearchParams({ action: null }); + } + }, [actionSearchParam, open, setOpen, updateSearchParams]); + + return ( + setOpen(value)}> + e.stopPropagation()}> + {trigger} + + + + + + Copy Signing Links + + + + + You can copy and share these links to recipients so they can action the document. + + + + +
    + {recipients.length === 0 && ( +
  • + No recipients +
  • + )} + + {recipients.map((recipient) => ( +
  • + {recipient.email}

    } + secondaryText={ +

    + {_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)} +

    + } + /> + + {recipient.role !== RecipientRole.CC && ( + { + toast({ + title: _(msg`Copied to clipboard`), + description: _(msg`The signing link has been copied to your clipboard.`), + }); + }} + badgeContentUncopied={ +

    + Copy +

    + } + badgeContentCopied={ +

    + Copied +

    + } + /> + )} +
  • + ))} +
+ + + + + + + + +
+
+ ); +}; diff --git a/packages/lib/utils/recipients.ts b/packages/lib/utils/recipients.ts index 22afa451d..eab5f963c 100644 --- a/packages/lib/utils/recipients.ts +++ b/packages/lib/utils/recipients.ts @@ -1,5 +1,9 @@ import { type Field, type Recipient, RecipientRole, SigningStatus } from '@documenso/prisma/client'; +import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app'; + +export const formatSigningLink = (token: string) => `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${token}`; + /** * Whether a recipient can be modified by the document owner. */ diff --git a/packages/ui/components/common/copy-text-button.tsx b/packages/ui/components/common/copy-text-button.tsx new file mode 100644 index 000000000..608318690 --- /dev/null +++ b/packages/ui/components/common/copy-text-button.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useState } from 'react'; + +import { motion } from 'framer-motion'; +import { AnimatePresence } from 'framer-motion'; +import { CheckSquareIcon, CopyIcon } from 'lucide-react'; + +import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; +import { Button } from '@documenso/ui/primitives/button'; + +import { cn } from '../../lib/utils'; + +export type CopyTextButtonProps = { + value: string; + badgeContentUncopied?: React.ReactNode; + badgeContentCopied?: React.ReactNode; + onCopySuccess?: () => void; +}; + +export const CopyTextButton = ({ + value, + onCopySuccess, + badgeContentUncopied, + badgeContentCopied, +}: CopyTextButtonProps) => { + const [, copy] = useCopyToClipboard(); + + const [copiedTimeout, setCopiedTimeout] = useState(null); + + const onCopy = async () => { + await copy(value).then(() => onCopySuccess?.()); + + if (copiedTimeout) { + clearTimeout(copiedTimeout); + } + + setCopiedTimeout( + setTimeout(() => { + setCopiedTimeout(null); + }, 2000), + ); + }; + + return ( + + ); +};