feat: add document version history UI

This commit is contained in:
David Nguyen
2024-02-15 18:20:10 +11:00
parent 524a2f4ea9
commit bd3c64658a
20 changed files with 1144 additions and 193 deletions

View File

@ -52,7 +52,7 @@ export const DocumentPageViewInformation = ({
}, [isMounted, document, locale, userId]);
return (
<section className="dark:bg-background text-foreground border-border bg-widget mt-6 flex flex-col rounded-xl border">
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">
<h1 className="px-4 py-3 font-medium">Information</h1>
<ul className="divide-y border-t">

View File

@ -0,0 +1,146 @@
'use client';
import { useMemo } from 'react';
import { CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { cn } from '@documenso/ui/lib/utils';
export type DocumentPageViewRecentActivityProps = {
documentId: number;
userId: number;
};
export const DocumentPageViewRecentActivity = ({
documentId,
userId,
}: DocumentPageViewRecentActivityProps) => {
const {
data,
isLoading,
isLoadingError,
refetch,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = trpc.document.findDocumentAuditLogs.useInfiniteQuery(
{
documentId,
filterForRecentActivity: true,
orderBy: {
column: 'createdAt',
direction: 'asc',
},
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
},
);
const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]);
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<div className="flex flex-row items-center justify-between border-b px-4 py-3">
<h1 className="text-foreground font-medium">Recent activity</h1>
{/* Can add dropdown menu here for additional options. */}
</div>
{isLoading && (
<div className="flex h-full items-center justify-center py-16">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
)}
{isLoadingError && (
<div className="flex h-full flex-col items-center justify-center py-16">
<p className="text-foreground/80 text-sm">Unable to load document history</p>
<button
onClick={async () => refetch()}
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
>
Click here to retry
</button>
</div>
)}
<AnimateGenericFadeInOut>
{data && (
<ul role="list" className="space-y-6 p-4">
{hasNextPage && (
<li className="relative flex gap-x-4">
<div className="absolute -bottom-6 left-0 top-0 flex w-6 justify-center">
<div className="w-px bg-gray-200" />
</div>
<div className="bg-widget relative flex h-6 w-6 flex-none items-center justify-center">
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
</div>
<button
onClick={async () => fetchNextPage()}
className="text-foreground/70 hover:text-muted-foreground text-xs"
>
{isFetchingNextPage ? 'Loading...' : 'Load older activity'}
</button>
</li>
)}
{documentAuditLogs.map((auditLog, auditLogIndex) => (
<li key={auditLog.id} className="relative flex gap-x-4">
<div
className={cn(
auditLogIndex === documentAuditLogs.length - 1 ? 'h-6' : '-bottom-6',
'absolute left-0 top-0 flex w-6 justify-center',
)}
>
<div className="bg-border w-px" />
</div>
<div className="bg-widget text-foreground/40 relative flex h-6 w-6 flex-none items-center justify-center">
{match(auditLog.type)
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED, () => (
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<CheckCheckIcon className="h-3 w-3" aria-hidden="true" />
</div>
))
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => (
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<CheckIcon className="h-3 w-3" aria-hidden="true" />
</div>
))
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, () => (
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<MailOpen className="h-3 w-3" aria-hidden="true" />
</div>
))
.otherwise(() => (
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
))}
</div>
<p className="text-muted-foreground dark:text-muted-foreground/70 flex-auto py-0.5 text-xs leading-5">
<span className="text-foreground font-medium">
{formatDocumentAuditLogAction(auditLog, userId).prefix}
</span>{' '}
{formatDocumentAuditLogAction(auditLog, userId).description}
</p>
<time className="text-muted-foreground dark:text-muted-foreground/70 flex-none py-0.5 text-xs leading-5">
{DateTime.fromJSDate(auditLog.createdAt).toRelative({ style: 'short' })}
</time>
</li>
))}
</ul>
)}
</AnimateGenericFadeInOut>
</section>
);
};

View File

@ -0,0 +1,115 @@
import Link from 'next/link';
import { CheckIcon, Clock, MailIcon, MailOpenIcon, PenIcon, PlusIcon } from 'lucide-react';
import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Document, Recipient } from '@documenso/prisma/client';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge';
export type DocumentPageViewRecipientsProps = {
document: Document & {
Recipient: Recipient[];
};
documentRootPath: string;
};
export const DocumentPageViewRecipients = ({
document,
documentRootPath,
}: DocumentPageViewRecipientsProps) => {
const recipients = document.Recipient;
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<div className="flex flex-row items-center justify-between px-4 py-3">
<h1 className="text-foreground font-medium">Recipients</h1>
{document.status !== DocumentStatus.COMPLETED && (
<Link
href={`${documentRootPath}/${document.id}/edit?step=signers`}
title="Modify recipients"
className="flex flex-row items-center justify-between"
>
{recipients.length === 0 ? (
<PlusIcon className="ml-2 h-4 w-4" />
) : (
<PenIcon className="ml-2 h-3 w-3" />
)}
</Link>
)}
</div>
<ul className="text-muted-foreground divide-y border-t">
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">No recipients</li>
)}
{recipients.map((recipient) => (
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
secondaryText={
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
}
/>
{document.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.SIGNED && (
<Badge variant="default">
{match(recipient.role)
.with(RecipientRole.APPROVER, () => (
<>
<CheckIcon className="mr-1 h-3 w-3" />
Approved
</>
))
.with(RecipientRole.CC, () =>
document.status === DocumentStatus.COMPLETED ? (
<>
<MailIcon className="mr-1 h-3 w-3" />
Sent
</>
) : (
<>
<CheckIcon className="mr-1 h-3 w-3" />
Ready
</>
),
)
.with(RecipientRole.SIGNER, () => (
<>
<SignatureIcon className="mr-1 h-3 w-3" />
Signed
</>
))
.with(RecipientRole.VIEWER, () => (
<>
<MailOpenIcon className="mr-1 h-3 w-3" />
Viewed
</>
))
.exhaustive()}
</Badge>
)}
{document.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
<Badge variant="secondary">
<Clock className="mr-1 h-3 w-3" />
Pending
</Badge>
)}
</li>
))}
</ul>
</section>
);
};

