Compare commits

...

12 Commits

Author SHA1 Message Date
Ephraim Duncan 814eb51b26 Merge branch 'main' into feat/bulk-download 2026-05-27 13:46:51 +00:00
ephraimduncan a16dd73ec1 Merge remote-tracking branch 'origin/main' into pr-2711
# Conflicts:
#	packages/lib/translations/de/web.po
#	packages/lib/translations/en/web.po
#	packages/lib/translations/es/web.po
#	packages/lib/translations/fr/web.po
#	packages/lib/translations/it/web.po
#	packages/lib/translations/ja/web.po
#	packages/lib/translations/ko/web.po
#	packages/lib/translations/nl/web.po
#	packages/lib/translations/pl/web.po
#	packages/lib/translations/pt-BR/web.po
#	packages/lib/translations/zh/web.po
2026-05-14 15:43:59 +00:00
ephraimduncan 5da2a2020e chore: merge main, resolve biome formatting conflicts
Merge origin/main into feat/bulk-download. Take PR's bulk action bar
design (escape-key dismiss, popover pill with count badge, optional
download button via onDownloadClick) and the documents page bulk
download state (envelopeMetaCache cached across pages, selected
envelopes mapped from cache for download).

Imports reordered into biome's @documenso → external → relative
groups; combined the @prisma/client import line to include
DocumentStatus as PrismaDocumentStatus alongside EnvelopeType,
FolderType, OrganisationType.
2026-05-12 11:50:10 +00:00
ephraimduncan d79b1d4612 fix: persist selected envelope metadata across pagination for bulk download 2026-04-22 17:23:49 +00:00
ephraimduncan 3685acc0ab fix: propagate createDocumentOptions.title to envelope item in seedDraftDocument 2026-04-20 21:58:46 +00:00
ephraimduncan eeea3651ee fix: update bulk action e2e tests for redesigned toolbar
The bulk action toolbar redesign split the count and "selected"
text into separate spans and renamed "Move to Folder" to "Move",
breaking getByText('N selected') and the toolbar button lookups.
Use /N\s*selected/ regex and scope the dialog submit to the
open dialog since the toolbar's "Move" button persists.
2026-04-20 21:27:58 +00:00
ephraimduncan 50997d7e92 chore: drop blur and transparency from bulk action bar
The translucent popover + backdrop blur muddied the pill against busy
table rows. Switch to an opaque background so the bar reads cleanly.
2026-04-20 20:42:32 +00:00
ephraimduncan bc97af14d3 chore: drop unused toolbar role and aria-label from bulk action bar 2026-04-20 19:08:30 +00:00
ephraimduncan 278dfa3d77 chore: deslop bulk download dialog and action bar
Merge duplicate lingui imports, drop redundant state-reset comment,
simplify Select onValueChange to match codebase style, and remove
broken tap-target span with invalid Tailwind 3.4 classes
(pointer-fine:, -translate-1/2).
2026-04-20 18:57:43 +00:00
ephraimduncan 5b64137237 feat: redesign bulk action toolbar and add escape-to-clear 2026-04-20 18:35:42 +00:00
ephraimduncan b9b29e5a76 fix: bulk download partial failure, abort, and race-safe e2e
- onSuccess now reports successful envelope ids so the parent clears
  only those rows from selection instead of wiping all pages.
- Partial failures no longer auto-close the dialog; failed/unprocessed
  ids stay selected for retry.
- Cancel button turns into Stop while downloading and aborts the batch
  at the next envelope boundary.
- Replace dual waitForEvent('download') with a page.on collector +
  expect.poll so both downloads are captured reliably.
