mirror of
https://github.com/documenso/documenso.git
synced 2026-08-15 02:53:32 +10:00
feat: bulk download documents (#2711)
This commit is contained in:
@@ -0,0 +1,377 @@
|
|||||||
|
import {
|
||||||
|
createZipWriter,
|
||||||
|
sanitizeZipPathSegment,
|
||||||
|
type ZipFileEntry,
|
||||||
|
} from '@documenso/lib/client-only/create-zip-writer';
|
||||||
|
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||||
|
import { fetchPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { RadioGroupSegmented, RadioGroupSegmentedItem } from '@documenso/ui/primitives/radio-group';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
import { plural } from '@lingui/core/macro';
|
||||||
|
import { Plural, Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { DocumentStatus } from '@prisma/client';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum number of documents that can be downloaded in a single bulk
|
||||||
|
* download. Each document requires fetching its full PDFs into the browser,
|
||||||
|
* so this bounds both request volume and blob storage usage. Matches the
|
||||||
|
* spirit of the server-side 100 cap on bulk move/delete/cancel.
|
||||||
|
*/
|
||||||
|
export const MAX_BULK_DOWNLOAD_ENVELOPES = 50;
|
||||||
|
|
||||||
|
type BulkDownloadVersion = 'signed' | 'original' | 'pending';
|
||||||
|
|
||||||
|
export type EnvelopeBulkDownloadItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: DocumentStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the envelope is a legacy (v1) envelope. Legacy envelopes use a
|
||||||
|
* different field-rendering pipeline that the partial PDF helper does not
|
||||||
|
* implement, so the Partial option is hidden for them.
|
||||||
|
*/
|
||||||
|
isLegacy: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDefaultVersion = (envelope: EnvelopeBulkDownloadItem): BulkDownloadVersion =>
|
||||||
|
envelope.status === DocumentStatus.COMPLETED ? 'signed' : 'original';
|
||||||
|
|
||||||
|
export type EnvelopesBulkDownloadDialogProps = {
|
||||||
|
envelopes: EnvelopeBulkDownloadItem[];
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess?: (successfulEnvelopeIds: string[]) => void;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
export const EnvelopesBulkDownloadDialog = ({
|
||||||
|
envelopes,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
...props
|
||||||
|
}: EnvelopesBulkDownloadDialogProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [versionMap, setVersionMap] = useState<Record<string, BulkDownloadVersion>>({});
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
|
||||||
|
const abortRef = useRef(false);
|
||||||
|
|
||||||
|
const trpcUtils = trpc.useUtils();
|
||||||
|
|
||||||
|
const isOverDownloadLimit = envelopes.length > MAX_BULK_DOWNLOAD_ENVELOPES;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVersionMap(Object.fromEntries(envelopes.map((envelope) => [envelope.id, getDefaultVersion(envelope)])));
|
||||||
|
setProgress(0);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const getDownloadVersion = (envelope: EnvelopeBulkDownloadItem): BulkDownloadVersion =>
|
||||||
|
versionMap[envelope.id] ?? getDefaultVersion(envelope);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The version options selectable for an envelope, mirroring the gating used
|
||||||
|
* by the single envelope download dialog:
|
||||||
|
* - COMPLETED: signed or original.
|
||||||
|
* - PENDING (non-legacy): partial or original. Legacy envelopes use a
|
||||||
|
* field-rendering pipeline the partial PDF helper does not implement.
|
||||||
|
* - Anything else: original only, so no choice is shown.
|
||||||
|
*/
|
||||||
|
const getVersionOptions = (
|
||||||
|
envelope: EnvelopeBulkDownloadItem,
|
||||||
|
): { value: BulkDownloadVersion; label: string }[] | null => {
|
||||||
|
if (envelope.status === DocumentStatus.COMPLETED) {
|
||||||
|
return [
|
||||||
|
{ value: 'signed', label: t({ message: 'Signed', context: 'Signed document (adjective)' }) },
|
||||||
|
{ value: 'original', label: t({ message: 'Original', context: 'Original document (adjective)' }) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.status === DocumentStatus.PENDING && !envelope.isLegacy) {
|
||||||
|
return [
|
||||||
|
{ value: 'pending', label: t({ message: 'Partial', context: 'Partially signed document (adjective)' }) },
|
||||||
|
{ value: 'original', label: t({ message: 'Original', context: 'Original document (adjective)' }) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
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`)
|
||||||
|
.with(DocumentStatus.CANCELLED, () => t`Cancelled`)
|
||||||
|
.exhaustive();
|
||||||
|
|
||||||
|
const onDownload = async () => {
|
||||||
|
if (envelopes.length === 0 || isOverDownloadLimit || isDownloading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
abortRef.current = false;
|
||||||
|
setIsDownloading(true);
|
||||||
|
setProgress(0);
|
||||||
|
|
||||||
|
const zipWriter = createZipWriter();
|
||||||
|
|
||||||
|
const successfulEnvelopeIds: string[] = [];
|
||||||
|
let failedDownloads = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const envelope of envelopes) {
|
||||||
|
if (abortRef.current) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const downloadVersion = getDownloadVersion(envelope);
|
||||||
|
|
||||||
|
const { data: envelopeItems } = await trpcUtils.envelope.item.getManyByToken.fetch({
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
access: {
|
||||||
|
type: 'user',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Each envelope's items are grouped in their own folder. The id
|
||||||
|
// prefix guarantees uniqueness, the truncated title keeps it
|
||||||
|
// readable without risking overly long extraction paths.
|
||||||
|
const folderName = sanitizeZipPathSegment(`${envelope.id}_${envelope.title}`.slice(0, 96));
|
||||||
|
|
||||||
|
// Buffer this envelope's files before writing so a failed envelope
|
||||||
|
// is either fully in the zip or not at all. Files from previous
|
||||||
|
// envelopes have already been written to the zip stream and freed.
|
||||||
|
const envelopeFiles: ZipFileEntry[] = [];
|
||||||
|
|
||||||
|
for (const envelopeItem of envelopeItems) {
|
||||||
|
const { filename, blob } = await fetchPDF({
|
||||||
|
envelopeItem,
|
||||||
|
token: undefined,
|
||||||
|
fileName: envelopeItem.title,
|
||||||
|
version: downloadVersion,
|
||||||
|
});
|
||||||
|
|
||||||
|
envelopeFiles.push({
|
||||||
|
filename: `${folderName}/${sanitizeZipPathSegment(filename)}`,
|
||||||
|
data: blob,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of envelopeFiles) {
|
||||||
|
await zipWriter.addFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
successfulEnvelopeIds.push(envelope.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
failedDownloads++;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgress((p) => p + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user intentionally stopped the download, discard anything fetched
|
||||||
|
// so far without toasting an error.
|
||||||
|
if (abortRef.current) {
|
||||||
|
zipWriter.abort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successfulEnvelopeIds.length === 0) {
|
||||||
|
zipWriter.abort();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Error`,
|
||||||
|
description: t`An error occurred while downloading the documents.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
downloadFile({
|
||||||
|
filename: `documenso-documents-${new Date().toISOString().slice(0, 10)}.zip`,
|
||||||
|
data: zipWriter.finalize(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
zipWriter.abort();
|
||||||
|
|
||||||
|
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(successfulEnvelopeIds.length, {
|
||||||
|
one: '# document downloaded.',
|
||||||
|
other: '# documents downloaded.',
|
||||||
|
})} ${plural(failedDownloads, {
|
||||||
|
one: '# document could not be downloaded.',
|
||||||
|
other: '# documents could not be downloaded.',
|
||||||
|
})}`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
onSuccess?.(successfulEnvelopeIds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Documents downloaded`,
|
||||||
|
description: plural(successfulEnvelopeIds.length, {
|
||||||
|
one: '# document has been downloaded.',
|
||||||
|
other: '# documents have been downloaded.',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
onSuccess?.(successfulEnvelopeIds);
|
||||||
|
onOpenChange(false);
|
||||||
|
} 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>
|
||||||
|
|
||||||
|
{isOverDownloadLimit && (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans>
|
||||||
|
You can download up to {MAX_BULK_DOWNLOAD_ENVELOPES} documents at a time. Deselect some documents to
|
||||||
|
continue.
|
||||||
|
</Trans>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<fieldset disabled={isDownloading} className="space-y-4">
|
||||||
|
<div className="-mx-3 max-h-96 overflow-y-auto px-3">
|
||||||
|
<div className="divide-y divide-border rounded-lg border border-border">
|
||||||
|
{envelopes.map((envelope) => {
|
||||||
|
const versionOptions = getVersionOptions(envelope);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={envelope.id} className="flex items-center gap-3 px-3 py-2.5">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium text-foreground text-sm" title={envelope.title}>
|
||||||
|
{envelope.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">{getStatusLabel(envelope.status)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{versionOptions && (
|
||||||
|
<RadioGroupSegmented
|
||||||
|
className="shrink-0"
|
||||||
|
value={getDownloadVersion(envelope)}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setVersionMap((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[envelope.id]: value as BulkDownloadVersion,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
aria-label={t`Download version for ${envelope.title}`}
|
||||||
|
>
|
||||||
|
{versionOptions.map((option) => (
|
||||||
|
<RadioGroupSegmentedItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</RadioGroupSegmentedItem>
|
||||||
|
))}
|
||||||
|
</RadioGroupSegmented>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDownloading && (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
<Trans>
|
||||||
|
Downloading {progress} / {envelopes.length}...
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
if (isDownloading) {
|
||||||
|
abortRef.current = true;
|
||||||
|
} else {
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDownloading ? <Trans>Stop</Trans> : <Trans>Cancel</Trans>}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onDownload()}
|
||||||
|
loading={isDownloading}
|
||||||
|
disabled={envelopes.length === 0 || isOverDownloadLimit}
|
||||||
|
>
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
|
import { DownloadIcon, FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
export type EnvelopesTableBulkActionBarProps = {
|
export type EnvelopesTableBulkActionBarProps = {
|
||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
|
onDownloadClick?: () => void;
|
||||||
onMoveClick: () => void;
|
onMoveClick: () => void;
|
||||||
onDeleteClick: () => void;
|
onDeleteClick: () => void;
|
||||||
onCancelClick?: () => void;
|
onCancelClick?: () => void;
|
||||||
@@ -12,6 +14,7 @@ export type EnvelopesTableBulkActionBarProps = {
|
|||||||
|
|
||||||
export const EnvelopesTableBulkActionBar = ({
|
export const EnvelopesTableBulkActionBar = ({
|
||||||
selectedCount,
|
selectedCount,
|
||||||
|
onDownloadClick,
|
||||||
onMoveClick,
|
onMoveClick,
|
||||||
onDeleteClick,
|
onDeleteClick,
|
||||||
onCancelClick,
|
onCancelClick,
|
||||||
@@ -19,37 +22,106 @@ export const EnvelopesTableBulkActionBar = ({
|
|||||||
}: EnvelopesTableBulkActionBarProps) => {
|
}: EnvelopesTableBulkActionBarProps) => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
// Radix dismissable layers (dialogs, dropdowns, etc) call preventDefault
|
||||||
|
// when handling Escape, so this only clears the selection when nothing
|
||||||
|
// else consumed the key press.
|
||||||
|
if (event.key === 'Escape' && !event.defaultPrevented) {
|
||||||
|
onClearSelection();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [selectedCount, onClearSelection]);
|
||||||
|
|
||||||
if (selectedCount === 0) {
|
if (selectedCount === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-4 rounded-lg border border-border bg-background px-4 py-3 shadow-lg">
|
<div className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-1 rounded-xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-black/10 dark:ring-white/10">
|
||||||
<span className="font-medium text-sm">
|
<div className="flex items-center gap-x-2 px-2">
|
||||||
|
<span className="sr-only" aria-live="polite">
|
||||||
<Trans>{selectedCount} selected</Trans>
|
<Trans>{selectedCount} selected</Trans>
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex h-5 min-w-5 items-center justify-center rounded-md bg-primary px-1 font-semibold text-primary-foreground text-xs tabular-nums"
|
||||||
|
>
|
||||||
|
{selectedCount}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden="true" className="font-medium text-foreground text-sm max-[420px]:hidden">
|
||||||
|
<Trans>selected</Trans>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="h-6 w-px bg-border" />
|
<div className="mx-1 h-5 w-px bg-border" />
|
||||||
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={onMoveClick}>
|
<Button
|
||||||
<FolderInputIcon className="mr-2 h-4 w-4" />
|
type="button"
|
||||||
<Trans>Move to Folder</Trans>
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onMoveClick}
|
||||||
|
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
|
||||||
|
>
|
||||||
|
<FolderInputIcon className="size-4 shrink-0" />
|
||||||
|
<Trans>Move</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{onDownloadClick && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onDownloadClick}
|
||||||
|
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
|
||||||
|
>
|
||||||
|
<DownloadIcon className="size-4 shrink-0" />
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{onCancelClick && (
|
{onCancelClick && (
|
||||||
<Button type="button" variant="outline" size="sm" onClick={onCancelClick}>
|
<Button
|
||||||
<XCircleIcon className="mr-2 h-4 w-4" />
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onCancelClick}
|
||||||
|
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
|
||||||
|
>
|
||||||
|
<XCircleIcon className="size-4 shrink-0" />
|
||||||
<Trans>Cancel</Trans>
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button type="button" variant="destructive" size="sm" onClick={onDeleteClick}>
|
<Button
|
||||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onDeleteClick}
|
||||||
|
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-4 shrink-0" />
|
||||||
<Trans>Delete</Trans>
|
<Trans>Delete</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button variant="ghost" size="sm" onClick={onClearSelection} aria-label={t`Clear selection`}>
|
<div className="mx-1 h-5 w-px bg-border" />
|
||||||
<XIcon className="h-4 w-4" />
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClearSelection}
|
||||||
|
aria-label={t`Clear selection`}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<XIcon className="size-4 shrink-0" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,13 +14,22 @@ import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
|
|||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { EnvelopeType, FolderType, OrganisationType } from '@prisma/client';
|
import {
|
||||||
|
EnvelopeType,
|
||||||
|
FolderType,
|
||||||
|
OrganisationType,
|
||||||
|
type DocumentStatus as PrismaDocumentStatus,
|
||||||
|
} from '@prisma/client';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
|
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-dialog';
|
import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-dialog';
|
||||||
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
|
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
|
||||||
|
import {
|
||||||
|
type EnvelopeBulkDownloadItem,
|
||||||
|
EnvelopesBulkDownloadDialog,
|
||||||
|
} from '~/components/dialogs/envelopes-bulk-download-dialog';
|
||||||
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
|
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
|
||||||
import { DocumentSearch } from '~/components/general/document/document-search';
|
import { DocumentSearch } from '~/components/general/document/document-search';
|
||||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||||
@@ -38,6 +47,14 @@ export function meta() {
|
|||||||
return appMetaTags(msg`Documents`);
|
return appMetaTags(msg`Documents`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EnvelopeMetaCache = Record<string, { title: string; status: PrismaDocumentStatus; isLegacy: boolean }>;
|
||||||
|
|
||||||
|
// Stable initial values: `useSessionStorage` keeps its setter identity stable
|
||||||
|
// only while the initial value reference is stable, and the metadata cache
|
||||||
|
// effect below depends on that setter.
|
||||||
|
const EMPTY_ROW_SELECTION: RowSelectionState = {};
|
||||||
|
const EMPTY_ENVELOPE_META_CACHE: EnvelopeMetaCache = {};
|
||||||
|
|
||||||
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
||||||
status: true,
|
status: true,
|
||||||
period: true,
|
period: true,
|
||||||
@@ -61,9 +78,18 @@ export default function DocumentsPage() {
|
|||||||
const [isMovingDocument, setIsMovingDocument] = useState(false);
|
const [isMovingDocument, setIsMovingDocument] = useState(false);
|
||||||
const [documentToMove, setDocumentToMove] = useState<string | null>(null);
|
const [documentToMove, setDocumentToMove] = useState<string | null>(null);
|
||||||
|
|
||||||
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('documents-bulk-selection', {});
|
// Scoped by team so selections made in one team never leak into another.
|
||||||
|
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
|
||||||
|
`documents-bulk-selection-${team.id}`,
|
||||||
|
EMPTY_ROW_SELECTION,
|
||||||
|
);
|
||||||
|
const [envelopeMetaCache, setEnvelopeMetaCache] = useSessionStorage<EnvelopeMetaCache>(
|
||||||
|
`documents-bulk-selection-meta-${team.id}`,
|
||||||
|
EMPTY_ENVELOPE_META_CACHE,
|
||||||
|
);
|
||||||
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
|
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
|
||||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||||
|
const [isBulkDownloadDialogOpen, setIsBulkDownloadDialogOpen] = useState(false);
|
||||||
const [isBulkCancelDialogOpen, setIsBulkCancelDialogOpen] = useState(false);
|
const [isBulkCancelDialogOpen, setIsBulkCancelDialogOpen] = useState(false);
|
||||||
|
|
||||||
const selectedEnvelopeIds = useMemo(() => {
|
const selectedEnvelopeIds = useMemo(() => {
|
||||||
@@ -96,6 +122,51 @@ export default function DocumentsPage() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEnvelopeMetaCache((prev) => {
|
||||||
|
const next: EnvelopeMetaCache = {};
|
||||||
|
|
||||||
|
for (const id of Object.keys(prev)) {
|
||||||
|
if (rowSelection[id]) {
|
||||||
|
next[id] = prev[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const document of data?.data ?? []) {
|
||||||
|
if (rowSelection[document.envelopeId]) {
|
||||||
|
next[document.envelopeId] = {
|
||||||
|
title: document.title,
|
||||||
|
status: document.status,
|
||||||
|
isLegacy: document.internalVersion === 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [data?.data, rowSelection, setEnvelopeMetaCache]);
|
||||||
|
|
||||||
|
const selectedEnvelopesForDownload = useMemo(() => {
|
||||||
|
return selectedEnvelopeIds
|
||||||
|
.map((id): EnvelopeBulkDownloadItem | null => {
|
||||||
|
const meta = envelopeMetaCache[id];
|
||||||
|
|
||||||
|
if (!meta) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: meta.title,
|
||||||
|
status: meta.status,
|
||||||
|
// Stale cache entries predating this field are treated as legacy so
|
||||||
|
// the Partial option is never offered without certainty.
|
||||||
|
isLegacy: meta.isLegacy ?? true,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is EnvelopeBulkDownloadItem => item !== null);
|
||||||
|
}, [selectedEnvelopeIds, envelopeMetaCache]);
|
||||||
|
|
||||||
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
|
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
|
|
||||||
@@ -238,12 +309,28 @@ export default function DocumentsPage() {
|
|||||||
|
|
||||||
<EnvelopesTableBulkActionBar
|
<EnvelopesTableBulkActionBar
|
||||||
selectedCount={selectedEnvelopeIds.length}
|
selectedCount={selectedEnvelopeIds.length}
|
||||||
|
onDownloadClick={() => setIsBulkDownloadDialogOpen(true)}
|
||||||
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
|
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
|
||||||
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
|
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
|
||||||
onCancelClick={() => setIsBulkCancelDialogOpen(true)}
|
onCancelClick={() => setIsBulkCancelDialogOpen(true)}
|
||||||
onClearSelection={() => setRowSelection({})}
|
onClearSelection={() => setRowSelection({})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EnvelopesBulkDownloadDialog
|
||||||
|
envelopes={selectedEnvelopesForDownload}
|
||||||
|
open={isBulkDownloadDialogOpen}
|
||||||
|
onOpenChange={setIsBulkDownloadDialogOpen}
|
||||||
|
onSuccess={(successfulEnvelopeIds) => {
|
||||||
|
setRowSelection((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const id of successfulEnvelopeIds) {
|
||||||
|
delete next[id];
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<EnvelopesBulkMoveDialog
|
<EnvelopesBulkMoveDialog
|
||||||
envelopeIds={selectedEnvelopeIds}
|
envelopeIds={selectedEnvelopeIds}
|
||||||
envelopeType={EnvelopeType.DOCUMENT}
|
envelopeType={EnvelopeType.DOCUMENT}
|
||||||
|
|||||||
@@ -26,12 +26,14 @@ import { appMetaTags } from '~/utils/meta';
|
|||||||
|
|
||||||
const TEMPLATE_VIEWS = ['team', 'organisation'] as const;
|
const TEMPLATE_VIEWS = ['team', 'organisation'] as const;
|
||||||
|
|
||||||
type TemplateView = (typeof TEMPLATE_VIEWS)[number];
|
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return appMetaTags(msg`Templates`);
|
return appMetaTags(msg`Templates`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stable initial value: `useSessionStorage` keeps its setter identity stable
|
||||||
|
// only while the initial value reference is stable.
|
||||||
|
const EMPTY_ROW_SELECTION: RowSelectionState = {};
|
||||||
|
|
||||||
export default function TemplatesPage() {
|
export default function TemplatesPage() {
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
@@ -47,7 +49,11 @@ export default function TemplatesPage() {
|
|||||||
const isOrgView = view === 'organisation';
|
const isOrgView = view === 'organisation';
|
||||||
const showOrgTab = organisation.type !== OrganisationType.PERSONAL;
|
const showOrgTab = organisation.type !== OrganisationType.PERSONAL;
|
||||||
|
|
||||||
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('templates-bulk-selection', {});
|
// Scoped by team so selections made in one team never leak into another.
|
||||||
|
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
|
||||||
|
`templates-bulk-selection-${team.id}`,
|
||||||
|
EMPTY_ROW_SELECTION,
|
||||||
|
);
|
||||||
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
|
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
|
||||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
|||||||
Generated
+10
-3
@@ -22,6 +22,7 @@
|
|||||||
"@prisma/extension-read-replicas": "^0.4.1",
|
"@prisma/extension-read-replicas": "^0.4.1",
|
||||||
"ai": "^5.0.104",
|
"ai": "^5.0.104",
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
"posthog-node": "4.18.0",
|
"posthog-node": "4.18.0",
|
||||||
@@ -20080,9 +20081,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fflate": {
|
"node_modules/fflate": {
|
||||||
"version": "0.4.8",
|
"version": "0.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
"integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==",
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/file-selector": {
|
"node_modules/file-selector": {
|
||||||
@@ -26685,6 +26686,12 @@
|
|||||||
"web-vitals": "^4.2.4"
|
"web-vitals": "^4.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/posthog-js/node_modules/fflate": {
|
||||||
|
"version": "0.4.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz",
|
||||||
|
"integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/posthog-node": {
|
"node_modules/posthog-node": {
|
||||||
"version": "4.18.0",
|
"version": "4.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-4.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-4.18.0.tgz",
|
||||||
|
|||||||
@@ -94,6 +94,7 @@
|
|||||||
"@prisma/extension-read-replicas": "^0.4.1",
|
"@prisma/extension-read-replicas": "^0.4.1",
|
||||||
"ai": "^5.0.104",
|
"ai": "^5.0.104",
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
"posthog-node": "4.18.0",
|
"posthog-node": "4.18.0",
|
||||||
|
|||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { apiSignin } from '../../../fixtures/authentication';
|
||||||
|
|
||||||
|
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||||
|
|
||||||
|
test.describe.configure({
|
||||||
|
mode: 'parallel',
|
||||||
|
});
|
||||||
|
|
||||||
|
const downloadUrl = (envelopeId: string, envelopeItemId: string, version: 'original' | 'signed' | 'pending') =>
|
||||||
|
`${WEBAPP_BASE_URL}/api/files/envelope/${envelopeId}/envelopeItem/${envelopeItemId}/download/${version}`;
|
||||||
|
|
||||||
|
const seedOwnerWithDraft = async () => {
|
||||||
|
const owner = await seedUser();
|
||||||
|
|
||||||
|
const draft = await seedDraftDocument(owner.user, owner.team.id, [], {
|
||||||
|
createDocumentOptions: { title: 'File Download Auth Test' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { owner, draft, draftItem: draft.envelopeItems[0] };
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('Envelope item file download endpoint authorization', () => {
|
||||||
|
test('rejects an unauthenticated download request', async ({ request }) => {
|
||||||
|
const { draft, draftItem } = await seedOwnerWithDraft();
|
||||||
|
|
||||||
|
const res = await request.get(downloadUrl(draft.id, draftItem.id, 'original'));
|
||||||
|
|
||||||
|
expect(res.ok()).toBeFalsy();
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a download request from a user outside the organisation', async ({ page }) => {
|
||||||
|
const { draft, draftItem } = await seedOwnerWithDraft();
|
||||||
|
const { user: outsider } = await seedUser();
|
||||||
|
|
||||||
|
await apiSignin({ page, email: outsider.email });
|
||||||
|
|
||||||
|
const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'original'));
|
||||||
|
|
||||||
|
expect(res.ok()).toBeFalsy();
|
||||||
|
expect(res.status()).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 404 for a nonexistent envelope', async ({ page }) => {
|
||||||
|
const { user } = await seedUser();
|
||||||
|
|
||||||
|
await apiSignin({ page, email: user.email });
|
||||||
|
|
||||||
|
const res = await page.request.get(
|
||||||
|
downloadUrl('envelope_does_not_exist', 'envelope_item_does_not_exist', 'original'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.ok()).toBeFalsy();
|
||||||
|
expect(res.status()).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a pending version download for a draft envelope', async ({ page }) => {
|
||||||
|
const { owner, draft, draftItem } = await seedOwnerWithDraft();
|
||||||
|
|
||||||
|
await apiSignin({ page, email: owner.user.email });
|
||||||
|
|
||||||
|
const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'pending'));
|
||||||
|
|
||||||
|
expect(res.ok()).toBeFalsy();
|
||||||
|
expect(res.status()).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a pending version download for a legacy envelope', async ({ page }) => {
|
||||||
|
const owner = await seedUser();
|
||||||
|
const { user: recipient } = await seedUser();
|
||||||
|
|
||||||
|
// Default internalVersion is 1 (legacy).
|
||||||
|
const pendingDocument = await seedPendingDocument(owner.user, owner.team.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'Legacy Pending Download Test' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const envelopeItem = pendingDocument.envelopeItems[0];
|
||||||
|
|
||||||
|
await apiSignin({ page, email: owner.user.email });
|
||||||
|
|
||||||
|
const res = await page.request.get(downloadUrl(pendingDocument.id, envelopeItem.id, 'pending'));
|
||||||
|
|
||||||
|
expect(res.ok()).toBeFalsy();
|
||||||
|
expect(res.status()).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows the owner to download their own document', async ({ page }) => {
|
||||||
|
const { owner, draft, draftItem } = await seedOwnerWithDraft();
|
||||||
|
|
||||||
|
await apiSignin({ page, email: owner.user.email });
|
||||||
|
|
||||||
|
const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'original'));
|
||||||
|
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
expect(res.headers()['content-type']).toContain('application/pdf');
|
||||||
|
|
||||||
|
const body = await res.body();
|
||||||
|
|
||||||
|
// %PDF magic bytes.
|
||||||
|
expect(Array.from(body.subarray(0, 4))).toEqual([0x25, 0x50, 0x44, 0x46]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a recipient-token download with an invalid token', async ({ request }) => {
|
||||||
|
const { draftItem } = await seedOwnerWithDraft();
|
||||||
|
|
||||||
|
const res = await request.get(
|
||||||
|
`${WEBAPP_BASE_URL}/api/files/token/invalid-token-12345/envelopeItem/${draftItem.id}/download/original`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.ok()).toBeFalsy();
|
||||||
|
expect(res.status()).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import { createTeam } from '@documenso/lib/server-only/team/create-team';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||||
@@ -5,6 +7,7 @@ import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
|||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||||
|
import { unzipSync } from 'fflate';
|
||||||
|
|
||||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||||
@@ -50,10 +53,10 @@ test('[BULK_ACTIONS]: can select multiple documents with checkboxes', async ({ p
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||||
await expect(page.getByText('1 selected')).toBeVisible();
|
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
|
||||||
await expect(page.getByText('2 selected')).toBeVisible();
|
await expect(page.getByText(/2\s*selected/)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ page }) => {
|
test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ page }) => {
|
||||||
@@ -67,7 +70,7 @@ test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ p
|
|||||||
|
|
||||||
await page.locator('thead').getByRole('checkbox').click();
|
await page.locator('thead').getByRole('checkbox').click();
|
||||||
|
|
||||||
await expect(page.getByText(`${documents.length} selected`)).toBeVisible();
|
await expect(page.getByText(new RegExp(`${documents.length}\\s*selected`))).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
||||||
@@ -80,11 +83,11 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.locator('thead').getByRole('checkbox').click();
|
await page.locator('thead').getByRole('checkbox').click();
|
||||||
await expect(page.getByText(/\d+ selected/)).toBeVisible();
|
await expect(page.getByText(/\d+\s*selected/)).toBeVisible();
|
||||||
|
|
||||||
await page.getByLabel('Clear selection').click();
|
await page.getByLabel('Clear selection').click();
|
||||||
|
|
||||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page }) => {
|
test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page }) => {
|
||||||
@@ -98,13 +101,13 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page })
|
|||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
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.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
|
||||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
await expect(page.getByText('Move Documents to Folder')).toBeVisible();
|
await expect(page.getByText('Move Documents to Folder')).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: folder.name }).click();
|
await page.getByRole('button', { name: folder.name }).click();
|
||||||
await page.getByRole('button', { name: 'Move' }).click();
|
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||||
|
|
||||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||||
|
|
||||||
@@ -113,6 +116,122 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page })
|
|||||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).toBeVisible();
|
await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[BULK_ACTIONS]: selection does not leak between teams', async ({ page }) => {
|
||||||
|
const { sender } = await seedBulkActionsTestRequirements();
|
||||||
|
|
||||||
|
const teamBUrl = `team-b-${Date.now()}`;
|
||||||
|
|
||||||
|
await createTeam({
|
||||||
|
userId: sender.user.id,
|
||||||
|
teamName: 'Team B',
|
||||||
|
teamUrl: teamBUrl,
|
||||||
|
organisationId: sender.organisation.id,
|
||||||
|
inheritMembers: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamB = await prisma.team.findFirstOrThrow({
|
||||||
|
where: { url: teamBUrl },
|
||||||
|
});
|
||||||
|
|
||||||
|
await seedDraftDocument(sender.user, teamB.id, [], {
|
||||||
|
createDocumentOptions: { title: 'Team B Doc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
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 expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||||
|
|
||||||
|
// The selection made in team A must not appear in team B.
|
||||||
|
await page.goto(`/t/${teamBUrl}/documents`);
|
||||||
|
await expect(page.getByRole('link', { name: 'Team B Doc' })).toBeVisible();
|
||||||
|
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||||
|
|
||||||
|
// Returning to team A restores its selection.
|
||||||
|
await page.goto(`/t/${sender.team.url}/documents`);
|
||||||
|
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[BULK_ACTIONS]: escape clears selection unless a dialog is open', 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 expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||||
|
|
||||||
|
// Escape while a dialog is open should close the dialog but keep the selection.
|
||||||
|
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||||
|
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||||
|
|
||||||
|
// Escape with no dialog open should clear the selection.
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
await expect(page.getByText(/1\s*selected/)).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[BULK_ACTIONS]: can bulk download multiple documents as a zip', async ({ page }) => {
|
||||||
|
const { sender, documents } = await seedBulkActionsTestRequirements();
|
||||||
|
|
||||||
|
const [doc1, doc2] = documents;
|
||||||
|
|
||||||
|
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 downloadPromise = page.waitForEvent('download', { timeout: 10_000 });
|
||||||
|
|
||||||
|
await dialog.getByRole('button', { name: 'Download' }).click();
|
||||||
|
|
||||||
|
const download = await downloadPromise;
|
||||||
|
|
||||||
|
expect(download.suggestedFilename()).toMatch(/^documenso-documents-\d{4}-\d{2}-\d{2}\.zip$/);
|
||||||
|
|
||||||
|
const downloadPath = await download.path();
|
||||||
|
const zipContents = unzipSync(new Uint8Array(fs.readFileSync(downloadPath)));
|
||||||
|
|
||||||
|
// Each envelope's files are nested inside an `envelopeId_title` folder.
|
||||||
|
expect(Object.keys(zipContents).sort()).toEqual(
|
||||||
|
[`${doc1.id}_Bulk Test Doc 1/Bulk Test Doc 1.pdf`, `${doc2.id}_Bulk Test Doc 2/Bulk Test Doc 2.pdf`].sort(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Each entry should be a valid non-empty PDF (%PDF magic bytes).
|
||||||
|
for (const entry of Object.values(zipContents)) {
|
||||||
|
expect(Array.from(entry.slice(0, 4))).toEqual([0x25, 0x50, 0x44, 0x46]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await expectToastTextToBeVisible(page, 'Documents downloaded');
|
||||||
|
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('[BULK_ACTIONS]: can delete multiple draft documents', async ({ page }) => {
|
test('[BULK_ACTIONS]: can delete multiple draft documents', async ({ page }) => {
|
||||||
const { sender } = await seedBulkActionsTestRequirements();
|
const { sender } = await seedBulkActionsTestRequirements();
|
||||||
|
|
||||||
@@ -152,14 +271,14 @@ test('[BULK_ACTIONS]: selection clears after successful move', async ({ page })
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||||
await expect(page.getByText('1 selected')).toBeVisible();
|
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||||
await page.getByRole('button', { name: folder.name }).click();
|
await page.getByRole('button', { name: folder.name }).click();
|
||||||
await page.getByRole('button', { name: 'Move' }).click();
|
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||||
|
|
||||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
|
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
|
||||||
@@ -172,13 +291,13 @@ test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||||
await expect(page.getByText('1 selected')).toBeVisible();
|
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Delete' }).click();
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
|
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
|
||||||
|
|
||||||
await expectToastTextToBeVisible(page, 'Documents deleted');
|
await expectToastTextToBeVisible(page, 'Documents deleted');
|
||||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
|
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
|
||||||
@@ -199,7 +318,7 @@ test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) =
|
|||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
|
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
|
||||||
@@ -236,14 +355,14 @@ test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ p
|
|||||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
|
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
|
||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||||
await expect(page.getByText('1 selected')).toBeVisible();
|
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
|
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Move' }).click();
|
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||||
|
|
||||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||||
|
|
||||||
|
|||||||
@@ -49,10 +49,10 @@ test('[BULK_ACTIONS]: can select multiple templates with checkboxes', async ({ p
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||||
await expect(page.getByText('1 selected')).toBeVisible();
|
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
|
||||||
await expect(page.getByText('2 selected')).toBeVisible();
|
await expect(page.getByText(/2\s*selected/)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ page }) => {
|
test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ page }) => {
|
||||||
@@ -66,7 +66,7 @@ test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ p
|
|||||||
|
|
||||||
await page.locator('thead').getByRole('checkbox').click();
|
await page.locator('thead').getByRole('checkbox').click();
|
||||||
|
|
||||||
await expect(page.getByText(`${templates.length} selected`)).toBeVisible();
|
await expect(page.getByText(new RegExp(`${templates.length}\\s*selected`))).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
||||||
@@ -79,11 +79,11 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.locator('thead').getByRole('checkbox').click();
|
await page.locator('thead').getByRole('checkbox').click();
|
||||||
await expect(page.getByText(/\d+ selected/)).toBeVisible();
|
await expect(page.getByText(/\d+\s*selected/)).toBeVisible();
|
||||||
|
|
||||||
await page.getByLabel('Clear selection').click();
|
await page.getByLabel('Clear selection').click();
|
||||||
|
|
||||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page }) => {
|
test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page }) => {
|
||||||
@@ -97,13 +97,13 @@ test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page })
|
|||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
|
||||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
await expect(page.getByText('Move Templates to Folder')).toBeVisible();
|
await expect(page.getByText('Move Templates to Folder')).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: folder.name }).click();
|
await page.getByRole('button', { name: folder.name }).click();
|
||||||
await page.getByRole('button', { name: 'Move' }).click();
|
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||||
|
|
||||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||||
|
|
||||||
@@ -151,14 +151,14 @@ test('[BULK_ACTIONS]: selection clears after successful move', async ({ page })
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||||
await expect(page.getByText('1 selected')).toBeVisible();
|
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||||
await page.getByRole('button', { name: folder.name }).click();
|
await page.getByRole('button', { name: folder.name }).click();
|
||||||
await page.getByRole('button', { name: 'Move' }).click();
|
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||||
|
|
||||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
|
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
|
||||||
@@ -171,13 +171,13 @@ test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||||
await expect(page.getByText('1 selected')).toBeVisible();
|
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Delete' }).click();
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
|
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
|
||||||
|
|
||||||
await expectToastTextToBeVisible(page, 'Templates deleted');
|
await expectToastTextToBeVisible(page, 'Templates deleted');
|
||||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
|
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
|
||||||
@@ -199,7 +199,7 @@ test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) =
|
|||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
|
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
|
||||||
@@ -236,14 +236,14 @@ test('[BULK_ACTIONS]: can move templates from folder to home (root)', async ({ p
|
|||||||
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
|
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
|
||||||
|
|
||||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||||
await expect(page.getByText('1 selected')).toBeVisible();
|
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
|
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Move' }).click();
|
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||||
|
|
||||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,9 @@
|
|||||||
"@playwright/test": "1.56.1",
|
"@playwright/test": "1.56.1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pngjs": "^6.0.5",
|
"@types/pngjs": "^6.0.5",
|
||||||
"tsx": "^4.23.1",
|
|
||||||
"pixelmatch": "^7.1.0",
|
"pixelmatch": "^7.1.0",
|
||||||
"pngjs": "^7.0.0"
|
"pngjs": "^7.0.0",
|
||||||
|
"tsx": "^4.23.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"start-server-and-test": "^2.1.3"
|
"start-server-and-test": "^2.1.3"
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { Zip, ZipPassThrough } from 'fflate';
|
||||||
|
|
||||||
|
export type ZipFileEntry = {
|
||||||
|
/**
|
||||||
|
* The path of the file within the archive. Forward slashes create folders.
|
||||||
|
* Individual path segments should be sanitized with
|
||||||
|
* {@link sanitizeZipPathSegment} when derived from user-controlled values.
|
||||||
|
*/
|
||||||
|
filename: string;
|
||||||
|
data: Blob;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes a single path segment (folder or file name) for use inside a zip
|
||||||
|
* archive, replacing characters that are path separators or invalid on
|
||||||
|
* Windows extraction.
|
||||||
|
*/
|
||||||
|
export const sanitizeZipPathSegment = (segment: string): string => {
|
||||||
|
const sanitized = segment
|
||||||
|
.replace(/[\\/:*?"<>|\p{Cc}]/gu, '-')
|
||||||
|
.trim()
|
||||||
|
// Windows cannot extract folders or files ending with a dot.
|
||||||
|
.replace(/\.+$/, '');
|
||||||
|
|
||||||
|
return sanitized || 'untitled';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ZipWriter = {
|
||||||
|
/**
|
||||||
|
* Adds a file to the zip stream. Files are written incrementally so the
|
||||||
|
* input blob can be garbage collected once this resolves.
|
||||||
|
*/
|
||||||
|
addFile: (entry: ZipFileEntry) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finishes the zip stream and returns the archive as a blob.
|
||||||
|
*/
|
||||||
|
finalize: () => Blob;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discards the zip stream and any buffered output.
|
||||||
|
*/
|
||||||
|
abort: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many bytes of a blob to materialise into the JS heap per read. Blobs
|
||||||
|
* (e.g. fetch responses) can be disk-backed by the browser, it is only
|
||||||
|
* `arrayBuffer()` that forces them into memory, so we read in slices.
|
||||||
|
*/
|
||||||
|
const READ_SLICE_BYTES = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Once this many bytes of zip output have accumulated in the JS heap they are
|
||||||
|
* coalesced into an intermediate blob. Browsers can page blob storage to disk
|
||||||
|
* under memory pressure, and the final `new Blob(parts)` composes parts by
|
||||||
|
* reference, so this keeps the heap bounded regardless of archive size.
|
||||||
|
*/
|
||||||
|
const OUTPUT_COALESCE_BYTES = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an incremental client-side zip writer.
|
||||||
|
*
|
||||||
|
* Files are stored without compression (PDFs are already internally
|
||||||
|
* compressed) and streamed through the archive as they are added, so peak JS
|
||||||
|
* heap usage is bounded by roughly one read slice plus one output buffer
|
||||||
|
* rather than the total size of the archive.
|
||||||
|
*/
|
||||||
|
export const createZipWriter = (): ZipWriter => {
|
||||||
|
const usedNames = new Set<string>();
|
||||||
|
|
||||||
|
const outputParts: Blob[] = [];
|
||||||
|
let pendingChunks: Uint8Array[] = [];
|
||||||
|
let pendingSize = 0;
|
||||||
|
|
||||||
|
let zipError: Error | null = null;
|
||||||
|
|
||||||
|
const flushPendingChunks = () => {
|
||||||
|
if (pendingChunks.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
outputParts.push(new Blob(pendingChunks));
|
||||||
|
pendingChunks = [];
|
||||||
|
pendingSize = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ZipPassThrough is synchronous (no workers), so output callbacks have
|
||||||
|
// always fired by the time `push`/`end` return.
|
||||||
|
const zipStream = new Zip((error, chunk, isFinal) => {
|
||||||
|
if (error) {
|
||||||
|
zipError = error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingChunks.push(chunk);
|
||||||
|
pendingSize += chunk.length;
|
||||||
|
|
||||||
|
if (pendingSize >= OUTPUT_COALESCE_BYTES || isFinal) {
|
||||||
|
flushPendingChunks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicates filenames case-insensitively (Windows extraction is
|
||||||
|
* case-insensitive) by appending " (n)" before the extension.
|
||||||
|
*/
|
||||||
|
const deduplicateFilename = (filename: string) => {
|
||||||
|
const match = filename.match(/^(.*?)(\.[^./]+)?$/);
|
||||||
|
|
||||||
|
const baseName = match?.[1] ?? filename;
|
||||||
|
const extension = match?.[2] ?? '';
|
||||||
|
|
||||||
|
let candidate = filename;
|
||||||
|
let counter = 1;
|
||||||
|
|
||||||
|
while (usedNames.has(candidate.toLowerCase())) {
|
||||||
|
candidate = `${baseName} (${counter})${extension}`;
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
usedNames.add(candidate.toLowerCase());
|
||||||
|
|
||||||
|
return candidate;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFile = async ({ filename, data }: ZipFileEntry) => {
|
||||||
|
if (zipError) {
|
||||||
|
throw zipError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = new ZipPassThrough(deduplicateFilename(filename));
|
||||||
|
|
||||||
|
zipStream.add(file);
|
||||||
|
|
||||||
|
for (let offset = 0; offset < data.size; offset += READ_SLICE_BYTES) {
|
||||||
|
const slice = data.slice(offset, offset + READ_SLICE_BYTES);
|
||||||
|
|
||||||
|
file.push(new Uint8Array(await slice.arrayBuffer()));
|
||||||
|
|
||||||
|
if (zipError) {
|
||||||
|
throw zipError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file.push(new Uint8Array(0), true);
|
||||||
|
|
||||||
|
if (zipError) {
|
||||||
|
throw zipError;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalize = () => {
|
||||||
|
zipStream.end();
|
||||||
|
|
||||||
|
if (zipError) {
|
||||||
|
throw zipError;
|
||||||
|
}
|
||||||
|
|
||||||
|
flushPendingChunks();
|
||||||
|
|
||||||
|
return new Blob(outputParts, { type: 'application/zip' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const abort = () => {
|
||||||
|
zipStream.terminate();
|
||||||
|
|
||||||
|
pendingChunks = [];
|
||||||
|
pendingSize = 0;
|
||||||
|
outputParts.length = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
addFile,
|
||||||
|
finalize,
|
||||||
|
abort,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -32,7 +32,11 @@ const versionToFilenameSuffix = (version: DocumentVersion): string => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const downloadPDF = async ({ envelopeItem, token, fileName, version = 'signed' }: DownloadPDFProps) => {
|
/**
|
||||||
|
* Fetches a PDF for an envelope item and returns it as a blob alongside the
|
||||||
|
* filename it should be saved as. Throws on non-OK responses.
|
||||||
|
*/
|
||||||
|
export const fetchPDF = async ({ envelopeItem, token, fileName, version = 'signed' }: DownloadPDFProps) => {
|
||||||
const downloadUrl = getEnvelopeItemPdfUrl({
|
const downloadUrl = getEnvelopeItemPdfUrl({
|
||||||
type: 'download',
|
type: 'download',
|
||||||
envelopeItem: envelopeItem,
|
envelopeItem: envelopeItem,
|
||||||
@@ -40,12 +44,27 @@ export const downloadPDF = async ({ envelopeItem, token, fileName, version = 'si
|
|||||||
version,
|
version,
|
||||||
});
|
});
|
||||||
|
|
||||||
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
|
const response = await fetch(downloadUrl);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to download PDF: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
|
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
|
||||||
|
|
||||||
downloadFile({
|
return {
|
||||||
filename: `${baseTitle}${versionToFilenameSuffix(version)}`,
|
filename: `${baseTitle}${versionToFilenameSuffix(version)}`,
|
||||||
|
blob,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const downloadPDF = async (options: DownloadPDFProps) => {
|
||||||
|
const { filename, blob } = await fetchPDF(options);
|
||||||
|
|
||||||
|
downloadFile({
|
||||||
|
filename,
|
||||||
data: blob,
|
data: blob,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -310,6 +310,9 @@ export const seedDraftDocument = async (
|
|||||||
|
|
||||||
const documentId = await incrementDocumentId();
|
const documentId = await incrementDocumentId();
|
||||||
|
|
||||||
|
const envelopeTitle =
|
||||||
|
typeof createDocumentOptions.title === 'string' ? createDocumentOptions.title : `[TEST] Document ${key} - Draft`;
|
||||||
|
|
||||||
const document = await prisma.envelope.create({
|
const document = await prisma.envelope.create({
|
||||||
data: {
|
data: {
|
||||||
id: prefixedId('envelope'),
|
id: prefixedId('envelope'),
|
||||||
@@ -320,12 +323,12 @@ export const seedDraftDocument = async (
|
|||||||
documentMetaId: documentMeta.id,
|
documentMetaId: documentMeta.id,
|
||||||
source: DocumentSource.DOCUMENT,
|
source: DocumentSource.DOCUMENT,
|
||||||
teamId,
|
teamId,
|
||||||
title: `[TEST] Document ${key} - Draft`,
|
title: envelopeTitle,
|
||||||
status: DocumentStatus.DRAFT,
|
status: DocumentStatus.DRAFT,
|
||||||
envelopeItems: {
|
envelopeItems: {
|
||||||
create: {
|
create: {
|
||||||
id: prefixedId('envelope_item'),
|
id: prefixedId('envelope_item'),
|
||||||
title: `[TEST] Document ${key} - Draft`,
|
title: envelopeTitle,
|
||||||
documentDataId: documentData.id,
|
documentDataId: documentData.id,
|
||||||
order: 1,
|
order: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,4 +35,43 @@ const RadioGroupItem = React.forwardRef<
|
|||||||
|
|
||||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||||
|
|
||||||
export { RadioGroup, RadioGroupItem };
|
/**
|
||||||
|
* A segmented-control style radio group where each item renders as a small
|
||||||
|
* toggle button rather than a radio circle.
|
||||||
|
*/
|
||||||
|
const RadioGroupSegmented = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
className={cn('inline-flex items-center gap-0.5 rounded-md bg-muted p-0.5', className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
RadioGroupSegmented.displayName = 'RadioGroupSegmented';
|
||||||
|
|
||||||
|
const RadioGroupSegmentedItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'rounded-sm px-2 py-0.5 font-medium text-muted-foreground text-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-background data-[state=checked]:text-foreground data-[state=checked]:shadow-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
RadioGroupSegmentedItem.displayName = 'RadioGroupSegmentedItem';
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem, RadioGroupSegmented, RadioGroupSegmentedItem };
|
||||||
|
|||||||
Reference in New Issue
Block a user