From 5b63b5deb908d6292e1aee532401d8f06f50233f Mon Sep 17 00:00:00 2001 From: ephraimduncan Date: Mon, 20 Apr 2026 15:04:59 +0000 Subject: [PATCH] feat: bulk download documents --- .../envelopes-bulk-download-dialog.tsx | 286 ++++++++++++++++++ .../envelopes-table-bulk-action-bar.tsx | 11 +- .../t.$teamUrl+/documents._index.tsx | 22 ++ .../documents/bulk-document-actions.spec.ts | 40 +++ 4 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx diff --git a/apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx b/apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx new file mode 100644 index 000000000..bd217ac03 --- /dev/null +++ b/apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx @@ -0,0 +1,286 @@ +import { useEffect, useState } from 'react'; + +import { plural } from '@lingui/core/macro'; +import { Plural, useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import { DocumentStatus } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { match } from 'ts-pattern'; + +import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type EnvelopeBulkDownloadItem = { + id: string; + title: string; + status: DocumentStatus; +}; + +export type EnvelopesBulkDownloadDialogProps = { + envelopes: EnvelopeBulkDownloadItem[]; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; +} & Omit; + +type DownloadVersion = 'signed' | 'original'; + +export const EnvelopesBulkDownloadDialog = ({ + envelopes, + open, + onOpenChange, + onSuccess, + ...props +}: EnvelopesBulkDownloadDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [versionMap, setVersionMap] = useState>({}); + const [progress, setProgress] = useState(0); + const [isDownloading, setIsDownloading] = useState(false); + + const trpcUtils = trpc.useUtils(); + + useEffect(() => { + if (!open) { + return; + } + + setVersionMap( + Object.fromEntries( + envelopes.map((envelope) => [ + envelope.id, + envelope.status === DocumentStatus.COMPLETED ? 'signed' : 'original', + ]), + ), + ); + setProgress(0); + // Only reset selections/progress when the dialog transitions to open. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const getStatusLabel = (status: DocumentStatus) => + match(status) + .with(DocumentStatus.COMPLETED, () => t`Completed`) + .with(DocumentStatus.PENDING, () => t`Pending`) + .with(DocumentStatus.DRAFT, () => t`Draft`) + .with(DocumentStatus.REJECTED, () => t`Rejected`) + .exhaustive(); + + const onDownload = async () => { + if (envelopes.length === 0 || isDownloading) { + return; + } + + setIsDownloading(true); + setProgress(0); + + let successfulDownloads = 0; + let failedDownloads = 0; + + try { + for (let index = 0; index < envelopes.length; index++) { + const envelope = envelopes[index]; + + try { + const downloadVersion: DownloadVersion = + versionMap[envelope.id] === 'signed' && envelope.status === DocumentStatus.COMPLETED + ? 'signed' + : 'original'; + + const { data: envelopeItems } = await trpcUtils.envelope.item.getManyByToken.fetch({ + envelopeId: envelope.id, + access: { + type: 'user', + }, + }); + + for (const envelopeItem of envelopeItems) { + await downloadPDF({ + envelopeItem, + token: undefined, + fileName: envelopeItem.title, + version: downloadVersion, + }); + } + + successfulDownloads++; + } catch (error) { + console.error(error); + failedDownloads++; + } + + setProgress(index + 1); + } + + if (successfulDownloads === 0) { + toast({ + title: t`Error`, + description: t`An error occurred while downloading the documents.`, + variant: 'destructive', + }); + + return; + } + + if (failedDownloads > 0) { + toast({ + title: t`Documents partially downloaded`, + description: t`${plural(successfulDownloads, { + one: '# document downloaded.', + other: '# documents downloaded.', + })} ${plural(failedDownloads, { + one: '# document could not be downloaded.', + other: '# documents could not be downloaded.', + })}`, + variant: 'destructive', + }); + } else { + toast({ + title: t`Documents downloaded`, + description: plural(successfulDownloads, { + one: '# document has been downloaded.', + other: '# documents have been downloaded.', + }), + }); + } + + onSuccess?.(); + onOpenChange(false); + } catch (error) { + console.error(error); + + toast({ + title: t`Error`, + description: t`An error occurred while downloading the documents.`, + variant: 'destructive', + }); + } finally { + setIsDownloading(false); + } + }; + + return ( + { + if (!isDownloading) { + onOpenChange(value); + } + }} + > + + + + Download Documents + + + + + + + +
+
+ {envelopes.map((envelope) => { + const isCompleted = envelope.status === DocumentStatus.COMPLETED; + + return ( +
+
+

+ {envelope.title} +

+

+ {getStatusLabel(envelope.status)} +

+
+ + {isCompleted && ( + + )} +
+ ); + })} +
+ + {isDownloading && ( +

+ + Downloading {progress} / {envelopes.length}... + +

+ )} + + + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx b/apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx index a7e0efa25..105d1929e 100644 --- a/apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +++ b/apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx @@ -1,11 +1,12 @@ import { useLingui } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro'; -import { FolderInputIcon, Trash2Icon, XIcon } from 'lucide-react'; +import { DownloadIcon, FolderInputIcon, Trash2Icon, XIcon } from 'lucide-react'; import { Button } from '@documenso/ui/primitives/button'; export type EnvelopesTableBulkActionBarProps = { selectedCount: number; + onDownloadClick?: () => void; onMoveClick: () => void; onDeleteClick: () => void; onClearSelection: () => void; @@ -13,6 +14,7 @@ export type EnvelopesTableBulkActionBarProps = { export const EnvelopesTableBulkActionBar = ({ selectedCount, + onDownloadClick, onMoveClick, onDeleteClick, onClearSelection, @@ -36,6 +38,13 @@ export const EnvelopesTableBulkActionBar = ({ Move to Folder + {onDownloadClick && ( + + )} +