View File

@ -1,34 +1,23 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import {
CheckIcon,
ChevronLeft,
Clock,
MailIcon,
MailOpenIcon,
PenIcon,
PlusIcon,
Users2,
} from 'lucide-react';
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
import { match } from 'ts-pattern';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
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, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import type { Team } from '@documenso/prisma/client';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
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 {
DocumentStatus as DocumentStatusComponent,
FRIENDLY_STATUS_MAP,
@ -37,6 +26,8 @@ import {
import { DocumentPageViewButton } from './document-page-view-button';
import { DocumentPageViewDropdown } from './document-page-view-dropdown';
import { DocumentPageViewInformation } from './document-page-view-information';
import { DocumentPageViewRecentActivity } from './document-page-view-recent-activity';
import { DocumentPageViewRecipients } from './document-page-view-recipients';
export type DocumentPageViewProps = {
params: {
@ -104,27 +95,38 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
Documents
</Link>
<div>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title}
</h1>
<div className="flex flex-row justify-between">
<div>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatusComponent
inheritColor
status={document.status}
className="text-muted-foreground"
/>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatusComponent
inheritColor
status={document.status}
className="text-muted-foreground"
/>
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
<span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip>
</div>
)}
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
<span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip>
</div>
)}
</div>
</div>
<div className="self-end">
<DocumentHistorySheet documentId={document.id} userId={user.id}>
<Button variant="outline">
<Clock9 className="mr-1.5 h-4 w-4" />
Document history
</Button>
</DocumentHistorySheet>
</div>
</div>
@ -139,8 +141,8 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
</Card>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<div className="sticky top-20">
<section className="dark:bg-background border-border bg-widget sticky flex flex-col rounded-xl border pb-4 pt-6">
<div className="space-y-6">
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
<div className="flex flex-row items-center justify-between px-4">
<h3 className="text-foreground text-2xl font-semibold">
Document {FRIENDLY_STATUS_MAP[document.status].label.toLowerCase()}
@ -180,100 +182,13 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
<DocumentPageViewInformation document={documentWithRecipients} userId={user.id} />
{/* Recipients section. */}
<section className="dark:bg-background border-border bg-widget mt-6 flex flex-col rounded-xl border">
<div className="flex flex-row items-center justify-between px-4 py-3">
<h1 className="text-foreground font-medium">Recipients</h1>
<DocumentPageViewRecipients
document={documentWithRecipients}
documentRootPath={documentRootPath}
/>
{document.status !== DocumentStatus.COMPLETED && (
<Link
href={`${documentRootPath}/${document.id}/edit?step=signers`}
title="Modify recipients"
className="flex flex-row items-center justify-between"
>
{recipients.length === 0 ? (
<PlusIcon className="ml-2 h-4 w-4" />
) : (
<PenIcon className="ml-2 h-3 w-3" />
)}
</Link>
)}
</div>
<ul className="text-muted-foreground divide-y border-t">
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">
No recipients
</li>
)}
{recipients.map((recipient) => (
<li
key={recipient.id}
className="flex items-center justify-between px-4 py-2.5 text-sm"
>
<AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
primaryText={
<p className="text-muted-foreground text-sm">{recipient.email}</p>
}
secondaryText={
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
}
/>
{document.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.SIGNED && (
<Badge variant="default">
{match(recipient.role)
.with(RecipientRole.APPROVER, () => (
<>
<CheckIcon className="mr-1 h-3 w-3" />
Approved
</>
))
.with(RecipientRole.CC, () =>
document.status === DocumentStatus.COMPLETED ? (
<>
<MailIcon className="mr-1 h-3 w-3" />
Sent
</>
) : (
<>
<CheckIcon className="mr-1 h-3 w-3" />
Ready
</>
),
)
.with(RecipientRole.SIGNER, () => (
<>
<SignatureIcon className="mr-1 h-3 w-3" />
Signed
</>
))
.with(RecipientRole.VIEWER, () => (
<>
<MailOpenIcon className="mr-1 h-3 w-3" />
Viewed
</>
))
.exhaustive()}
</Badge>
)}
{document.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
<Badge variant="secondary">
<Clock className="mr-1 h-3 w-3" />
Pending
</Badge>
)}
</li>
))}
</ul>
</section>
{/* Recent activity section. */}
<DocumentPageViewRecentActivity documentId={document.id} userId={user.id} />
</div>
</div>
</div>