Merge branch 'main' into feat/template-document-name-options

This commit is contained in:
Catalin Pit
2026-08-04 14:39:19 +03:00
committed by GitHub
25 changed files with 1669 additions and 358 deletions
@@ -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<DialogPrimitive.DialogProps, 'children'>;
export const EnvelopesBulkDownloadDialog = ({
envelopes,
open,
onOpenChange,
onSuccess,
...props
}: EnvelopesBulkDownloadDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [versionMap, setVersionMap] = useState<Record<string, BulkDownloadVersion>>({});
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 (
<Dialog
{...props}
open={open}
onOpenChange={(value) => {
if (!isDownloading) {
onOpenChange(value);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Download Documents</Trans>
</DialogTitle>
<DialogDescription>
<Plural
value={envelopes.length}
one="Select the version to download for the selected document."
other="Select the version to download for each of the # selected documents."
/>
</DialogDescription>
</DialogHeader>
{isOverDownloadLimit && (
<Alert variant="warning">
<AlertDescription>
<Trans>
You can download up to {MAX_BULK_DOWNLOAD_ENVELOPES} documents at a time. Deselect some documents to
continue.
</Trans>
</AlertDescription>
</Alert>
)}
<fieldset disabled={isDownloading} className="space-y-4">
<div className="-mx-3 max-h-96 overflow-y-auto px-3">
<div className="divide-y divide-border rounded-lg border border-border">
{envelopes.map((envelope) => {
const versionOptions = getVersionOptions(envelope);
return (
<div key={envelope.id} className="flex items-center gap-3 px-3 py-2.5">
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground text-sm" title={envelope.title}>
{envelope.title}
</p>
<p className="text-muted-foreground text-xs">{getStatusLabel(envelope.status)}</p>
</div>
{versionOptions && (
<RadioGroupSegmented
className="shrink-0"
value={getDownloadVersion(envelope)}
onValueChange={(value) =>
setVersionMap((prev) => ({
...prev,
[envelope.id]: value as BulkDownloadVersion,
}))
}
aria-label={t`Download version for ${envelope.title}`}
>
{versionOptions.map((option) => (
<RadioGroupSegmentedItem key={option.value} value={option.value}>
{option.label}
</RadioGroupSegmentedItem>
))}
</RadioGroupSegmented>
)}
</div>
);
})}
</div>
</div>
{isDownloading && (
<p className="text-muted-foreground text-sm">
<Trans>
Downloading {progress} / {envelopes.length}...
</Trans>
</p>
)}
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => {
if (isDownloading) {
abortRef.current = true;
} else {
onOpenChange(false);
}
}}
>
{isDownloading ? <Trans>Stop</Trans> : <Trans>Cancel</Trans>}
</Button>
<Button
type="button"
onClick={() => void onDownload()}
loading={isDownloading}
disabled={envelopes.length === 0 || isOverDownloadLimit}
>
<Trans>Download</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};
@@ -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 (
<Input
@@ -0,0 +1,186 @@
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { Separator } from '@documenso/ui/primitives/separator';
import { Trans } from '@lingui/react/macro';
import { CheckIcon, ChevronDownIcon } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import type { ReactNode } from 'react';
import { useState } from 'react';
export type FilterPillOption = {
value: string;
label: ReactNode;
trailing?: string;
};
type FilterPillCommonProps = {
icon: LucideIcon;
label: ReactNode;
options: FilterPillOption[];
enableSearch?: boolean;
searchPlaceholder?: string;
loading?: boolean;
testId?: string;
};
export type FilterPillSingleProps = FilterPillCommonProps & {
multiple?: false;
value: string | null;
onChange: (value: string | null) => 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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
disabled={loading}
className={cn('border-dashed text-muted-foreground', {
'border-solid text-foreground': hasSelection,
})}
data-testid={testId}
>
<Icon className="mr-2 h-4 w-4" />
{label}
{hasSelection && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
{props.multiple ? (
<span className="flex items-center gap-x-1">
{selectedOptions.slice(0, 2).map((option) => (
<Badge key={option.value} variant="neutral" size="small">
{option.label}
</Badge>
))}
{extraCount > 0 && (
<Badge variant="neutral" size="small">
<Trans>+{extraCount} more</Trans>
</Badge>
)}
</span>
) : (
<span className="font-medium">{props.selectedLabel ?? selectedOptions[0].label}</span>
)}
</>
)}
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-52 p-0" align="start">
<Command>
{enableSearch && <CommandInput placeholder={searchPlaceholder} />}
<CommandList>
<CommandEmpty>
<Trans>No results found.</Trans>
</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem key={option.value} onSelect={() => onSelect(option.value)}>
<CheckIcon
className={cn(
'mr-2 h-4 w-4 shrink-0',
selectedValues.includes(option.value) ? 'opacity-100' : 'opacity-0',
)}
/>
{option.label}
{option.trailing !== undefined && (
<span className="ml-auto pl-4 text-muted-foreground text-xs">{option.trailing}</span>
)}
</CommandItem>
))}
</CommandGroup>
{hasSelection && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem className="justify-center text-center text-muted-foreground" onSelect={onClear}>
<Trans>Clear</Trans>
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
@@ -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: <Trans>Last 7 days</Trans> },
{ value: '14d', label: <Trans>Last 14 days</Trans> },
{ value: '30d', label: <Trans>Last 30 days</Trans> },
];
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 (
<FilterPill
icon={CalendarIcon}
label={<Trans>Period</Trans>}
value={period}
onChange={onChange}
options={PERIOD_OPTIONS}
testId="documents-table-period-filter"
/>
);
};
@@ -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 (
<MultiSelectCombobox
emptySelectionPlaceholder={
<p className="font-normal text-muted-foreground">
<Trans>
<span className="text-muted-foreground/70">Sender:</span> All
</Trans>
</p>
}
enableClearAllButton={true}
inputPlaceholder={msg`Search`}
loading={!isMounted || isLoading}
options={comboBoxOptions}
selectedValues={senderIds}
<FilterPill
multiple
icon={UserIcon}
label={<Trans>Sender</Trans>}
value={selectedSenderIds}
onChange={onChange}
options={options}
enableSearch
searchPlaceholder={_(msg`Search members...`)}
loading={!isMounted || isLoading}
testId="documents-table-sender-filter"
/>
);
};
@@ -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 (
<>
<FilterPill
icon={ListFilterIcon}
label={<Trans>Status</Trans>}
value={selectedStatus}
onChange={onChange}
selectedLabel={selectedStatus && <DocumentStatus status={selectedStatus} className="[&>svg]:mr-1.5" />}
options={selectableStatuses.map((value) => ({
value,
label: <DocumentStatus status={value} />,
trailing: formatStatsCount(stats[value]),
}))}
testId="documents-table-status-filter"
/>
{/* Visually hidden document counts, for screen readers and tests. */}
<span className="sr-only" data-testid="documents-status-counts">
{[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => (
<span key={value}>
{_(FRIENDLY_STATUS_MAP[value].label)}:{' '}
<span data-testid={`documents-status-count-${value}`}>{stats[value]}</span>
</span>
))}
</span>
</>
);
};
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();
};
@@ -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 (
<div className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-4 rounded-lg border border-border bg-background px-4 py-3 shadow-lg">
<span className="font-medium text-sm">
<Trans>{selectedCount} selected</Trans>
</span>
<div className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-1 rounded-xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-black/10 dark:ring-white/10">
<div className="flex items-center gap-x-2 px-2">
<span className="sr-only" aria-live="polite">
<Trans>{selectedCount} selected</Trans>
</span>
<span
aria-hidden="true"
className="flex h-5 min-w-5 items-center justify-center rounded-md bg-primary px-1 font-semibold text-primary-foreground text-xs tabular-nums"
>
{selectedCount}
</span>
<span aria-hidden="true" className="font-medium text-foreground text-sm max-[420px]:hidden">
<Trans>selected</Trans>
</span>
</div>
<div className="h-6 w-px bg-border" />
<div className="mx-1 h-5 w-px bg-border" />
<Button type="button" variant="outline" size="sm" onClick={onMoveClick}>
<FolderInputIcon className="mr-2 h-4 w-4" />
<Trans>Move to Folder</Trans>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onMoveClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
>
<FolderInputIcon className="size-4 shrink-0" />
<Trans>Move</Trans>
</Button>
{onDownloadClick && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onDownloadClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
>
<DownloadIcon className="size-4 shrink-0" />
<Trans>Download</Trans>
</Button>
)}
{onCancelClick && (
<Button type="button" variant="outline" size="sm" onClick={onCancelClick}>
<XCircleIcon className="mr-2 h-4 w-4" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={onCancelClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
>
<XCircleIcon className="size-4 shrink-0" />
<Trans>Cancel</Trans>
</Button>
)}
<Button type="button" variant="destructive" size="sm" onClick={onDeleteClick}>
<Trash2Icon className="mr-2 h-4 w-4" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={onDeleteClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2 text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2Icon className="size-4 shrink-0" />
<Trans>Delete</Trans>
</Button>
<Button variant="ghost" size="sm" onClick={onClearSelection} aria-label={t`Clear selection`}>
<XIcon className="h-4 w-4" />
<div className="mx-1 h-5 w-px bg-border" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClearSelection}
aria-label={t`Clear selection`}
className="h-8 w-8 p-0"
>
<XIcon className="size-4 shrink-0" />
</Button>
</div>
);
@@ -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<string, { title: string; status: PrismaDocumentStatus; isLegacy: boolean }>;
// 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<string | null>(null);
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('documents-bulk-selection', {});
// Scoped by team so selections made in one team never leak into another.
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
`documents-bulk-selection-${team.id}`,
EMPTY_ROW_SELECTION,
);
const [envelopeMetaCache, setEnvelopeMetaCache] = useSessionStorage<EnvelopeMetaCache>(
`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() {
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<FolderGrid type={FolderType.DOCUMENT} parentId={folderId ?? null} />
<div className="mt-8 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
<div className="flex flex-row items-center">
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
</Avatar>
<div className="mt-8 flex flex-row items-center">
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
</Avatar>
<h2 className="font-semibold text-4xl">
<Trans>Documents</Trans>
</h2>
<h2 className="font-semibold text-4xl">
<Trans>Documents</Trans>
</h2>
</div>
<div className="mt-8 flex flex-wrap items-center gap-x-2 gap-y-4">
<div className="w-56">
<DocumentSearch />
</div>
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
<TabsList>
{[
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;
}
<DocumentsTableStatusFilter stats={stats} />
return true;
})
.map((value) => (
<TabsTrigger key={value} className="min-w-[60px] hover:text-foreground" value={value} asChild>
<Link to={getTabHref(value)} preventScrollReset>
<DocumentStatus status={value} />
{team && <DocumentsTableSenderFilter teamId={team.id} />}
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 inline-block opacity-50">
{stats[value] >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : stats[value]}
</span>
)}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
<DocumentsTablePeriodFilter />
{team && <DocumentsTableSenderFilter teamId={team.id} />}
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
</div>
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<DocumentSearch initialValue={findDocumentSearchParams.query} />
</div>
</div>
{hasActiveFilters && (
<Button variant="ghost" className="px-2 text-muted-foreground lg:px-3" onClick={onResetFilters}>
<Trans>Reset</Trans>
<XIcon className="ml-1 h-4 w-4" />
</Button>
)}
</div>
<div className="mt-8">
<div>
{data && data.count === 0 ? (
<DocumentsTableEmptyState status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL} />
<DocumentsTableEmptyState status={findDocumentSearchParams.status ?? ExtendedDocumentStatus.ALL} />
) : (
<DocumentsTable
data={data}
@@ -238,12 +252,28 @@ export default function DocumentsPage() {
<EnvelopesTableBulkActionBar
selectedCount={selectedEnvelopeIds.length}
onDownloadClick={() => setIsBulkDownloadDialogOpen(true)}
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
onCancelClick={() => setIsBulkCancelDialogOpen(true)}
onClearSelection={() => setRowSelection({})}
/>
<EnvelopesBulkDownloadDialog
envelopes={selectedEnvelopesForDownload}
open={isBulkDownloadDialogOpen}
onOpenChange={setIsBulkDownloadDialogOpen}
onSuccess={(successfulEnvelopeIds) => {
setRowSelection((prev) => {
const next = { ...prev };
for (const id of successfulEnvelopeIds) {
delete next[id];
}
return next;
});
}}
/>
<EnvelopesBulkMoveDialog
envelopeIds={selectedEnvelopeIds}
envelopeType={EnvelopeType.DOCUMENT}
@@ -26,12 +26,14 @@ import { appMetaTags } from '~/utils/meta';
const TEMPLATE_VIEWS = ['team', 'organisation'] as const;
type TemplateView = (typeof TEMPLATE_VIEWS)[number];
export function meta() {
return appMetaTags(msg`Templates`);
}
// Stable initial value: `useSessionStorage` keeps its setter identity stable
// only while the initial value reference is stable.
const EMPTY_ROW_SELECTION: RowSelectionState = {};
export default function TemplatesPage() {
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
@@ -47,7 +49,11 @@ export default function TemplatesPage() {
const isOrgView = view === 'organisation';
const showOrgTab = organisation.type !== OrganisationType.PERSONAL;
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('templates-bulk-selection', {});
// Scoped by team so selections made in one team never leak into another.
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
`templates-bulk-selection-${team.id}`,
EMPTY_ROW_SELECTION,
);
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
@@ -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,
};
+10 -3
View File
@@ -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",
+1
View File
@@ -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",
@@ -0,0 +1,118 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
const downloadUrl = (envelopeId: string, envelopeItemId: string, version: 'original' | 'signed' | 'pending') =>
`${WEBAPP_BASE_URL}/api/files/envelope/${envelopeId}/envelopeItem/${envelopeItemId}/download/${version}`;
const seedOwnerWithDraft = async () => {
const owner = await seedUser();
const draft = await seedDraftDocument(owner.user, owner.team.id, [], {
createDocumentOptions: { title: 'File Download Auth Test' },
});
return { owner, draft, draftItem: draft.envelopeItems[0] };
};
test.describe('Envelope item file download endpoint authorization', () => {
test('rejects an unauthenticated download request', async ({ request }) => {
const { draft, draftItem } = await seedOwnerWithDraft();
const res = await request.get(downloadUrl(draft.id, draftItem.id, 'original'));
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a download request from a user outside the organisation', async ({ page }) => {
const { draft, draftItem } = await seedOwnerWithDraft();
const { user: outsider } = await seedUser();
await apiSignin({ page, email: outsider.email });
const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'original'));
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(403);
});
test('returns 404 for a nonexistent envelope', async ({ page }) => {
const { user } = await seedUser();
await apiSignin({ page, email: user.email });
const res = await page.request.get(
downloadUrl('envelope_does_not_exist', 'envelope_item_does_not_exist', 'original'),
);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('rejects a pending version download for a draft envelope', async ({ page }) => {
const { owner, draft, draftItem } = await seedOwnerWithDraft();
await apiSignin({ page, email: owner.user.email });
const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'pending'));
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('rejects a pending version download for a legacy envelope', async ({ page }) => {
const owner = await seedUser();
const { user: recipient } = await seedUser();
// Default internalVersion is 1 (legacy).
const pendingDocument = await seedPendingDocument(owner.user, owner.team.id, [recipient], {
createDocumentOptions: { title: 'Legacy Pending Download Test' },
});
const envelopeItem = pendingDocument.envelopeItems[0];
await apiSignin({ page, email: owner.user.email });
const res = await page.request.get(downloadUrl(pendingDocument.id, envelopeItem.id, 'pending'));
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('allows the owner to download their own document', async ({ page }) => {
const { owner, draft, draftItem } = await seedOwnerWithDraft();
await apiSignin({ page, email: owner.user.email });
const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'original'));
expect(res.ok()).toBeTruthy();
expect(res.headers()['content-type']).toContain('application/pdf');
const body = await res.body();
// %PDF magic bytes.
expect(Array.from(body.subarray(0, 4))).toEqual([0x25, 0x50, 0x44, 0x46]);
});
test('rejects a recipient-token download with an invalid token', async ({ request }) => {
const { draftItem } = await seedOwnerWithDraft();
const res = await request.get(
`${WEBAPP_BASE_URL}/api/files/token/invalid-token-12345/envelopeItem/${draftItem.id}/download/original`,
);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
});
@@ -1,3 +1,5 @@
import fs from 'node:fs';
import { createTeam } from '@documenso/lib/server-only/team/create-team';
import { prisma } from '@documenso/prisma';
import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
@@ -5,6 +7,7 @@ import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { unzipSync } from 'fflate';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { expectToastTextToBeVisible } from '../fixtures/generic';
@@ -50,10 +53,10 @@ test('[BULK_ACTIONS]: can select multiple documents with checkboxes', async ({ p
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
await expect(page.getByText('2 selected')).toBeVisible();
await expect(page.getByText(/2\s*selected/)).toBeVisible();
});
test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ page }) => {
@@ -67,7 +70,7 @@ test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ p
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(`${documents.length} selected`)).toBeVisible();
await expect(page.getByText(new RegExp(`${documents.length}\\s*selected`))).toBeVisible();
});
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
@@ -80,11 +83,11 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
});
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(/\d+ selected/)).toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).toBeVisible();
await page.getByLabel('Clear selection').click();
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page }) => {
@@ -98,13 +101,13 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page })
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('Move Documents to Folder')).toBeVisible();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
@@ -113,6 +116,122 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page })
await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).toBeVisible();
});
test('[BULK_ACTIONS]: selection does not leak between teams', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
const teamBUrl = `team-b-${Date.now()}`;
await createTeam({
userId: sender.user.id,
teamName: 'Team B',
teamUrl: teamBUrl,
organisationId: sender.organisation.id,
inheritMembers: true,
});
const teamB = await prisma.team.findFirstOrThrow({
where: { url: teamBUrl },
});
await seedDraftDocument(sender.user, teamB.id, [], {
createDocumentOptions: { title: 'Team B Doc' },
});
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
// The selection made in team A must not appear in team B.
await page.goto(`/t/${teamBUrl}/documents`);
await expect(page.getByRole('link', { name: 'Team B Doc' })).toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
// Returning to team A restores its selection.
await page.goto(`/t/${sender.team.url}/documents`);
await expect(page.getByText(/1\s*selected/)).toBeVisible();
});
test('[BULK_ACTIONS]: escape clears selection unless a dialog is open', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
// Escape while a dialog is open should close the dialog but keep the selection.
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
// Escape with no dialog open should clear the selection.
await page.keyboard.press('Escape');
await expect(page.getByText(/1\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can bulk download multiple documents as a zip', async ({ page }) => {
const { sender, documents } = await seedBulkActionsTestRequirements();
const [doc1, doc2] = documents;
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Download', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByText('Download Documents')).toBeVisible();
await expect(dialog.getByText('Bulk Test Doc 1')).toBeVisible();
await expect(dialog.getByText('Bulk Test Doc 2')).toBeVisible();
await expect(dialog.getByText('Draft').first()).toBeVisible();
const downloadPromise = page.waitForEvent('download', { timeout: 10_000 });
await dialog.getByRole('button', { name: 'Download' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^documenso-documents-\d{4}-\d{2}-\d{2}\.zip$/);
const downloadPath = await download.path();
const zipContents = unzipSync(new Uint8Array(fs.readFileSync(downloadPath)));
// Each envelope's files are nested inside an `envelopeId_title` folder.
expect(Object.keys(zipContents).sort()).toEqual(
[`${doc1.id}_Bulk Test Doc 1/Bulk Test Doc 1.pdf`, `${doc2.id}_Bulk Test Doc 2/Bulk Test Doc 2.pdf`].sort(),
);
// Each entry should be a valid non-empty PDF (%PDF magic bytes).
for (const entry of Object.values(zipContents)) {
expect(Array.from(entry.slice(0, 4))).toEqual([0x25, 0x50, 0x44, 0x46]);
}
await expectToastTextToBeVisible(page, 'Documents downloaded');
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can delete multiple draft documents', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
@@ -152,14 +271,14 @@ test('[BULK_ACTIONS]: selection clears after successful move', async ({ page })
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
@@ -172,13 +291,13 @@ test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
await expectToastTextToBeVisible(page, 'Documents deleted');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
@@ -199,7 +318,7 @@ test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) =
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
@@ -236,14 +355,14 @@ test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ p
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
@@ -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' })
@@ -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 });
});
@@ -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);
+109 -4
View File
@@ -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) {
@@ -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 });
}
@@ -49,10 +49,10 @@ test('[BULK_ACTIONS]: can select multiple templates with checkboxes', async ({ p
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
await expect(page.getByText('2 selected')).toBeVisible();
await expect(page.getByText(/2\s*selected/)).toBeVisible();
});
test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ page }) => {
@@ -66,7 +66,7 @@ test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ p
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(`${templates.length} selected`)).toBeVisible();
await expect(page.getByText(new RegExp(`${templates.length}\\s*selected`))).toBeVisible();
});
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
@@ -79,11 +79,11 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
});
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(/\d+ selected/)).toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).toBeVisible();
await page.getByLabel('Clear selection').click();
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page }) => {
@@ -97,13 +97,13 @@ test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page })
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('Move Templates to Folder')).toBeVisible();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
@@ -151,14 +151,14 @@ test('[BULK_ACTIONS]: selection clears after successful move', async ({ page })
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
@@ -171,13 +171,13 @@ test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
await expectToastTextToBeVisible(page, 'Templates deleted');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
@@ -199,7 +199,7 @@ test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) =
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
@@ -236,14 +236,14 @@ test('[BULK_ACTIONS]: can move templates from folder to home (root)', async ({ p
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
+2 -2
View File
@@ -18,9 +18,9 @@
"@playwright/test": "1.56.1",
"@types/node": "^20",
"@types/pngjs": "^6.0.5",
"tsx": "^4.23.1",
"pixelmatch": "^7.1.0",
"pngjs": "^7.0.0"
"pngjs": "^7.0.0",
"tsx": "^4.23.1"
},
"dependencies": {
"start-server-and-test": "^2.1.3"
@@ -0,0 +1,178 @@
import { Zip, ZipPassThrough } from 'fflate';
export type ZipFileEntry = {
/**
* The path of the file within the archive. Forward slashes create folders.
* Individual path segments should be sanitized with
* {@link sanitizeZipPathSegment} when derived from user-controlled values.
*/
filename: string;
data: Blob;
};
/**
* Sanitizes a single path segment (folder or file name) for use inside a zip
* archive, replacing characters that are path separators or invalid on
* Windows extraction.
*/
export const sanitizeZipPathSegment = (segment: string): string => {
const sanitized = segment
.replace(/[\\/:*?"<>|\p{Cc}]/gu, '-')
.trim()
// Windows cannot extract folders or files ending with a dot.
.replace(/\.+$/, '');
return sanitized || 'untitled';
};
export type ZipWriter = {
/**
* Adds a file to the zip stream. Files are written incrementally so the
* input blob can be garbage collected once this resolves.
*/
addFile: (entry: ZipFileEntry) => Promise<void>;
/**
* Finishes the zip stream and returns the archive as a blob.
*/
finalize: () => Blob;
/**
* Discards the zip stream and any buffered output.
*/
abort: () => void;
};
/**
* How many bytes of a blob to materialise into the JS heap per read. Blobs
* (e.g. fetch responses) can be disk-backed by the browser, it is only
* `arrayBuffer()` that forces them into memory, so we read in slices.
*/
const READ_SLICE_BYTES = 4 * 1024 * 1024;
/**
* Once this many bytes of zip output have accumulated in the JS heap they are
* coalesced into an intermediate blob. Browsers can page blob storage to disk
* under memory pressure, and the final `new Blob(parts)` composes parts by
* reference, so this keeps the heap bounded regardless of archive size.
*/
const OUTPUT_COALESCE_BYTES = 16 * 1024 * 1024;
/**
* Creates an incremental client-side zip writer.
*
* Files are stored without compression (PDFs are already internally
* compressed) and streamed through the archive as they are added, so peak JS
* heap usage is bounded by roughly one read slice plus one output buffer
* rather than the total size of the archive.
*/
export const createZipWriter = (): ZipWriter => {
const usedNames = new Set<string>();
const outputParts: Blob[] = [];
let pendingChunks: Uint8Array[] = [];
let pendingSize = 0;
let zipError: Error | null = null;
const flushPendingChunks = () => {
if (pendingChunks.length === 0) {
return;
}
outputParts.push(new Blob(pendingChunks));
pendingChunks = [];
pendingSize = 0;
};
// ZipPassThrough is synchronous (no workers), so output callbacks have
// always fired by the time `push`/`end` return.
const zipStream = new Zip((error, chunk, isFinal) => {
if (error) {
zipError = error;
return;
}
pendingChunks.push(chunk);
pendingSize += chunk.length;
if (pendingSize >= OUTPUT_COALESCE_BYTES || isFinal) {
flushPendingChunks();
}
});
/**
* Deduplicates filenames case-insensitively (Windows extraction is
* case-insensitive) by appending " (n)" before the extension.
*/
const deduplicateFilename = (filename: string) => {
const match = filename.match(/^(.*?)(\.[^./]+)?$/);
const baseName = match?.[1] ?? filename;
const extension = match?.[2] ?? '';
let candidate = filename;
let counter = 1;
while (usedNames.has(candidate.toLowerCase())) {
candidate = `${baseName} (${counter})${extension}`;
counter += 1;
}
usedNames.add(candidate.toLowerCase());
return candidate;
};
const addFile = async ({ filename, data }: ZipFileEntry) => {
if (zipError) {
throw zipError;
}
const file = new ZipPassThrough(deduplicateFilename(filename));
zipStream.add(file);
for (let offset = 0; offset < data.size; offset += READ_SLICE_BYTES) {
const slice = data.slice(offset, offset + READ_SLICE_BYTES);
file.push(new Uint8Array(await slice.arrayBuffer()));
if (zipError) {
throw zipError;
}
}
file.push(new Uint8Array(0), true);
if (zipError) {
throw zipError;
}
};
const finalize = () => {
zipStream.end();
if (zipError) {
throw zipError;
}
flushPendingChunks();
return new Blob(outputParts, { type: 'application/zip' });
};
const abort = () => {
zipStream.terminate();
pendingChunks = [];
pendingSize = 0;
outputParts.length = 0;
};
return {
addFile,
finalize,
abort,
};
};
+22 -3
View File
@@ -32,7 +32,11 @@ const versionToFilenameSuffix = (version: DocumentVersion): string => {
}
};
export const downloadPDF = async ({ envelopeItem, token, fileName, version = 'signed' }: DownloadPDFProps) => {
/**
* Fetches a PDF for an envelope item and returns it as a blob alongside the
* filename it should be saved as. Throws on non-OK responses.
*/
export const fetchPDF = async ({ envelopeItem, token, fileName, version = 'signed' }: DownloadPDFProps) => {
const downloadUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem: envelopeItem,
@@ -40,12 +44,27 @@ export const downloadPDF = async ({ envelopeItem, token, fileName, version = 'si
version,
});
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
const response = await fetch(downloadUrl);
if (!response.ok) {
throw new Error(`Failed to download PDF: ${response.status}`);
}
const blob = await response.blob();
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
downloadFile({
return {
filename: `${baseTitle}${versionToFilenameSuffix(version)}`,
blob,
};
};
export const downloadPDF = async (options: DownloadPDFProps) => {
const { filename, blob } = await fetchPDF(options);
downloadFile({
filename,
data: blob,
});
};
+5 -2
View File
@@ -310,6 +310,9 @@ export const seedDraftDocument = async (
const documentId = await incrementDocumentId();
const envelopeTitle =
typeof createDocumentOptions.title === 'string' ? createDocumentOptions.title : `[TEST] Document ${key} - Draft`;
const document = await prisma.envelope.create({
data: {
id: prefixedId('envelope'),
@@ -320,12 +323,12 @@ export const seedDraftDocument = async (
documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT,
teamId,
title: `[TEST] Document ${key} - Draft`,
title: envelopeTitle,
status: DocumentStatus.DRAFT,
envelopeItems: {
create: {
id: prefixedId('envelope_item'),
title: `[TEST] Document ${key} - Draft`,
title: envelopeTitle,
documentDataId: documentData.id,
order: 1,
},
+40 -1
View File
@@ -35,4 +35,43 @@ const RadioGroupItem = React.forwardRef<
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };
/**
* A segmented-control style radio group where each item renders as a small
* toggle button rather than a radio circle.
*/
const RadioGroupSegmented = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn('inline-flex items-center gap-0.5 rounded-md bg-muted p-0.5', className)}
{...props}
ref={ref}
/>
);
});
RadioGroupSegmented.displayName = 'RadioGroupSegmented';
const RadioGroupSegmentedItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, children, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
'rounded-sm px-2 py-0.5 font-medium text-muted-foreground text-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-background data-[state=checked]:text-foreground data-[state=checked]:shadow-sm',
className,
)}
{...props}
>
{children}
</RadioGroupPrimitive.Item>
);
});
RadioGroupSegmentedItem.displayName = 'RadioGroupSegmentedItem';
export { RadioGroup, RadioGroupItem, RadioGroupSegmented, RadioGroupSegmentedItem };