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/general/document/document-search.tsx b/apps/remix/app/components/general/document/document-search.tsx index 9079be8f8..bb0819008 100644 --- a/apps/remix/app/components/general/document/document-search.tsx +++ b/apps/remix/app/components/general/document/document-search.tsx @@ -2,38 +2,24 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce import { Input } from '@documenso/ui/primitives/input'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; -import { useCallback, useEffect, useState } from 'react'; -import { useSearchParams } from 'react-router'; +import { useQueryState } from 'nuqs'; +import { useEffect, useState } from 'react'; -export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string }) => { +import { documentsSearchParams } from '~/utils/documents-search-params'; + +export const DocumentSearch = () => { const { _ } = useLingui(); - const [searchParams, setSearchParams] = useSearchParams(); + const [query, setQuery] = useQueryState('query', documentsSearchParams.query); - const [searchTerm, setSearchTerm] = useState(initialValue); + const [searchTerm, setSearchTerm] = useState(query ?? ''); const debouncedSearchTerm = useDebouncedValue(searchTerm, 500); - const handleSearch = useCallback( - (term: string) => { - const params = new URLSearchParams(searchParams?.toString() ?? ''); - if (term) { - params.set('query', term); - } else { - params.delete('query'); - } - - setSearchParams(params); - }, - [searchParams], - ); - useEffect(() => { - const currentQueryParam = searchParams.get('query') || ''; - - if (debouncedSearchTerm !== currentQueryParam) { - handleSearch(debouncedSearchTerm); + if (debouncedSearchTerm !== (query ?? '')) { + void setQuery(debouncedSearchTerm || null); } - }, [debouncedSearchTerm, searchParams]); + }, [debouncedSearchTerm, query, setQuery]); return ( void; + selectedLabel?: ReactNode; +}; + +export type FilterPillMultipleProps = FilterPillCommonProps & { + multiple: true; + value: string[]; + onChange: (value: string[]) => void; +}; + +export type FilterPillProps = FilterPillSingleProps | FilterPillMultipleProps; + +/** + * A faceted filter pill. + * + * Renders as a dashed "add a filter" pill at rest, and shows the current + * selection inline once a value is picked. Selecting the active option + * again (or the Clear row) removes it. + * + * Single select by default, closing on pick. When `multiple` is set the + * popover stays open for toggling, and the trigger shows the first two + * selections followed by a "+N more" chip. + */ +export const FilterPill = (props: FilterPillProps) => { + const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, testId } = props; + + const [open, setOpen] = useState(false); + + const selectedValues = props.multiple ? props.value : props.value === null ? [] : [props.value]; + + const selectedOptions = selectedValues + .map((value) => options.find((option) => option.value === value)) + .filter((option): option is FilterPillOption => option !== undefined); + + const hasSelection = selectedOptions.length > 0; + const extraCount = selectedOptions.length - 2; + + const onSelect = (nextValue: string) => { + if (props.multiple) { + const newValues = selectedValues.includes(nextValue) + ? selectedValues.filter((value) => value !== nextValue) + : [...selectedValues, nextValue]; + + props.onChange(newValues); + return; + } + + props.onChange(nextValue === props.value ? null : nextValue); + setOpen(false); + }; + + const onClear = () => { + if (props.multiple) { + props.onChange([]); + } else { + props.onChange(null); + } + + setOpen(false); + }; + + return ( + + + + + + + + {enableSearch && } + + + + No results found. + + + + {options.map((option) => ( + onSelect(option.value)}> + + + {option.label} + + {option.trailing !== undefined && ( + {option.trailing} + )} + + ))} + + + {hasSelection && ( + <> + + + + Clear + + + + )} + + + + + ); +}; diff --git a/apps/remix/app/components/tables/documents-table-period-filter.tsx b/apps/remix/app/components/tables/documents-table-period-filter.tsx new file mode 100644 index 000000000..b051b41fa --- /dev/null +++ b/apps/remix/app/components/tables/documents-table-period-filter.tsx @@ -0,0 +1,40 @@ +import { Trans } from '@lingui/react/macro'; +import { CalendarIcon } from 'lucide-react'; +import { useQueryStates } from 'nuqs'; + +import { FilterPill } from '~/components/general/filter-pill'; +import { DOCUMENTS_PERIOD_VALUES, documentsSearchParams } from '~/utils/documents-search-params'; + +const PERIOD_OPTIONS = [ + { value: '7d', label: Last 7 days }, + { value: '14d', label: Last 14 days }, + { value: '30d', label: Last 30 days }, +]; + +export const DocumentsTablePeriodFilter = () => { + const [{ period }, setSearchParams] = useQueryStates( + { + period: documentsSearchParams.period, + page: documentsSearchParams.page, + }, + { history: 'push' }, + ); + + const onChange = (newPeriod: string | null) => { + void setSearchParams({ + period: DOCUMENTS_PERIOD_VALUES.find((value) => value === newPeriod) ?? null, + page: null, + }); + }; + + return ( + Period} + value={period} + onChange={onChange} + options={PERIOD_OPTIONS} + testId="documents-table-period-filter" + /> + ); +}; diff --git a/apps/remix/app/components/tables/documents-table-sender-filter.tsx b/apps/remix/app/components/tables/documents-table-sender-filter.tsx index c4c2bbd4a..1d398fb02 100644 --- a/apps/remix/app/components/tables/documents-table-sender-filter.tsx +++ b/apps/remix/app/components/tables/documents-table-sender-filter.tsx @@ -1,63 +1,61 @@ import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted'; import { trpc } from '@documenso/trpc/react'; -import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox'; import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import { useLocation, useNavigate, useSearchParams } from 'react-router'; +import { UserIcon } from 'lucide-react'; +import { useQueryStates } from 'nuqs'; + +import { FilterPill } from '~/components/general/filter-pill'; +import { documentsSearchParams } from '~/utils/documents-search-params'; type DocumentsTableSenderFilterProps = { teamId: number; }; export const DocumentsTableSenderFilter = ({ teamId }: DocumentsTableSenderFilterProps) => { - const { pathname } = useLocation(); - const [searchParams] = useSearchParams(); - const navigate = useNavigate(); + const { _ } = useLingui(); const isMounted = useIsMounted(); - const senderIds = (searchParams?.get('senderIds') ?? '').split(',').filter((value) => value !== ''); + const [{ senderIds }, setSearchParams] = useQueryStates( + { + senderIds: documentsSearchParams.senderIds, + page: documentsSearchParams.page, + }, + { history: 'push' }, + ); + + const selectedSenderIds = (senderIds ?? []).map((senderId) => senderId.toString()); const { data, isLoading } = trpc.team.member.getMany.useQuery({ teamId, }); - const comboBoxOptions = (data ?? []).map((member) => ({ + const options = (data ?? []).map((member) => ({ label: member.name ?? member.email, value: member.userId.toString(), })); const onChange = (newSenderIds: string[]) => { - if (!pathname) { - return; - } - - const params = new URLSearchParams(searchParams?.toString()); - - params.set('senderIds', newSenderIds.join(',')); - - if (newSenderIds.length === 0) { - params.delete('senderIds'); - } - - void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true }); + void setSearchParams({ + senderIds: newSenderIds.length > 0 ? newSenderIds.map(Number) : null, + page: null, + }); }; return ( - - - Sender: All - -

- } - enableClearAllButton={true} - inputPlaceholder={msg`Search`} - loading={!isMounted || isLoading} - options={comboBoxOptions} - selectedValues={senderIds} + Sender} + value={selectedSenderIds} onChange={onChange} + options={options} + enableSearch + searchPlaceholder={_(msg`Search members...`)} + loading={!isMounted || isLoading} + testId="documents-table-sender-filter" /> ); }; diff --git a/apps/remix/app/components/tables/documents-table-status-filter.tsx b/apps/remix/app/components/tables/documents-table-status-filter.tsx new file mode 100644 index 000000000..25abc1164 --- /dev/null +++ b/apps/remix/app/components/tables/documents-table-status-filter.tsx @@ -0,0 +1,98 @@ +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { STATS_COUNT_CAP } from '@documenso/lib/constants/document'; +import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; +import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { OrganisationType } from '@prisma/client'; +import { ListFilterIcon } from 'lucide-react'; +import { useQueryStates } from 'nuqs'; +import { useMemo } from 'react'; + +import { DocumentStatus, FRIENDLY_STATUS_MAP } from '~/components/general/document/document-status'; +import { FilterPill } from '~/components/general/filter-pill'; +import { documentsSearchParams } from '~/utils/documents-search-params'; + +type DocumentsTableStatusFilterProps = { + stats: TFindDocumentsInternalResponse['stats']; +}; + +export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilterProps) => { + const { _ } = useLingui(); + + const organisation = useCurrentOrganisation(); + + const [{ status }, setSearchParams] = useQueryStates( + { + status: documentsSearchParams.status, + page: documentsSearchParams.page, + }, + { history: 'push' }, + ); + + const selectableStatuses = useMemo( + () => + SELECTABLE_STATUSES.filter((value) => { + if (organisation.type === OrganisationType.PERSONAL) { + return value !== ExtendedDocumentStatus.INBOX; + } + + return true; + }), + [organisation.type], + ); + + const selectedStatus = useMemo( + () => selectableStatuses.find((value) => value === status) ?? null, + [selectableStatuses, status], + ); + + const onChange = (newStatus: string | null) => { + void setSearchParams({ + status: selectableStatuses.find((value) => value === newStatus) ?? null, + page: null, + }); + }; + + return ( + <> + Status} + value={selectedStatus} + onChange={onChange} + selectedLabel={selectedStatus && } + options={selectableStatuses.map((value) => ({ + value, + label: , + trailing: formatStatsCount(stats[value]), + }))} + testId="documents-table-status-filter" + /> + + {/* Visually hidden document counts, for screen readers and tests. */} + + {[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => ( + + {_(FRIENDLY_STATUS_MAP[value].label)}:{' '} + {stats[value]} + + ))} + + + ); +}; + +const SELECTABLE_STATUSES: ExtendedDocumentStatus[] = [ + ExtendedDocumentStatus.INBOX, + ExtendedDocumentStatus.PENDING, + ExtendedDocumentStatus.COMPLETED, + ExtendedDocumentStatus.CANCELLED, + ExtendedDocumentStatus.DRAFT, + ExtendedDocumentStatus.REJECTED, + ExtendedDocumentStatus.EXPIRED, +]; + +const formatStatsCount = (count: number) => { + return count >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : count.toString(); +}; 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..9f5015542 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx @@ -1,59 +1,57 @@ import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage'; -import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; -import { STATS_COUNT_CAP } from '@documenso/lib/constants/document'; import { SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc'; import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; -import { parseToIntegerArray } from '@documenso/lib/utils/params'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import { trpc } from '@documenso/trpc/react'; import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types'; -import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/find-documents-internal.types'; import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; 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, type DocumentStatus as PrismaDocumentStatus } from '@prisma/client'; +import { XIcon } from 'lucide-react'; +import { useQueryStates } from 'nuqs'; import { useEffect, useMemo, useState } from 'react'; -import { Link, useNavigate, useParams, useSearchParams } from 'react-router'; -import { z } from 'zod'; +import { useNavigate, useParams } from 'react-router'; 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'; import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper'; import { FolderGrid } from '~/components/general/folder/folder-grid'; -import { PeriodSelector } from '~/components/general/period-selector'; import { DocumentsTable } from '~/components/tables/documents-table'; import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state'; +import { DocumentsTablePeriodFilter } from '~/components/tables/documents-table-period-filter'; import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter'; +import { DocumentsTableStatusFilter } from '~/components/tables/documents-table-status-filter'; import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar'; import { useCurrentTeam } from '~/providers/team'; +import { documentsSearchParams } from '~/utils/documents-search-params'; import { appMetaTags } from '~/utils/meta'; export function meta() { return appMetaTags(msg`Documents`); } -const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({ - status: true, - period: true, - page: true, - perPage: true, - query: true, -}).extend({ - senderIds: z.string().transform(parseToIntegerArray).optional().catch([]), -}); +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 = {}; export default function DocumentsPage() { - const organisation = useCurrentOrganisation(); const team = useCurrentTeam(); const { folderId } = useParams(); - const [searchParams] = useSearchParams(); const navigate = useNavigate(); const documentsPath = formatDocumentsPath(team.url); @@ -61,9 +59,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(() => { @@ -81,14 +88,18 @@ export default function DocumentsPage() { [ExtendedDocumentStatus.ALL]: 0, }); - const findDocumentSearchParams = useMemo( - () => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {}, - [searchParams], - ); + const [findDocumentSearchParams, setFindDocumentSearchParams] = useQueryStates(documentsSearchParams, { + history: 'push', + }); const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery( { - ...findDocumentSearchParams, + status: findDocumentSearchParams.status ?? undefined, + period: findDocumentSearchParams.period ?? undefined, + senderIds: findDocumentSearchParams.senderIds ?? undefined, + page: findDocumentSearchParams.page ?? undefined, + perPage: findDocumentSearchParams.perPage ?? undefined, + query: findDocumentSearchParams.query ?? undefined, folderId, }, { @@ -96,34 +107,66 @@ export default function DocumentsPage() { }, ); - const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => { - const params = new URLSearchParams(searchParams); + useEffect(() => { + setEnvelopeMetaCache((prev) => { + const next: EnvelopeMetaCache = {}; - params.set('status', value); + for (const id of Object.keys(prev)) { + if (rowSelection[id]) { + next[id] = prev[id]; + } + } - if (value === ExtendedDocumentStatus.ALL) { - params.delete('status'); - } + for (const document of data?.data ?? []) { + if (rowSelection[document.envelopeId]) { + next[document.envelopeId] = { + title: document.title, + status: document.status, + isLegacy: document.internalVersion === 1, + }; + } + } - if (value === ExtendedDocumentStatus.INBOX && organisation.type === OrganisationType.PERSONAL) { - params.delete('status'); - } + return next; + }); + }, [data?.data, rowSelection, setEnvelopeMetaCache]); - if (params.has('page')) { - params.delete('page'); - } + const selectedEnvelopesForDownload = useMemo(() => { + return selectedEnvelopeIds + .map((id): EnvelopeBulkDownloadItem | null => { + const meta = envelopeMetaCache[id]; - let path = formatDocumentsPath(team.url); + if (!meta) { + return null; + } - if (folderId) { - path += `/f/${folderId}`; - } + 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]); - if (params.toString()) { - path += `?${params.toString()}`; - } + const hasActiveFilters = useMemo(() => { + return Boolean( + (findDocumentSearchParams.status && findDocumentSearchParams.status !== ExtendedDocumentStatus.ALL) || + findDocumentSearchParams.senderIds?.length || + findDocumentSearchParams.period, + ); + }, [findDocumentSearchParams]); - return path; + const onResetFilters = () => { + void setFindDocumentSearchParams({ + status: null, + senderIds: null, + period: null, + page: null, + }); }; useEffect(() => { @@ -137,69 +180,40 @@ export default function DocumentsPage() {
-
-
- - {team.avatarImageId && } - {team.name.slice(0, 1)} - +
+ + {team.avatarImageId && } + {team.name.slice(0, 1)} + -

