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:
+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