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,
};