diff --git a/apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx b/apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx new file mode 100644 index 000000000..940055854 --- /dev/null +++ b/apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx @@ -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; + +export const EnvelopesBulkDownloadDialog = ({ + envelopes, + open, + onOpenChange, + onSuccess, + ...props +}: EnvelopesBulkDownloadDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [versionMap, setVersionMap] = useState>({}); + 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 ( + { + if (!isDownloading) { + onOpenChange(value); + } + }} + > + + + + Download Documents + + + + + + + + {isOverDownloadLimit && ( + + + + You can download up to {MAX_BULK_DOWNLOAD_ENVELOPES} documents at a time. Deselect some documents to + continue. + + + + )} + +
+
+
+ {envelopes.map((envelope) => { + const versionOptions = getVersionOptions(envelope); + + return ( +
+
+

+ {envelope.title} +

+

{getStatusLabel(envelope.status)}

+
+ + {versionOptions && ( + + setVersionMap((prev) => ({ + ...prev, + [envelope.id]: value as BulkDownloadVersion, + })) + } + aria-label={t`Download version for ${envelope.title}`} + > + {versionOptions.map((option) => ( + + {option.label} + + ))} + + )} +
+ ); + })} +
+
+ + {isDownloading && ( +

+ + Downloading {progress} / {envelopes.length}... + +

+ )} + + + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx b/apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx index ccba956f6..e9de26777 100644 --- a/apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +++ b/apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx @@ -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 ( -
- - {selectedCount} selected - +
+
+ + {selectedCount} selected + + + +
-
+
- + {onDownloadClick && ( + + )} + {onCancelClick && ( - )} - -
); diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx index 3350e29e1..37883b796 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx @@ -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; + +// 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(null); - const [rowSelection, setRowSelection] = useSessionStorage('documents-bulk-selection', {}); + // Scoped by team so selections made in one team never leak into another. + const [rowSelection, setRowSelection] = useSessionStorage( + `documents-bulk-selection-${team.id}`, + EMPTY_ROW_SELECTION, + ); + const [envelopeMetaCache, setEnvelopeMetaCache] = useSessionStorage( + `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() { setIsBulkDownloadDialogOpen(true)} onMoveClick={() => setIsBulkMoveDialogOpen(true)} onDeleteClick={() => setIsBulkDeleteDialogOpen(true)} onCancelClick={() => setIsBulkCancelDialogOpen(true)} onClearSelection={() => setRowSelection({})} /> + { + setRowSelection((prev) => { + const next = { ...prev }; + for (const id of successfulEnvelopeIds) { + delete next[id]; + } + return next; + }); + }} + /> + ('templates-bulk-selection', {}); + // Scoped by team so selections made in one team never leak into another. + const [rowSelection, setRowSelection] = useSessionStorage( + `templates-bulk-selection-${team.id}`, + EMPTY_ROW_SELECTION, + ); const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false); const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); diff --git a/package-lock.json b/package-lock.json index 2f7ef057b..3510fd7a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 97b1b0811..60e97f1c8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-file-download.spec.ts b/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-file-download.spec.ts new file mode 100644 index 000000000..ff9b7ec1a --- /dev/null +++ b/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-file-download.spec.ts @@ -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); + }); +}); diff --git a/packages/app-tests/e2e/documents/bulk-document-actions.spec.ts b/packages/app-tests/e2e/documents/bulk-document-actions.spec.ts index 5fc563041..12282ca15 100644 --- a/packages/app-tests/e2e/documents/bulk-document-actions.spec.ts +++ b/packages/app-tests/e2e/documents/bulk-document-actions.spec.ts @@ -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.'); diff --git a/packages/app-tests/e2e/templates/bulk-template-actions.spec.ts b/packages/app-tests/e2e/templates/bulk-template-actions.spec.ts index 4ef72f495..d6edfa24b 100644 --- a/packages/app-tests/e2e/templates/bulk-template-actions.spec.ts +++ b/packages/app-tests/e2e/templates/bulk-template-actions.spec.ts @@ -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.'); diff --git a/packages/app-tests/package.json b/packages/app-tests/package.json index 7b552080c..245619141 100644 --- a/packages/app-tests/package.json +++ b/packages/app-tests/package.json @@ -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" diff --git a/packages/lib/client-only/create-zip-writer.ts b/packages/lib/client-only/create-zip-writer.ts new file mode 100644 index 000000000..da8e07d97 --- /dev/null +++ b/packages/lib/client-only/create-zip-writer.ts @@ -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; + + /** + * 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(); + + 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, + }; +}; diff --git a/packages/lib/client-only/download-pdf.ts b/packages/lib/client-only/download-pdf.ts index ab5820cce..3bde40884 100644 --- a/packages/lib/client-only/download-pdf.ts +++ b/packages/lib/client-only/download-pdf.ts @@ -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, }); }; diff --git a/packages/prisma/seed/documents.ts b/packages/prisma/seed/documents.ts index 4bd357eae..f3616fac8 100644 --- a/packages/prisma/seed/documents.ts +++ b/packages/prisma/seed/documents.ts @@ -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, }, diff --git a/packages/ui/primitives/radio-group.tsx b/packages/ui/primitives/radio-group.tsx index 1ce57a1e1..d89470755 100644 --- a/packages/ui/primitives/radio-group.tsx +++ b/packages/ui/primitives/radio-group.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ); +}); + +RadioGroupSegmented.displayName = 'RadioGroupSegmented'; + +const RadioGroupSegmentedItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => { + return ( + + {children} + + ); +}); + +RadioGroupSegmentedItem.displayName = 'RadioGroupSegmentedItem'; + +export { RadioGroup, RadioGroupItem, RadioGroupSegmented, RadioGroupSegmentedItem };