2026-04-20 15:48:18 +00:00
ephraimduncan 5b63b5deb9 feat: bulk download documents 2026-04-20 15:04:59 +00:00
6 changed files with 489 additions and 51 deletions
@@ -0,0 +1,283 @@
import { useEffect, useRef, useState } from 'react';
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 { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EnvelopeBulkDownloadItem = {
id: string;
title: string;
status: DocumentStatus;
};
export type EnvelopesBulkDownloadDialogProps = {
envelopes: EnvelopeBulkDownloadItem[];
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: (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, 'signed' | 'original'>>({});
const [progress, setProgress] = useState(0);
const [isDownloading, setIsDownloading] = useState(false);
const abortRef = useRef(false);
const trpcUtils = trpc.useUtils();
useEffect(() => {
if (!open) {
return;
}
setVersionMap(
Object.fromEntries(
envelopes.map((envelope) => [
envelope.id,
envelope.status === DocumentStatus.COMPLETED ? 'signed' : 'original',
]),
),
);
setProgress(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const getStatusLabel = (status: DocumentStatus) =>
match(status)
.with(DocumentStatus.COMPLETED, () => t`Completed`)
.with(DocumentStatus.PENDING, () => t`Pending`)
.with(DocumentStatus.DRAFT, () => t`Draft`)
.with(DocumentStatus.REJECTED, () => t`Rejected`)
.exhaustive();
const onDownload = async () => {
if (envelopes.length === 0 || isDownloading) {
return;
}
abortRef.current = false;
setIsDownloading(true);
setProgress(0);
const successfulEnvelopeIds: string[] = [];
let failedDownloads = 0;
try {
for (const envelope of envelopes) {
if (abortRef.current) {
break;
}
try {
const downloadVersion = versionMap[envelope.id] ?? 'original';
const { data: envelopeItems } = await trpcUtils.envelope.item.getManyByToken.fetch({
envelopeId: envelope.id,
access: {
type: 'user',
},
});
for (const envelopeItem of envelopeItems) {
await downloadPDF({
envelopeItem,
token: undefined,
fileName: envelopeItem.title,
version: downloadVersion,
});
}
successfulEnvelopeIds.push(envelope.id);
} catch (error) {
console.error(error);
failedDownloads++;
}
setProgress((p) => p + 1);
}
if (successfulEnvelopeIds.length === 0) {
toast({
title: t`Error`,
description: t`An error occurred while downloading the documents.`,
variant: 'destructive',
});
return;
}
if (failedDownloads > 0) {
toast({
title: t`Documents partially downloaded`,
description: t`${plural(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>
<fieldset disabled={isDownloading} className="space-y-4">
<div className="max-h-96 space-y-2 overflow-y-auto">
{envelopes.map((envelope) => {
const isCompleted = envelope.status === DocumentStatus.COMPLETED;
return (
<div
key={envelope.id}
className="flex items-center gap-3 rounded-lg border border-border bg-card p-3"
>
<div className="min-w-0 flex-1">
<p
className="truncate text-sm font-medium text-foreground"
title={envelope.title}
>
{envelope.title}
</p>
<p className="text-xs text-muted-foreground">
{getStatusLabel(envelope.status)}
</p>
</div>
{isCompleted && (
<Select
value={versionMap[envelope.id] ?? 'signed'}
onValueChange={(value) =>
setVersionMap((prev) => ({
...prev,
[envelope.id]: value as 'signed' | 'original',
}))
}
>
<SelectTrigger className="w-[120px] flex-shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="signed">
<Trans context="Signed document (adjective)">Signed</Trans>
</SelectItem>
<SelectItem value="original">
<Trans context="Original document (adjective)">Original</Trans>
</SelectItem>
</SelectContent>
</Select>
)}
</div>
);
})}
</div>
{isDownloading && (
<p className="text-sm text-muted-foreground">
<Trans>
Downloading {progress} / {envelopes.length}...
</Trans>
</p>
)}
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => {
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}
>
<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, XIcon } from 'lucide-react';
import { DownloadIcon, FolderInputIcon, Trash2Icon, XIcon } from 'lucide-react';
import { useEffect } from 'react';
export type EnvelopesTableBulkActionBarProps = {
selectedCount: number;
onDownloadClick?: () => void;
onMoveClick: () => void;
onDeleteClick: () => void;
onClearSelection: () => void;
@@ -11,36 +13,91 @@ export type EnvelopesTableBulkActionBarProps = {
export const EnvelopesTableBulkActionBar = ({
selectedCount,
onDownloadClick,
onMoveClick,
onDeleteClick,
onClearSelection,
}: EnvelopesTableBulkActionBarProps) => {
const { t } = useLingui();
useEffect(() => {
if (selectedCount === 0) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
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">
<Trans>{selectedCount} selected</Trans>
</span>
<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="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 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>
<Button type="button" variant="destructive" size="sm" onClick={onDeleteClick}>
<Trash2Icon className="mr-2 h-4 w-4" />
{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>
)}
<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,19 @@ 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, useParams, useSearchParams } from 'react-router';
import { z } from 'zod';
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
import { EnvelopesBulkDownloadDialog } from '~/components/dialogs/envelopes-bulk-download-dialog';
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
import { DocumentSearch } from '~/components/general/document/document-search';
import { DocumentStatus } from '~/components/general/document/document-status';
@@ -59,8 +65,12 @@ export default function DocumentsPage() {
const [documentToMove, setDocumentToMove] = useState<number | null>(null);
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('documents-bulk-selection', {});
const [envelopeMetaCache, setEnvelopeMetaCache] = useSessionStorage<
Record<string, { title: string; status: PrismaDocumentStatus }>
>('documents-bulk-selection-meta', {});
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [isBulkDownloadDialogOpen, setIsBulkDownloadDialogOpen] = useState(false);
const selectedEnvelopeIds = useMemo(() => {
return Object.keys(rowSelection).filter((id) => rowSelection[id]);
@@ -90,6 +100,38 @@ export default function DocumentsPage() {
},
);
useEffect(() => {
setEnvelopeMetaCache((prev) => {
const next: Record<string, { title: string; status: PrismaDocumentStatus }> = {};
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,
};
}
}
return next;
});
}, [data?.data, rowSelection, setEnvelopeMetaCache]);
const selectedEnvelopesForDownload = useMemo(() => {
return selectedEnvelopeIds
.map((id) => {
const meta = envelopeMetaCache[id];
return meta ? { id, title: meta.title, status: meta.status } : null;
})
.filter((item): item is { id: string; title: string; status: PrismaDocumentStatus } => item !== null);
}, [selectedEnvelopeIds, envelopeMetaCache]);
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
const params = new URLSearchParams(searchParams);
@@ -225,11 +267,27 @@ export default function DocumentsPage() {
<EnvelopesTableBulkActionBar
selectedCount={selectedEnvelopeIds.length}
onDownloadClick={() => setIsBulkDownloadDialogOpen(true)}
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
onClearSelection={() => setRowSelection({})}
/>
<EnvelopesBulkDownloadDialog
envelopes={selectedEnvelopesForDownload}
open={isBulkDownloadDialogOpen}
onOpenChange={setIsBulkDownloadDialogOpen}
onSuccess={(successfulEnvelopeIds) => {
setRowSelection((prev) => {
const next = { ...prev };
for (const id of successfulEnvelopeIds) {
delete next[id];
}
return next;
});
}}
/>
<EnvelopesBulkMoveDialog
envelopeIds={selectedEnvelopeIds}
envelopeType={EnvelopeType.DOCUMENT}
@@ -1,7 +1,7 @@
import { seedDraftDocument } from '@documenso/prisma/seed/documents';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { type Download, expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
import { expectToastTextToBeVisible } from '../fixtures/generic';
@@ -47,10 +47,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 }) => {
@@ -64,7 +64,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 }) => {
@@ -77,11 +77,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 }) => {
@@ -95,13 +95,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.');
@@ -110,6 +110,43 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page })
await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).toBeVisible();
});
test('[BULK_ACTIONS]: can bulk download multiple documents', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Download', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByText('Download Documents')).toBeVisible();
await expect(dialog.getByText('Bulk Test Doc 1')).toBeVisible();
await expect(dialog.getByText('Bulk Test Doc 2')).toBeVisible();
await expect(dialog.getByText('Draft').first()).toBeVisible();
const downloads: Download[] = [];
page.on('download', (d) => downloads.push(d));
await dialog.getByRole('button', { name: 'Download' }).click();
await expect.poll(() => downloads.length, { timeout: 10_000 }).toBe(2);
expect(downloads.map((d) => d.suggestedFilename())).toEqual(
expect.arrayContaining(['Bulk Test Doc 1.pdf', 'Bulk Test Doc 2.pdf']),
);
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();
@@ -149,14 +186,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 }) => {
@@ -169,13 +206,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 }) => {
@@ -196,7 +233,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();
@@ -233,14 +270,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.');
+5 -2
View File
@@ -308,6 +308,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'),
@@ -317,12 +320,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,
},