From 81809e5f6661e1bb22c4bebc12b132153f58604e Mon Sep 17 00:00:00 2001 From: Reese Herber Date: Wed, 3 Jun 2026 13:27:16 -0700 Subject: [PATCH] feat: add "download all" zip and scrollable list for envelope downloads (do-87) The envelope download dialog listed each document with its own download buttons but had no way to fetch them all at once, and the list had no height constraint so long lists (e.g. 12 documents) overflowed the dialog with no way to scroll to the clipped entries. - Add a dependency-free, store-mode ZIP writer (packages/lib/universal/zip.ts) used server-side to bundle an envelope's documents into a single archive. - Add authenticated and recipient-token "download-all" file routes that stream the bundled signed (or original) PDFs as a .zip named after the envelope. - Add a "Download all" action to the envelope download dialog and make the document list scrollable (max-height + overflow) so every document is reachable regardless of count. Closes #87 --- .../dialogs/envelope-download-dialog.tsx | 52 +++++- .../document/document-page-view-button.tsx | 1 + .../document/document-page-view-dropdown.tsx | 1 + .../tables/documents-table-action-button.tsx | 1 + .../documents-table-action-dropdown.tsx | 1 + .../app/components/tables/inbox-table.tsx | 1 + .../_recipient+/sign.$token+/complete.tsx | 1 + apps/remix/server/api/files/files.helpers.ts | 107 ++++++++++++ apps/remix/server/api/files/files.ts | 108 ++++++++++++- apps/remix/server/api/files/files.types.ts | 18 +++ .../lib/client-only/download-envelope-zip.ts | 48 ++++++ packages/lib/universal/zip.ts | 153 ++++++++++++++++++ packages/lib/utils/envelope-download.ts | 20 +++ 13 files changed, 510 insertions(+), 2 deletions(-) create mode 100644 packages/lib/client-only/download-envelope-zip.ts create mode 100644 packages/lib/universal/zip.ts diff --git a/apps/remix/app/components/dialogs/envelope-download-dialog.tsx b/apps/remix/app/components/dialogs/envelope-download-dialog.tsx index eb046ac446..811827f3f6 100644 --- a/apps/remix/app/components/dialogs/envelope-download-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-download-dialog.tsx @@ -5,6 +5,7 @@ import { Trans } from '@lingui/react/macro'; import { DocumentStatus, type EnvelopeItem } from '@prisma/client'; import { DownloadIcon, FileTextIcon } from 'lucide-react'; +import { downloadEnvelopeZip } from '@documenso/lib/client-only/download-envelope-zip'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; @@ -28,6 +29,11 @@ type EnvelopeDownloadDialogProps = { envelopeStatus: DocumentStatus; envelopeItems?: EnvelopeItemToDownload[]; + /** + * The envelope title, used to name the "download all" ZIP archive. + */ + envelopeTitle?: string; + /** * The recipient token to download the document. * @@ -48,6 +54,7 @@ export const EnvelopeDownloadDialog = ({ envelopeId, envelopeStatus, envelopeItems: initialEnvelopeItems, + envelopeTitle, token, canDownloadPartial = false, trigger, @@ -61,6 +68,8 @@ export const EnvelopeDownloadDialog = ({ [envelopeItemIdAndVersion: string]: boolean; }>({}); + const [isDownloadingAll, setIsDownloadingAll] = useState(false); + const generateDownloadKey = (envelopeItemId: string, version: DownloadVersion) => `${envelopeItemId}-${version}`; @@ -121,6 +130,34 @@ export const EnvelopeDownloadDialog = ({ } }; + const onDownloadAll = async () => { + if (isDownloadingAll) { + return; + } + + setIsDownloadingAll(true); + + try { + await downloadEnvelopeZip({ + envelopeId, + token, + fileName: envelopeTitle, + version: envelopeStatus === DocumentStatus.COMPLETED ? 'signed' : 'original', + }); + } catch (error) { + console.error(error); + + toast({ + title: t`Something went wrong`, + description: t`These documents could not be downloaded at this time. Please try again.`, + variant: 'destructive', + duration: 7500, + }); + } finally { + setIsDownloadingAll(false); + } + }; + return ( setOpen(value)}> {trigger} @@ -135,7 +172,20 @@ export const EnvelopeDownloadDialog = ({ -
+ {!isLoadingEnvelopeItems && envelopeItems.length > 1 && ( +
+

+ {t`${envelopeItems.length} documents`} +

+ + +
+ )} + +
{isLoadingEnvelopeItems ? ( <> {Array.from({ length: 1 }).map((_, index) => ( diff --git a/apps/remix/app/components/general/document/document-page-view-button.tsx b/apps/remix/app/components/general/document/document-page-view-button.tsx index e3e7e0afc2..9864d34a6f 100644 --- a/apps/remix/app/components/general/document/document-page-view-button.tsx +++ b/apps/remix/app/components/general/document/document-page-view-button.tsx @@ -74,6 +74,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps envelopeId={envelope.id} envelopeStatus={envelope.status} envelopeItems={envelope.envelopeItems} + envelopeTitle={envelope.title} token={recipient?.token} trigger={