feat: bulk download documents (#2711)

This commit is contained in:
Ephraim Duncan
2026-08-03 20:55:43 +10:00
committed by GitHub
parent 29020bcbed
commit b3c609a549
14 changed files with 1091 additions and 65 deletions
@@ -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.');
+2 -2
View File
@@ -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,
};
};
+22 -3
View File
@@ -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,
});
};
+5 -2
View File
@@ -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,
},
+40 -1
View File
@@ -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 };