feat: bulk download documents

This commit is contained in:
ephraimduncan
2026-04-20 15:04:59 +00:00
parent 198dafc8ec
commit 5b63b5deb9
4 changed files with 358 additions and 1 deletions
@@ -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<DialogPrimitive.DialogProps, 'children'>;
type DownloadVersion = 'signed' | 'original';
export const EnvelopesBulkDownloadDialog = ({
envelopes,
open,
onOpenChange,
onSuccess,
...props
}: EnvelopesBulkDownloadDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [versionMap, setVersionMap] = useState<Record<string, DownloadVersion>>({});
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 (
<Dialog
{...props}
open={open}
onOpenChange={(value) => {
if (!isDownloading) {
onOpenChange(value);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Download Documents</Trans>
</DialogTitle>
<DialogDescription>
<Plural
value={envelopes.length}
one="Select the version to download for the selected document."
other="Select the version to download for each of the # selected documents."
/>
</DialogDescription>
</DialogHeader>
<fieldset disabled={isDownloading} className="space-y-4">
<div className="max-h-96 space-y-2 overflow-y-auto">
{envelopes.map((envelope) => {
const isCompleted = envelope.status === DocumentStatus.COMPLETED;
return (
<div
key={envelope.id}
className="flex items-center gap-3 rounded-lg border border-border bg-card p-3"
>
<div className="min-w-0 flex-1">
<p
className="truncate text-sm font-medium text-foreground"
title={envelope.title}
>
{envelope.title}
</p>
<p className="text-xs text-muted-foreground">
{getStatusLabel(envelope.status)}
</p>
</div>
{isCompleted && (
<Select
value={versionMap[envelope.id] ?? 'signed'}
onValueChange={(value) => {
if (value === 'signed' || value === 'original') {
setVersionMap((prev) => ({ ...prev, [envelope.id]: value }));
}
}}
>
<SelectTrigger className="w-[120px] flex-shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="signed">
<Trans context="Signed document (adjective)">Signed</Trans>
</SelectItem>
<SelectItem value="original">
<Trans context="Original document (adjective)">Original</Trans>
</SelectItem>
</SelectContent>
</Select>
)}
</div>
);
})}
</div>
{isDownloading && (
<p className="text-sm text-muted-foreground">
<Trans>
Downloading {progress} / {envelopes.length}...
</Trans>
</p>
)}
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isDownloading}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
onClick={() => void onDownload()}
loading={isDownloading}
disabled={envelopes.length === 0}
>
<Trans>Download</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};
@@ -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 = ({
<Trans>Move to Folder</Trans>
</Button>
{onDownloadClick && (
<Button type="button" variant="outline" size="sm" onClick={onDownloadClick}>
<DownloadIcon className="mr-2 h-4 w-4" />
<Trans>Download</Trans>
</Button>
)}
<Button type="button" variant="destructive" size="sm" onClick={onDeleteClick}>
<Trash2Icon className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
@@ -25,6 +25,7 @@ import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
import { EnvelopesBulkDownloadDialog } from '~/components/dialogs/envelopes-bulk-download-dialog';
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
import { DocumentSearch } from '~/components/general/document/document-search';
import { DocumentStatus } from '~/components/general/document/document-status';
@@ -68,6 +69,7 @@ export default function DocumentsPage() {
);
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [isBulkDownloadDialogOpen, setIsBulkDownloadDialogOpen] = useState(false);
const selectedEnvelopeIds = useMemo(() => {
return Object.keys(rowSelection).filter((id) => rowSelection[id]);
@@ -97,6 +99,18 @@ export default function DocumentsPage() {
},
);
const selectedEnvelopesForDownload = useMemo(() => {
const selectedSet = new Set(selectedEnvelopeIds);
return (data?.data ?? [])
.filter((document) => selectedSet.has(document.envelopeId))
.map((document) => ({
id: document.envelopeId,
title: document.title,
status: document.status,
}));
}, [selectedEnvelopeIds, data?.data]);
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
const params = new URLSearchParams(searchParams);
@@ -243,11 +257,19 @@ export default function DocumentsPage() {
<EnvelopesTableBulkActionBar
selectedCount={selectedEnvelopeIds.length}
onDownloadClick={() => setIsBulkDownloadDialogOpen(true)}
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
onClearSelection={() => setRowSelection({})}
/>
<EnvelopesBulkDownloadDialog
envelopes={selectedEnvelopesForDownload}
open={isBulkDownloadDialogOpen}
onOpenChange={setIsBulkDownloadDialogOpen}
onSuccess={() => setRowSelection({})}
/>
<EnvelopesBulkMoveDialog
envelopeIds={selectedEnvelopeIds}
envelopeType={EnvelopeType.DOCUMENT}
@@ -111,6 +111,46 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page })
await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).toBeVisible();
});
test('[BULK_ACTIONS]: can bulk download multiple documents', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Download', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByText('Download Documents')).toBeVisible();
await expect(dialog.getByText('Bulk Test Doc 1')).toBeVisible();
await expect(dialog.getByText('Bulk Test Doc 2')).toBeVisible();
await expect(dialog.getByText('Draft').first()).toBeVisible();
const firstDownloadPromise = page.waitForEvent('download');
const secondDownloadPromise = page.waitForEvent('download');
await dialog.getByRole('button', { name: 'Download' }).click();
const [firstDownload, secondDownload] = await Promise.all([
firstDownloadPromise,
secondDownloadPromise,
]);
expect([firstDownload.suggestedFilename(), secondDownload.suggestedFilename()]).toEqual(
expect.arrayContaining(['Bulk Test Doc 1.pdf', 'Bulk Test Doc 2.pdf']),
);
await expectToastTextToBeVisible(page, 'Documents downloaded');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can delete multiple draft documents', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();