- Documents -

+

+ Documents +

+
+ +
+
+
-
- - - {[ - ExtendedDocumentStatus.INBOX, - ExtendedDocumentStatus.PENDING, - ExtendedDocumentStatus.COMPLETED, - ExtendedDocumentStatus.CANCELLED, - ExtendedDocumentStatus.DRAFT, - ExtendedDocumentStatus.REJECTED, - ExtendedDocumentStatus.EXPIRED, - ExtendedDocumentStatus.ALL, - ] - .filter((value) => { - if (organisation.type === OrganisationType.PERSONAL) { - return value !== ExtendedDocumentStatus.INBOX; - } + - return true; - }) - .map((value) => ( - - - + {team && } - {value !== ExtendedDocumentStatus.ALL && ( - - {stats[value] >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : stats[value]} - - )} - - - ))} - - + - {team && } - -
- -
-
- -
-
+ {hasActiveFilters && ( + + )}
{data && data.count === 0 ? ( - + ) : ( 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/apps/remix/app/utils/documents-search-params.ts b/apps/remix/app/utils/documents-search-params.ts new file mode 100644 index 000000000..159038f5a --- /dev/null +++ b/apps/remix/app/utils/documents-search-params.ts @@ -0,0 +1,19 @@ +import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; +import { parseAsArrayOf, parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs'; + +export const DOCUMENTS_PERIOD_VALUES = ['7d', '14d', '30d'] as const; + +/** + * Shared nuqs parsers for the documents page URL state. + * + * Used by the documents page and its filter components so every consumer + * parses and serialises the params identically. + */ +export const documentsSearchParams = { + status: parseAsStringLiteral(Object.values(ExtendedDocumentStatus)), + period: parseAsStringLiteral(DOCUMENTS_PERIOD_VALUES), + senderIds: parseAsArrayOf(parseAsInteger), + page: parseAsInteger, + perPage: parseAsInteger, + query: parseAsString, +}; 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/documents/cancel-documents.spec.ts b/packages/app-tests/e2e/documents/cancel-documents.spec.ts index 9554fac34..d34ae532f 100644 --- a/packages/app-tests/e2e/documents/cancel-documents.spec.ts +++ b/packages/app-tests/e2e/documents/cancel-documents.spec.ts @@ -7,7 +7,7 @@ import { expect, type Page, test } from '@playwright/test'; import { DocumentStatus, TeamMemberRole } from '@prisma/client'; import { apiSignin, apiSignout } from '../fixtures/authentication'; -import { checkDocumentTabCount } from '../fixtures/documents'; +import { checkDocumentCounts, selectDocumentStatusFilter } from '../fixtures/documents'; import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic'; test.describe.configure({ mode: 'serial' }); @@ -61,13 +61,10 @@ test('[DOCUMENTS]: cancelling a pending document keeps it in the owner dashboard await expectToastTextToBeVisible(page, 'Document cancelled'); // The document must remain in the dashboard, unlike deleting a pending document. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 0); - await checkDocumentTabCount(page, 'Cancelled', 1); - await checkDocumentTabCount(page, 'All', 1); + await checkDocumentCounts(page, { inbox: 0, pending: 0, cancelled: 1, all: 1 }); // The cancelled document is still listed. - await page.getByRole('tab', { name: 'Cancelled' }).click(); + await selectDocumentStatusFilter(page, 'Cancelled'); await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible(); // The envelope status is persisted as CANCELLED. @@ -131,7 +128,7 @@ test('[DOCUMENTS]: a cancelled document can be deleted, hiding it from the owner await expectToastTextToBeVisible(page, 'Document cancelled'); // Delete the now-cancelled document. Being terminal, it should soft delete (hide). - await page.getByRole('tab', { name: 'Cancelled' }).click(); + await selectDocumentStatusFilter(page, 'Cancelled'); const documentActionBtn = page .locator('tr', { hasText: 'Document 1 - Pending' }) diff --git a/packages/app-tests/e2e/documents/delete-documents.spec.ts b/packages/app-tests/e2e/documents/delete-documents.spec.ts index 98afb373a..ebe2313cb 100644 --- a/packages/app-tests/e2e/documents/delete-documents.spec.ts +++ b/packages/app-tests/e2e/documents/delete-documents.spec.ts @@ -3,7 +3,7 @@ import { seedUser } from '@documenso/prisma/seed/users'; import { expect, test } from '@playwright/test'; import { apiSignin, apiSignout } from '../fixtures/authentication'; -import { checkDocumentTabCount } from '../fixtures/documents'; +import { checkDocumentCounts } from '../fixtures/documents'; import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic'; test.describe.configure({ mode: 'serial' }); @@ -174,11 +174,7 @@ test('[DOCUMENTS]: deleting draft documents should permanently remove it', async await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible(); // Check document counts. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 1); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'Draft', 0); - await checkDocumentTabCount(page, 'All', 2); + await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 1, draft: 0, all: 2 }); }); test('[DOCUMENTS]: deleting pending documents should permanently remove it', async ({ page }) => { @@ -207,11 +203,7 @@ test('[DOCUMENTS]: deleting pending documents should permanently remove it', asy await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible(); // Check document counts. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 0); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'All', 2); + await checkDocumentCounts(page, { inbox: 0, pending: 0, completed: 1, draft: 1, all: 2 }); }); test('[DOCUMENTS]: deleting completed documents as an owner should hide it from only the owner', async ({ page }) => { @@ -239,11 +231,7 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from // Check document counts. await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible(); - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 1); - await checkDocumentTabCount(page, 'Completed', 0); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'All', 2); + await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 0, draft: 1, all: 2 }); // Sign into the recipient account. await apiSignout({ page }); @@ -255,11 +243,7 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from // Check document counts. await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).toBeVisible(); - await checkDocumentTabCount(page, 'Inbox', 1); - await checkDocumentTabCount(page, 'Pending', 0); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'Draft', 0); - await checkDocumentTabCount(page, 'All', 2); + await checkDocumentCounts(page, { inbox: 1, pending: 0, completed: 1, draft: 0, all: 2 }); }); test('[DOCUMENTS]: deleting documents as a recipient should only hide it for them', async ({ page }) => { @@ -300,11 +284,7 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the // Check document counts. await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible(); await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible(); - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 0); - await checkDocumentTabCount(page, 'Completed', 0); - await checkDocumentTabCount(page, 'Draft', 0); - await checkDocumentTabCount(page, 'All', 0); + await checkDocumentCounts(page, { inbox: 0, pending: 0, completed: 0, draft: 0, all: 0 }); // Sign into the sender account. await apiSignout({ page }); @@ -315,11 +295,7 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the }); // Check document counts for sender. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 1); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'All', 3); + await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 1, draft: 1, all: 3 }); // Sign into the other recipient account. await apiSignout({ page }); @@ -330,9 +306,5 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the }); // Check document counts for other recipient. - await checkDocumentTabCount(page, 'Inbox', 1); - await checkDocumentTabCount(page, 'Pending', 0); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'Draft', 0); - await checkDocumentTabCount(page, 'All', 2); + await checkDocumentCounts(page, { inbox: 1, pending: 0, completed: 1, draft: 0, all: 2 }); }); diff --git a/packages/app-tests/e2e/documents/find-documents.spec.ts b/packages/app-tests/e2e/documents/find-documents.spec.ts index 960a09863..143c6225b 100644 --- a/packages/app-tests/e2e/documents/find-documents.spec.ts +++ b/packages/app-tests/e2e/documents/find-documents.spec.ts @@ -20,7 +20,7 @@ import { } from '@prisma/client'; import { apiSignin, apiSignout } from '../fixtures/authentication'; -import { checkDocumentTabCount } from '../fixtures/documents'; +import { checkDocumentCounts, checkDocumentTabCount, toggleDocumentSenderFilter } from '../fixtures/documents'; test.describe.configure({ mode: 'parallel', @@ -61,10 +61,7 @@ test.describe('Find Documents UI - Personal Context', () => { redirectPath: `/t/${team.url}/documents`, }); - await checkDocumentTabCount(page, 'All', 3); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'Pending', 1); - await checkDocumentTabCount(page, 'Completed', 1); + await checkDocumentCounts(page, { draft: 1, pending: 1, completed: 1, all: 3 }); }); test('received documents from other teams should NOT appear in personal context', async ({ page }) => { @@ -140,10 +137,9 @@ test.describe('Find Documents UI - Personal Context', () => { redirectPath: `/t/${ownerTeam.url}/documents`, }); - // Inbox should be 0 since there's no team email and received docs are on sender's team - await checkDocumentTabCount(page, 'Inbox', 0); - // Owner's own doc should still show in All - await checkDocumentTabCount(page, 'All', 1); + // Inbox should be 0 since there's no team email and received docs are on sender's team. + // Owner's own doc should still show in All. + await checkDocumentCounts(page, { inbox: 0, all: 1 }); await expect(page.getByRole('link', { name: 'Owner Draft Control' })).toBeVisible(); }); @@ -707,9 +703,8 @@ test.describe('Find Documents UI - Team with Team Email', () => { redirectPath: `/t/${team.url}/documents`, }); - await checkDocumentTabCount(page, 'Inbox', 0); - // But pending should still show - await checkDocumentTabCount(page, 'Pending', 1); + // Inbox should be 0, but pending should still show. + await checkDocumentCounts(page, { inbox: 0, pending: 1 }); }); test('documents sent BY team email user should appear in team context', async ({ page }) => { @@ -810,12 +805,9 @@ test.describe('Find Documents UI - Data Isolation & No Leaking', () => { }); // UserA should see only their own docs - await checkDocumentTabCount(page, 'All', 3); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'Completed', 1); + await checkDocumentCounts(page, { draft: 1, completed: 1, all: 3 }); // Verify no B docs leaked - await page.getByRole('tab', { name: 'All' }).click(); await expect(page.getByRole('link', { name: 'A Own Draft' })).toBeVisible(); await expect(page.getByRole('link', { name: 'B Draft Private', exact: true })).not.toBeVisible(); await expect(page.getByRole('link', { name: 'B Pending Private', exact: true })).not.toBeVisible(); @@ -966,9 +958,9 @@ test.describe('Find Documents UI - Data Isolation & No Leaking', () => { redirectPath: `/t/${outsideTeam.url}/documents`, }); - // Only the outside user's own draft should appear (cross-team docs are not visible) - await checkDocumentTabCount(page, 'Inbox', 0); // No team email → 0 - await checkDocumentTabCount(page, 'All', 1); // Check All tab last so we can verify visible links + // Only the outside user's own draft should appear (cross-team docs are not visible). + // Inbox is 0 since there is no team email. + await checkDocumentCounts(page, { inbox: 0, all: 1 }); await expect(page.getByRole('link', { name: 'Outside Own Draft' })).toBeVisible(); await expect(page.getByRole('link', { name: 'Team Doc For Outside User', exact: true })).not.toBeVisible(); await expect(page.getByRole('link', { name: 'Team Doc For Other User Only', exact: true })).not.toBeVisible(); @@ -1013,12 +1005,10 @@ test.describe('Find Documents UI - Tab Counts Consistency', () => { redirectPath: `/t/${ownerTeam.url}/documents`, }); - // Only owner's own docs appear (received docs are on sender's team) - await checkDocumentTabCount(page, 'Draft', 2); - await checkDocumentTabCount(page, 'Pending', 1); - await checkDocumentTabCount(page, 'Inbox', 0); // No team email → inbox returns null → 0 - await checkDocumentTabCount(page, 'Completed', 1); // Only owned completed (received is on sender's team) - await checkDocumentTabCount(page, 'All', 4); // 2 drafts + 1 pending + 1 completed + // Only owner's own docs appear (received docs are on sender's team). + // Inbox is 0 since there is no team email, and only the owned completed + // doc counts (received is on sender's team). All = 2 drafts + 1 pending + 1 completed. + await checkDocumentCounts(page, { inbox: 0, draft: 2, pending: 1, completed: 1, all: 4 }); }); test('team context tab counts should be accurate with mixed documents', async ({ page }) => { @@ -1070,10 +1060,7 @@ test.describe('Find Documents UI - Tab Counts Consistency', () => { redirectPath: `/t/${team.url}/documents`, }); - await checkDocumentTabCount(page, 'Draft', 2); - await checkDocumentTabCount(page, 'Pending', 1); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'All', 4); + await checkDocumentCounts(page, { draft: 2, pending: 1, completed: 1, all: 4 }); }); test('team with team email tab counts should include received documents', async ({ page }) => { @@ -1107,11 +1094,9 @@ test.describe('Find Documents UI - Tab Counts Consistency', () => { redirectPath: `/t/${team.url}/documents`, }); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'Inbox', 1); // One pending doc received by team email (NOT_SIGNED) - await checkDocumentTabCount(page, 'Pending', 1); // Own pending - await checkDocumentTabCount(page, 'Completed', 1); // Received completed via email - await checkDocumentTabCount(page, 'All', 4); // All of the above + // Inbox = one pending doc received by team email (NOT_SIGNED), pending = own + // pending, completed = received completed via email, all = all of the above. + await checkDocumentCounts(page, { inbox: 1, draft: 1, pending: 1, completed: 1, all: 4 }); }); }); @@ -1163,9 +1148,7 @@ test.describe('Find Documents UI - Sender Filter', () => { await checkDocumentTabCount(page, 'All', 3); // Filter by member1 - await page.locator('button').filter({ hasText: 'Sender: All' }).click(); - await page.getByRole('option', { name: member1.name ?? '' }).click(); - await page.waitForURL(/senderIds/); + await toggleDocumentSenderFilter(page, member1.name ?? ''); // Should only show member1's doc await checkDocumentTabCount(page, 'All', 1); diff --git a/packages/app-tests/e2e/fixtures/documents.ts b/packages/app-tests/e2e/fixtures/documents.ts index 160dc1030..fbc49241a 100644 --- a/packages/app-tests/e2e/fixtures/documents.ts +++ b/packages/app-tests/e2e/fixtures/documents.ts @@ -1,11 +1,116 @@ import type { Page } from '@playwright/test'; import { expect } from '@playwright/test'; -export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => { - await page.getByRole('tab', { name: tabName }).click(); +type DocumentStatusCounts = { + inbox?: number; + pending?: number; + completed?: number; + draft?: number; + cancelled?: number; + rejected?: number; + expired?: number; + all?: number; +}; - if (tabName !== 'All') { - await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString()); +const STATUS_KEYS = { + inbox: 'INBOX', + pending: 'PENDING', + completed: 'COMPLETED', + draft: 'DRAFT', + cancelled: 'CANCELLED', + rejected: 'REJECTED', + expired: 'EXPIRED', + all: 'ALL', +} as const; + +/** + * Check the counts for multiple document statuses in one go via the + * visually hidden stats rendered alongside the status filter. + * + * When `all` is provided the status filter is also cleared and the + * unfiltered table count (or empty state) is verified. + */ +export const checkDocumentCounts = async (page: Page, counts: DocumentStatusCounts) => { + for (const [key, status] of Object.entries(STATUS_KEYS)) { + const count = counts[key as keyof typeof STATUS_KEYS]; + + if (count === undefined) { + continue; + } + + await expect(page.getByTestId(`documents-status-count-${status}`)).toHaveText(count.toString()); + } + + if (counts.all !== undefined) { + await clearDocumentStatusFilter(page); + + if (counts.all === 0) { + await expect(page.getByTestId('empty-document-state')).toBeVisible(); + return; + } + + await expect(page.getByTestId('data-table-count')).toContainText(`Showing ${counts.all}`); + } +}; + +/** + * Select a status in the documents status filter pill. + * + * No-op if the status is already selected, since selecting the active + * option again would clear the filter. + */ +export const selectDocumentStatusFilter = async (page: Page, statusName: string) => { + const currentStatus = new URL(page.url()).searchParams.get('status'); + + if (currentStatus === statusName.toUpperCase()) { + return; + } + + await page.getByTestId('documents-table-status-filter').click(); + await page.getByRole('option', { name: statusName }).click(); +}; + +/** + * Toggle a sender in the documents sender filter pill. + * + * The sender filter is a multi select, so the popover stays open after + * picking and is closed with Escape. + */ +export const toggleDocumentSenderFilter = async (page: Page, senderName: string) => { + await page.getByTestId('documents-table-sender-filter').click(); + await page.getByRole('option', { name: senderName }).click(); + await page.waitForURL(/senderIds/); + await page.keyboard.press('Escape'); +}; + +/** + * Clear the documents status filter pill, returning to the "All" view. + */ +export const clearDocumentStatusFilter = async (page: Page) => { + const currentStatus = new URL(page.url()).searchParams.get('status'); + + if (!currentStatus) { + return; + } + + await page.getByTestId('documents-table-status-filter').click(); + await page.getByRole('option', { name: 'Clear' }).click(); +}; + +/** + * Apply a status filter (or 'All' to clear it) and verify both the hidden + * stats count and the resulting table. + * + * The count is not asserted against the stats for 'All', since tests use it + * with search queries applied which only the table respects. + */ +export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => { + if (tabName === 'All') { + await clearDocumentStatusFilter(page); + } else { + await expect(page.getByTestId(`documents-status-count-${tabName.toUpperCase()}`)).toHaveText(count.toString()); + + await selectDocumentStatusFilter(page, tabName); } if (count === 0) { diff --git a/packages/app-tests/e2e/teams/team-documents.spec.ts b/packages/app-tests/e2e/teams/team-documents.spec.ts index 6d5f2ca08..c8a7df77e 100644 --- a/packages/app-tests/e2e/teams/team-documents.spec.ts +++ b/packages/app-tests/e2e/teams/team-documents.spec.ts @@ -5,7 +5,7 @@ import { expect, test } from '@playwright/test'; import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client'; import { apiSignin, apiSignout } from '../fixtures/authentication'; -import { checkDocumentTabCount } from '../fixtures/documents'; +import { checkDocumentCounts, checkDocumentTabCount, toggleDocumentSenderFilter } from '../fixtures/documents'; import { expectTextToBeVisible, expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic'; test('[TEAMS]: check team documents count', async ({ page }) => { @@ -20,23 +20,13 @@ test('[TEAMS]: check team documents count', async ({ page }) => { }); // Check document counts. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 2); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'Draft', 2); - await checkDocumentTabCount(page, 'All', 5); + await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 1, draft: 2, all: 5 }); // Apply filter. - await page.locator('button').filter({ hasText: 'Sender: All' }).click(); - await page.getByRole('option', { name: teamMember2.name ?? '' }).click(); - await page.waitForURL(/senderIds/); + await toggleDocumentSenderFilter(page, teamMember2.name ?? ''); // Check counts after filtering. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 2); - await checkDocumentTabCount(page, 'Completed', 0); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'All', 3); + await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 1, all: 3 }); await apiSignout({ page }); } @@ -115,23 +105,13 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa }); // Check document counts. - await checkDocumentTabCount(page, 'Inbox', 2); - await checkDocumentTabCount(page, 'Pending', 3); - await checkDocumentTabCount(page, 'Completed', 3); - await checkDocumentTabCount(page, 'Draft', 3); - await checkDocumentTabCount(page, 'All', 11); + await checkDocumentCounts(page, { inbox: 2, pending: 3, completed: 3, draft: 3, all: 11 }); // Apply filter. - await page.locator('button').filter({ hasText: 'Sender: All' }).click(); - await page.getByRole('option', { name: teamMember2.name ?? '' }).click(); - await page.waitForURL(/senderIds/); + await toggleDocumentSenderFilter(page, teamMember2.name ?? ''); // Check counts after filtering. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 2); - await checkDocumentTabCount(page, 'Completed', 0); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'All', 3); + await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 1, all: 3 }); await apiSignout({ page }); } @@ -202,23 +182,13 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa }); // Check document counts. - await checkDocumentTabCount(page, 'Inbox', 3); - await checkDocumentTabCount(page, 'Pending', 2); - await checkDocumentTabCount(page, 'Completed', 2); - await checkDocumentTabCount(page, 'Draft', 2); - await checkDocumentTabCount(page, 'All', 9); + await checkDocumentCounts(page, { inbox: 3, pending: 2, completed: 2, draft: 2, all: 9 }); // Apply filter. - await page.locator('button').filter({ hasText: 'Sender: All' }).click(); - await page.getByRole('option', { name: teamMember2.name ?? '' }).click(); - await page.waitForURL(/senderIds/); + await toggleDocumentSenderFilter(page, teamMember2.name ?? ''); // Check counts after filtering. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 2); - await checkDocumentTabCount(page, 'Completed', 0); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'All', 3); + await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 1, all: 3 }); }); test('[TEAMS]: resend pending team document', async ({ page }) => { @@ -273,11 +243,7 @@ test('[TEAMS]: delete draft team document', async ({ page }) => { }); // Check document counts. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 2); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'All', 4); + await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 1, draft: 1, all: 4 }); await apiSignout({ page }); } @@ -316,11 +282,7 @@ test('[TEAMS]: delete pending team document', async ({ page }) => { }); // Check document counts. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 1); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'Draft', 2); - await checkDocumentTabCount(page, 'All', 4); + await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 1, draft: 2, all: 4 }); await apiSignout({ page }); } @@ -359,11 +321,7 @@ test('[TEAMS]: delete completed team document', async ({ page }) => { }); // Check document counts. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 2); - await checkDocumentTabCount(page, 'Completed', 0); - await checkDocumentTabCount(page, 'Draft', 2); - await checkDocumentTabCount(page, 'All', 4); + await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 2, all: 4 }); await apiSignout({ page }); } 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 };