Merge branch 'main' of https://github.com/documenso/documenso into feat/redirect-templates

This commit is contained in:
Adithya Krishna
2024-04-30 17:43:57 +05:30
59 changed files with 1357 additions and 430 deletions

View File

@ -158,6 +158,7 @@ export const SinglePlayerClient = () => {
expired: null,
signedAt: null,
readStatus: 'OPENED',
documentDeletedAt: null,
signingStatus: 'NOT_SIGNED',
sendStatus: 'NOT_SENT',
role: 'SIGNER',

View File

@ -19,7 +19,7 @@ import { useSession } from 'next-auth/react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus } from '@documenso/prisma/client';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import type { Document, Recipient, Team, TeamEmail, User } from '@documenso/prisma/client';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import {
@ -41,7 +41,7 @@ export type DocumentPageViewDropdownProps = {
Recipient: Recipient[];
team: Pick<Team, 'id' | 'url'> | null;
};
team?: Pick<Team, 'id' | 'url'>;
team?: Pick<Team, 'id' | 'url'> & { teamEmail: TeamEmail | null };
};
export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => {
@ -59,9 +59,10 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
const isOwner = document.User.id === session.user.id;
const isDraft = document.status === DocumentStatus.DRAFT;
const isDeleted = document.deletedAt !== null;
const isComplete = document.status === DocumentStatus.COMPLETED;
const isDocumentDeletable = isOwner;
const isCurrentTeamDocument = team && document.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
const documentsPath = formatDocumentsPath(team?.url);
@ -127,7 +128,10 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
Duplicate
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}>
<DropdownMenuItem
onClick={() => setDeleteDialogOpen(true)}
disabled={Boolean(!canManageDocument && team?.teamEmail) || isDeleted}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
@ -154,15 +158,15 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
/>
</DropdownMenuContent>
{isDocumentDeletable && (
<DeleteDocumentDialog
id={document.id}
status={document.status}
documentTitle={document.title}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
/>
)}
<DeleteDocumentDialog
id={document.id}
status={document.status}
documentTitle={document.title}
open={isDeleteDialogOpen}
canManageDocument={canManageDocument}
onOpenChange={setDeleteDialogOpen}
/>
{isDuplicateDialogOpen && (
<DuplicateDocumentDialog
id={document.id}

View File

@ -8,17 +8,20 @@ import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { getCompletedFieldsForDocument } from '@documenso/lib/server-only/field/get-completed-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus } from '@documenso/prisma/client';
import type { Team } from '@documenso/prisma/client';
import type { Team, TeamEmail } from '@documenso/prisma/client';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
import {
DocumentStatus as DocumentStatusComponent,
FRIENDLY_STATUS_MAP,
@ -34,7 +37,7 @@ export type DocumentPageViewProps = {
params: {
id: string;
};
team?: Team;
team?: Team & { teamEmail: TeamEmail | null };
};
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
@ -83,11 +86,16 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
documentMeta.password = securePassword;
}
const recipients = await getRecipientsForDocument({
documentId,
teamId: team?.id,
userId: user.id,
});
const [recipients, completedFields] = await Promise.all([
getRecipientsForDocument({
documentId,
teamId: team?.id,
userId: user.id,
}),
getCompletedFieldsForDocument({
documentId,
}),
]);
const documentWithRecipients = {
...document,
@ -118,11 +126,17 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
<StackAvatarsWithTooltip
recipients={recipients}
documentStatus={document.status}
position="bottom"
>
<span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip>
</div>
)}
{document.deletedAt && <Badge variant="destructive">Document deleted</Badge>}
</div>
</div>
@ -148,6 +162,13 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
</CardContent>
</Card>
{document.status === DocumentStatus.PENDING && (
<DocumentReadOnlyFields
fields={completedFields}
documentMeta={document.documentMeta || undefined}
/>
)}
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<div className="space-y-6">
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">

View File

@ -92,7 +92,11 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
<StackAvatarsWithTooltip
recipients={recipients}
documentStatus={document.status}
position="bottom"
>
<span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip>
</div>

View File

@ -133,7 +133,11 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
</div>
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
<DownloadCertificateButton className="mr-2" documentId={document.id} />
<DownloadCertificateButton
className="mr-2"
documentId={document.id}
documentStatus={document.status}
/>
<DownloadAuditLogButton documentId={document.id} />
</div>

View File

@ -2,6 +2,7 @@
import { DownloadIcon } from 'lucide-react';
import { DocumentStatus } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -10,11 +11,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type DownloadCertificateButtonProps = {
className?: string;
documentId: number;
documentStatus: DocumentStatus;
};
export const DownloadCertificateButton = ({
className,
documentId,
documentStatus,
}: DownloadCertificateButtonProps) => {
const { toast } = useToast();
@ -69,6 +72,7 @@ export const DownloadCertificateButton = ({
className={cn('w-full sm:w-auto', className)}
loading={isLoading}
variant="outline"
disabled={documentStatus !== DocumentStatus.COMPLETED}
onClick={() => void onDownloadCertificatesClick()}
>
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}

View File

@ -15,7 +15,6 @@ import {
Pencil,
Share,
Trash2,
XCircle,
} from 'lucide-react';
import { useSession } from 'next-auth/react';
@ -45,7 +44,7 @@ export type DataTableActionDropdownProps = {
Recipient: Recipient[];
team: Pick<Team, 'id' | 'url'> | null;
};
team?: Pick<Team, 'id' | 'url'>;
team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
};
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
@ -67,8 +66,8 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
// const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED;
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isDocumentDeletable = isOwner;
const isCurrentTeamDocument = team && row.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
const documentsPath = formatDocumentsPath(team?.url);
@ -107,14 +106,14 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
return (
<DropdownMenu>
<DropdownMenuTrigger>
<DropdownMenuTrigger data-testid="document-table-action-btn">
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
{recipient && recipient?.role !== RecipientRole.CC && (
{!isDraft && recipient && recipient?.role !== RecipientRole.CC && (
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Link href={`/sign/${recipient?.token}`}>
{recipient?.role === RecipientRole.VIEWER && (
@ -141,7 +140,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
</DropdownMenuItem>
)}
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
<DropdownMenuItem disabled={!canManageDocument || isComplete} asChild>
<Link href={`${documentsPath}/${row.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
@ -158,14 +157,18 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
Duplicate
</DropdownMenuItem>
<DropdownMenuItem disabled>
{/* No point displaying this if there's no functionality. */}
{/* <DropdownMenuItem disabled>
<XCircle className="mr-2 h-4 w-4" />
Void
</DropdownMenuItem>
</DropdownMenuItem> */}
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}>
<DropdownMenuItem
onClick={() => setDeleteDialogOpen(true)}
disabled={Boolean(!canManageDocument && team?.teamEmail)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
{canManageDocument ? 'Delete' : 'Hide'}
</DropdownMenuItem>
<DropdownMenuLabel>Share</DropdownMenuLabel>
@ -186,16 +189,16 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
/>
</DropdownMenuContent>
{isDocumentDeletable && (
<DeleteDocumentDialog
id={row.id}
status={row.status}
documentTitle={row.title}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
teamId={team?.id}
/>
)}
<DeleteDocumentDialog
id={row.id}
status={row.status}
documentTitle={row.title}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
teamId={team?.id}
canManageDocument={canManageDocument}
/>
{isDuplicateDialogOpen && (
<DuplicateDocumentDialog
id={row.id}

View File

@ -29,7 +29,7 @@ export type DocumentsDataTableProps = {
}
>;
showSenderColumn?: boolean;
team?: Pick<Team, 'id' | 'url'>;
team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
};
export const DocumentsDataTable = ({
@ -76,7 +76,12 @@ export const DocumentsDataTable = ({
{
header: 'Recipient',
accessorKey: 'recipient',
cell: ({ row }) => <StackAvatarsWithTooltip recipients={row.original.Recipient} />,
cell: ({ row }) => (
<StackAvatarsWithTooltip
recipients={row.original.Recipient}
documentStatus={row.original.status}
/>
),
},
{
header: 'Status',

View File

@ -2,8 +2,11 @@ import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { match } from 'ts-pattern';
import { DocumentStatus } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@ -23,6 +26,7 @@ type DeleteDocumentDialogProps = {
status: DocumentStatus;
documentTitle: string;
teamId?: number;
canManageDocument: boolean;
};
export const DeleteDocumentDialog = ({
@ -32,6 +36,7 @@ export const DeleteDocumentDialog = ({
status,
documentTitle,
teamId,
canManageDocument,
}: DeleteDocumentDialogProps) => {
const router = useRouter();
@ -83,47 +88,82 @@ export const DeleteDocumentDialog = ({
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure you want to delete "{documentTitle}"?</DialogTitle>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
Please note that this action is irreversible. Once confirmed, your document will be
permanently deleted.
You are about to {canManageDocument ? 'delete' : 'hide'}{' '}
<strong>"{documentTitle}"</strong>
</DialogDescription>
</DialogHeader>
{status !== DocumentStatus.DRAFT && (
<div className="mt-4">
<Input
type="text"
value={inputValue}
onChange={onInputChange}
placeholder="Type 'delete' to confirm"
/>
</div>
{canManageDocument ? (
<Alert variant="warning" className="-mt-1">
{match(status)
.with(DocumentStatus.DRAFT, () => (
<AlertDescription>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this document will be permanently deleted.
</AlertDescription>
))
.with(DocumentStatus.PENDING, () => (
<AlertDescription>
<p>
Please note that this action is <strong>irreversible</strong>.
</p>
<p className="mt-1">Once confirmed, the following will occur:</p>
<ul className="mt-0.5 list-inside list-disc">
<li>Document will be permanently deleted</li>
<li>Document signing process will be cancelled</li>
<li>All inserted signatures will be voided</li>
<li>All recipients will be notified</li>
</ul>
</AlertDescription>
))
.with(DocumentStatus.COMPLETED, () => (
<AlertDescription>
<p>By deleting this document, the following will occur:</p>
<ul className="mt-0.5 list-inside list-disc">
<li>The document will be hidden from your account</li>
<li>Recipients will still retain their copy of the document</li>
</ul>
</AlertDescription>
))
.exhaustive()}
</Alert>
) : (
<Alert variant="warning" className="-mt-1">
<AlertDescription>
Please contact support if you would like to revert this action.
</AlertDescription>
</Alert>
)}
{status !== DocumentStatus.DRAFT && canManageDocument && (
<Input
type="text"
value={inputValue}
onChange={onInputChange}
placeholder="Type 'delete' to confirm"
/>
)}
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel
</Button>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="button"
loading={isLoading}
onClick={onDelete}
disabled={!isDeleteEnabled}
variant="destructive"
className="flex-1"
>
Delete
</Button>
</div>
<Button
type="button"
loading={isLoading}
onClick={onDelete}
disabled={!isDeleteEnabled && canManageDocument}
variant="destructive"
>
{canManageDocument ? 'Delete' : 'Hide'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -41,7 +41,9 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 20;
const senderIds = parseToIntegerArray(searchParams.senderIds ?? '');
const currentTeam = team ? { id: team.id, url: team.url } : undefined;
const currentTeam = team
? { id: team.id, url: team.url, teamEmail: team.teamEmail?.email }
: undefined;
const getStatOptions: GetStatsInput = {
user,

View File

@ -37,7 +37,10 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
}));
return (
<div className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4">
<div
className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4"
data-testid="empty-document-state"
>
<Icon className="h-12 w-12" strokeWidth={1.5} />
<div className="text-center">

View File

@ -6,6 +6,7 @@ import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-c
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
@ -37,7 +38,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
const [document, fields, recipient] = await Promise.all([
const [document, fields, recipient, completedFields] = await Promise.all([
getDocumentAndSenderByToken({
token,
userId: user?.id,
@ -45,9 +46,15 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
}).catch(() => null),
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
getCompletedFieldsForToken({ token }),
]);
if (!document || !document.documentData || !recipient) {
if (
!document ||
!document.documentData ||
!recipient ||
document.status === DocumentStatus.DRAFT
) {
return notFound();
}
@ -120,7 +127,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
signature={user?.email === recipient.email ? user.signature : undefined}
>
<DocumentAuthProvider document={document} recipient={recipient} user={user}>
<SigningPageView recipient={recipient} document={document} fields={fields} />
<SigningPageView
recipient={recipient}
document={document}
fields={fields}
completedFields={completedFields}
/>
</DocumentAuthProvider>
</SigningProvider>
);

View File

@ -4,12 +4,14 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { CompletedField } from '@documenso/lib/types/fields';
import type { Field, Recipient } from '@documenso/prisma/client';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
import { truncateTitle } from '~/helpers/truncate-title';
import { DateField } from './date-field';
@ -23,9 +25,15 @@ export type SigningPageViewProps = {
document: DocumentAndSender;
recipient: Recipient;
fields: Field[];
completedFields: CompletedField[];
};
export const SigningPageView = ({ document, recipient, fields }: SigningPageViewProps) => {
export const SigningPageView = ({
document,
recipient,
fields,
completedFields,
}: SigningPageViewProps) => {
const truncatedTitle = truncateTitle(document.title);
const { documentData, documentMeta } = document;
@ -70,6 +78,8 @@ export const SigningPageView = ({ document, recipient, fields }: SigningPageView
</div>
</div>
<DocumentReadOnlyFields fields={completedFields} />
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map((field) =>
match(field.type)

View File

@ -5,7 +5,7 @@ import { Button } from '@documenso/ui/primitives/button';
export default function SignatureDisclosure() {
return (
<div>
<article className="prose">
<article className="prose dark:prose-invert">
<h1>Electronic Signature Disclosure</h1>
<h2>Welcome</h2>

View File

@ -8,6 +8,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
@ -15,18 +16,21 @@ import { StackAvatar } from './stack-avatar';
export type AvatarWithRecipientProps = {
recipient: Recipient;
documentStatus: DocumentStatus;
};
export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) {
const [, copy] = useCopyToClipboard();
const { toast } = useToast();
const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null;
const onRecipientClick = () => {
if (!recipient.token) {
if (!signingToken) {
return;
}
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`).then(() => {
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => {
toast({
title: 'Copied to clipboard',
description: 'The signing link has been copied to your clipboard.',
@ -37,10 +41,10 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
return (
<div
className={cn('my-1 flex items-center gap-2', {
'cursor-pointer hover:underline': recipient.token,
'cursor-pointer hover:underline': signingToken,
})}
role={recipient.token ? 'button' : undefined}
title={recipient.token && 'Click to copy signing link for sending to recipient'}
role={signingToken ? 'button' : undefined}
title={signingToken ? 'Click to copy signing link for sending to recipient' : undefined}
onClick={onRecipientClick}
>
<StackAvatar
@ -49,16 +53,15 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<div
className="text-muted-foreground text-sm"
title="Click to copy signing link for sending to recipient"
>
<p>{recipient.email} </p>
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
</div>
<div
className="text-muted-foreground text-sm"
title={signingToken ? 'Click to copy signing link for sending to recipient' : undefined}
>
<p>{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
</div>
</div>
);

View File

@ -1,33 +1,28 @@
'use client';
import { useRef, useState } from 'react';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import type { DocumentStatus, Recipient } from '@documenso/prisma/client';
import { PopoverHover } from '@documenso/ui/primitives/popover';
import { AvatarWithRecipient } from './avatar-with-recipient';
import { StackAvatar } from './stack-avatar';
import { StackAvatars } from './stack-avatars';
export type StackAvatarsWithTooltipProps = {
documentStatus: DocumentStatus;
recipients: Recipient[];
position?: 'top' | 'bottom';
children?: React.ReactNode;
};
export const StackAvatarsWithTooltip = ({
documentStatus,
recipients,
position,
children,
}: StackAvatarsWithTooltipProps) => {
const [open, setOpen] = useState(false);
const isControlled = useRef(false);
const isMouseOverTimeout = useRef<NodeJS.Timeout | null>(null);
const waitingRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === 'waiting',
);
@ -44,105 +39,74 @@ export const StackAvatarsWithTooltip = ({
(recipient) => getRecipientType(recipient) === 'unsigned',
);
const onMouseEnter = () => {
if (isMouseOverTimeout.current) {
clearTimeout(isMouseOverTimeout.current);
}
if (isControlled.current) {
return;
}
isMouseOverTimeout.current = setTimeout(() => {
setOpen((o) => (!o ? true : o));
}, 200);
};
const onMouseLeave = () => {
if (isMouseOverTimeout.current) {
clearTimeout(isMouseOverTimeout.current);
}
if (isControlled.current) {
return;
}
setTimeout(() => {
setOpen((o) => (o ? false : o));
}, 200);
};
const onOpenChange = (newOpen: boolean) => {
isControlled.current = newOpen;
setOpen(newOpen);
};
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger
className="flex cursor-pointer"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{children || <StackAvatars recipients={recipients} />}
</PopoverTrigger>
<PopoverContent
side={position}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className="flex flex-col gap-y-5 py-2"
>
{completedRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">Completed</h1>
{completedRecipients.map((recipient: Recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div className="">
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
</div>
<PopoverHover
trigger={children || <StackAvatars recipients={recipients} />}
contentProps={{
className: 'flex flex-col gap-y-5 py-2',
side: position,
}}
>
{completedRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">Completed</h1>
{completedRecipients.map((recipient: Recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div className="">
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
{waitingRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">Waiting</h1>
{waitingRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
))}
</div>
)}
{waitingRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">Waiting</h1>
{waitingRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient
key={recipient.id}
recipient={recipient}
documentStatus={documentStatus}
/>
))}
</div>
)}
{openedRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">Opened</h1>
{openedRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
))}
</div>
)}
{openedRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">Opened</h1>
{openedRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient
key={recipient.id}
recipient={recipient}
documentStatus={documentStatus}
/>
))}
</div>
)}
{uncompletedRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">Uncompleted</h1>
{uncompletedRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
))}
</div>
)}
</PopoverContent>
</Popover>
{uncompletedRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">Uncompleted</h1>
{uncompletedRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient
key={recipient.id}
recipient={recipient}
documentStatus={documentStatus}
/>
))}
</div>
)}
</PopoverHover>
);
};

View File

@ -0,0 +1,112 @@
'use client';
import { useState } from 'react';
import { P, match } from 'ts-pattern';
import {
DEFAULT_DOCUMENT_DATE_FORMAT,
convertToLocalSystemFormat,
} from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { CompletedField } from '@documenso/lib/types/fields';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { DocumentMeta } from '@documenso/prisma/client';
import { FieldType } from '@documenso/prisma/client';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PopoverHover } from '@documenso/ui/primitives/popover';
export type DocumentReadOnlyFieldsProps = {
fields: CompletedField[];
documentMeta?: DocumentMeta;
};
export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnlyFieldsProps) => {
const [hiddenFieldIds, setHiddenFieldIds] = useState<Record<string, boolean>>({});
const handleHideField = (fieldId: string) => {
setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true }));
};
return (
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map(
(field) =>
!hiddenFieldIds[field.secondaryId] && (
<FieldRootContainer
field={field}
key={field.id}
cardClassName="border-gray-100/50 !shadow-none backdrop-blur-[1px] bg-background/90"
>
<div className="absolute -right-3 -top-3">
<PopoverHover
trigger={
<Avatar className="dark:border-border h-8 w-8 border-2 border-solid border-gray-200/50 transition-colors hover:border-gray-200">
<AvatarFallback className="bg-neutral-50 text-xs text-gray-400">
{extractInitials(field.Recipient.name || field.Recipient.email)}
</AvatarFallback>
</Avatar>
}
contentProps={{
className: 'flex w-fit flex-col py-2.5 text-sm',
}}
>
<p>
<span className="font-semibold">
{field.Recipient.name
? `${field.Recipient.name} (${field.Recipient.email})`
: field.Recipient.email}{' '}
</span>
inserted a {FRIENDLY_FIELD_TYPE[field.type].toLowerCase()}
</p>
<Button
variant="outline"
className="mt-2.5 h-6 text-xs focus:outline-none focus-visible:ring-0"
onClick={() => handleHideField(field.secondaryId)}
>
Hide field
</Button>
</PopoverHover>
</div>
<div className="text-muted-foreground break-all text-sm">
{match(field)
.with({ type: FieldType.SIGNATURE }, (field) =>
field.Signature?.signatureImageAsBase64 ? (
<img
src={field.Signature.signatureImageAsBase64}
alt="Signature"
className="h-full w-full object-contain dark:invert"
/>
) : (
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
{field.Signature?.typedSignature}
</p>
),
)
.with(
{ type: P.union(FieldType.NAME, FieldType.TEXT, FieldType.EMAIL) },
() => field.customText,
)
.with({ type: FieldType.DATE }, () =>
convertToLocalSystemFormat(
field.customText,
documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
),
)
.with({ type: FieldType.FREE_SIGNATURE }, () => null)
.exhaustive()}
</div>
</FieldRootContainer>
),
)}
</ElementVisible>
);
};

View File

@ -3,7 +3,7 @@ import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router';
export const config = {
maxDuration: 60,
maxDuration: 120,
api: {
bodyParser: {
sizeLimit: '50mb',