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 { 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 = {
|
||||
selectedCount: number;
|
||||
onDownloadClick?: () => void;
|
||||
onMoveClick: () => void;
|
||||
onDeleteClick: () => void;
|
||||
onCancelClick?: () => void;
|
||||
@@ -12,6 +14,7 @@ export type EnvelopesTableBulkActionBarProps = {
|
||||
|
||||
export const EnvelopesTableBulkActionBar = ({
|
||||
selectedCount,
|
||||
onDownloadClick,
|
||||
onMoveClick,
|
||||
onDeleteClick,
|
||||
onCancelClick,
|
||||
@@ -19,37 +22,106 @@ export const EnvelopesTableBulkActionBar = ({
|
||||
}: EnvelopesTableBulkActionBarProps) => {
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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">
|
||||
<span className="font-medium text-sm">
|
||||
<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">
|
||||
<div className="flex items-center gap-x-2 px-2">
|
||||
<span className="sr-only" aria-live="polite">
|
||||
<Trans>{selectedCount} selected</Trans>
|
||||
</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}>
|
||||
<FolderInputIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Move to Folder</Trans>
|
||||
<Button
|
||||
type="button"
|
||||
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>
|
||||
|
||||
{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 && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={onCancelClick}>
|
||||
<XCircleIcon className="mr-2 h-4 w-4" />
|
||||
<Button
|
||||
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>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button type="button" variant="destructive" size="sm" onClick={onDeleteClick}>
|
||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||
<Button
|
||||
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>
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={onClearSelection} aria-label={t`Clear selection`}>
|
||||
<XIcon className="h-4 w-4" />
|
||||
<div className="mx-1 h-5 w-px bg-border" />
|
||||
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,13 +14,22 @@ import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { msg } from '@lingui/core/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 { Link, useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-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 { DocumentSearch } from '~/components/general/document/document-search';
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
@@ -38,6 +47,14 @@ export function meta() {
|
||||
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({
|
||||
status: true,
|
||||
period: true,
|
||||
@@ -61,9 +78,18 @@ export default function DocumentsPage() {
|
||||
const [isMovingDocument, setIsMovingDocument] = useState(false);
|
||||
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 [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
const [isBulkDownloadDialogOpen, setIsBulkDownloadDialogOpen] = useState(false);
|
||||
const [isBulkCancelDialogOpen, setIsBulkCancelDialogOpen] = useState(false);
|
||||
|
||||
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 params = new URLSearchParams(searchParams);
|
||||
|
||||
@@ -238,12 +309,28 @@ export default function DocumentsPage() {
|
||||
|
||||
<EnvelopesTableBulkActionBar
|
||||
selectedCount={selectedEnvelopeIds.length}
|
||||
onDownloadClick={() => setIsBulkDownloadDialogOpen(true)}
|
||||
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
|
||||
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
|
||||
onCancelClick={() => setIsBulkCancelDialogOpen(true)}
|
||||
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
|
||||
envelopeIds={selectedEnvelopeIds}
|
||||
envelopeType={EnvelopeType.DOCUMENT}
|
||||
|
||||
@@ -26,12 +26,14 @@ import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
const TEMPLATE_VIEWS = ['team', 'organisation'] as const;
|
||||
|
||||
type TemplateView = (typeof TEMPLATE_VIEWS)[number];
|
||||
|
||||
export function meta() {
|
||||
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() {
|
||||
const team = useCurrentTeam();
|
||||
const organisation = useCurrentOrganisation();
|
||||
@@ -47,7 +49,11 @@ export default function TemplatesPage() {
|
||||
const isOrgView = view === 'organisation';
|
||||
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 [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
|
||||
|
||||
Generated
+10
-3
@@ -22,6 +22,7 @@
|
||||
"@prisma/extension-read-replicas": "^0.4.1",
|
||||
"ai": "^5.0.104",
|
||||
"cron-parser": "^5.5.0",
|
||||
"fflate": "^0.8.3",
|
||||
"luxon": "^3.7.2",
|
||||
"patch-package": "^8.0.1",
|
||||
"posthog-node": "4.18.0",
|
||||
@@ -20080,9 +20081,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.4.8",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
|
||||
"integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==",
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-selector": {
|
||||
@@ -26685,6 +26686,12 @@
|
||||
"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": {
|
||||
"version": "4.18.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-4.18.0.tgz",
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"@prisma/extension-read-replicas": "^0.4.1",
|
||||
"ai": "^5.0.104",
|
||||
"cron-parser": "^5.5.0",
|
||||
"fflate": "^0.8.3",
|
||||
"luxon": "^3.7.2",
|
||||
"patch-package": "^8.0.1",
|
||||
"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 { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
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 { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
import { unzipSync } from 'fflate';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
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 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 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 }) => {
|
||||
@@ -67,7 +70,7 @@ test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ p
|
||||
|
||||
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 }) => {
|
||||
@@ -80,11 +83,11 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
||||
});
|
||||
|
||||
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 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 }) => {
|
||||
@@ -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 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.getByText('Move Documents to Folder')).toBeVisible();
|
||||
|
||||
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.');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
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 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: 'Move' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
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 }) => {
|
||||
@@ -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 expect(page.getByText('1 selected')).toBeVisible();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
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 }) => {
|
||||
@@ -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.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('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 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 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.');
|
||||
|
||||
|
||||
@@ -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 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 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 }) => {
|
||||
@@ -66,7 +66,7 @@ test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ p
|
||||
|
||||
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 }) => {
|
||||
@@ -79,11 +79,11 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
||||
});
|
||||
|
||||
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 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 }) => {
|
||||
@@ -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 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.getByText('Move Templates to Folder')).toBeVisible();
|
||||
|
||||
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.');
|
||||
|
||||
@@ -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 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: 'Move' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
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 }) => {
|
||||
@@ -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 expect(page.getByText('1 selected')).toBeVisible();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
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 }) => {
|
||||
@@ -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.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('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 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 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.');
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
"@playwright/test": "1.56.1",
|
||||
"@types/node": "^20",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"tsx": "^4.23.1",
|
||||
"pixelmatch": "^7.1.0",
|
||||
"pngjs": "^7.0.0"
|
||||
"pngjs": "^7.0.0",
|
||||
"tsx": "^4.23.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"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({
|
||||
type: 'download',
|
||||
envelopeItem: envelopeItem,
|
||||
@@ -40,12 +44,27 @@ export const downloadPDF = async ({ envelopeItem, token, fileName, version = 'si
|
||||
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$/, '');
|
||||
|
||||
downloadFile({
|
||||
return {
|
||||
filename: `${baseTitle}${versionToFilenameSuffix(version)}`,
|
||||
blob,
|
||||
};
|
||||
};
|
||||
|
||||
export const downloadPDF = async (options: DownloadPDFProps) => {
|
||||
const { filename, blob } = await fetchPDF(options);
|
||||
|
||||
downloadFile({
|
||||
filename,
|
||||
data: blob,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -310,6 +310,9 @@ export const seedDraftDocument = async (
|
||||
|
||||
const documentId = await incrementDocumentId();
|
||||
|
||||
const envelopeTitle =
|
||||
typeof createDocumentOptions.title === 'string' ? createDocumentOptions.title : `[TEST] Document ${key} - Draft`;
|
||||
|
||||
const document = await prisma.envelope.create({
|
||||
data: {
|
||||
id: prefixedId('envelope'),
|
||||
@@ -320,12 +323,12 @@ export const seedDraftDocument = async (
|
||||
documentMetaId: documentMeta.id,
|
||||
source: DocumentSource.DOCUMENT,
|
||||
teamId,
|
||||
title: `[TEST] Document ${key} - Draft`,
|
||||
title: envelopeTitle,
|
||||
status: DocumentStatus.DRAFT,
|
||||
envelopeItems: {
|
||||
create: {
|
||||
id: prefixedId('envelope_item'),
|
||||
title: `[TEST] Document ${key} - Draft`,
|
||||
title: envelopeTitle,
|
||||
documentDataId: documentData.id,
|
||||
order: 1,
|
||||
},
|
||||
|
||||
@@ -35,4 +35,43 @@ const RadioGroupItem = React.forwardRef<
|
||||
|
||||
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