diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx index 953e7a6a1..ba9b806e5 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx @@ -8,6 +8,7 @@ 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'; @@ -20,6 +21,7 @@ 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, @@ -84,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, @@ -155,6 +162,13 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) + {document.status === DocumentStatus.PENDING && ( + + )} +
diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index bd46898af..b066193e6 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -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,6 +46,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp }).catch(() => null), getFieldsForToken({ token }), getRecipientByToken({ token }).catch(() => null), + getCompletedFieldsForToken({ token }), ]); if ( @@ -125,7 +127,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp signature={user?.email === recipient.email ? user.signature : undefined} > - + ); diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx index c04679956..4691d0d4c 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx @@ -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
+ + {fields.map((field) => match(field.type) diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx index 7a269d036..6bc8cf9af 100644 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx @@ -1,12 +1,10 @@ '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 { DocumentStatus, Recipient } from '@documenso/prisma/client'; -import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; +import { PopoverHover } from '@documenso/ui/primitives/popover'; import { AvatarWithRecipient } from './avatar-with-recipient'; import { StackAvatar } from './stack-avatar'; @@ -25,11 +23,6 @@ export const StackAvatarsWithTooltip = ({ position, children, }: StackAvatarsWithTooltipProps) => { - const [open, setOpen] = useState(false); - - const isControlled = useRef(false); - const isMouseOverTimeout = useRef(null); - const waitingRecipients = recipients.filter( (recipient) => getRecipientType(recipient) === 'waiting', ); @@ -46,117 +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 ( - - - {children || } - - - - {completedRecipients.length > 0 && ( -
-

Completed

- {completedRecipients.map((recipient: Recipient) => ( -
- -
-

{recipient.email}

-

- {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} -

-
+ } + contentProps={{ + className: 'flex flex-col gap-y-5 py-2', + side: position, + }} + > + {completedRecipients.length > 0 && ( +
+

Completed

+ {completedRecipients.map((recipient: Recipient) => ( +
+ +
+

{recipient.email}

+

+ {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} +

- ))} -
- )} +
+ ))} +
+ )} - {waitingRecipients.length > 0 && ( -
-

Waiting

- {waitingRecipients.map((recipient: Recipient) => ( - - ))} -
- )} + {waitingRecipients.length > 0 && ( +
+

Waiting

+ {waitingRecipients.map((recipient: Recipient) => ( + + ))} +
+ )} - {openedRecipients.length > 0 && ( -
-

Opened

- {openedRecipients.map((recipient: Recipient) => ( - - ))} -
- )} + {openedRecipients.length > 0 && ( +
+

Opened

+ {openedRecipients.map((recipient: Recipient) => ( + + ))} +
+ )} - {uncompletedRecipients.length > 0 && ( -
-

Uncompleted

- {uncompletedRecipients.map((recipient: Recipient) => ( - - ))} -
- )} - - + {uncompletedRecipients.length > 0 && ( +
+

Uncompleted

+ {uncompletedRecipients.map((recipient: Recipient) => ( + + ))} +
+ )} + ); }; diff --git a/apps/web/src/components/document/document-read-only-fields.tsx b/apps/web/src/components/document/document-read-only-fields.tsx new file mode 100644 index 000000000..95a907d8f --- /dev/null +++ b/apps/web/src/components/document/document-read-only-fields.tsx @@ -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>({}); + + const handleHideField = (fieldId: string) => { + setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true })); + }; + + return ( + + {fields.map( + (field) => + !hiddenFieldIds[field.secondaryId] && ( + +
+ + + {extractInitials(field.Recipient.name || field.Recipient.email)} + + + } + contentProps={{ + className: 'flex w-fit flex-col py-2.5 text-sm', + }} + > +

+ + {field.Recipient.name + ? `${field.Recipient.name} (${field.Recipient.email})` + : field.Recipient.email}{' '} + + inserted a {FRIENDLY_FIELD_TYPE[field.type].toLowerCase()} +

+ + +
+
+ +
+ {match(field) + .with({ type: FieldType.SIGNATURE }, (field) => + field.Signature?.signatureImageAsBase64 ? ( + Signature + ) : ( +

+ {field.Signature?.typedSignature} +

+ ), + ) + .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()} +
+
+ ), + )} +
+ ); +}; diff --git a/packages/lib/server-only/field/get-completed-fields-for-document.ts b/packages/lib/server-only/field/get-completed-fields-for-document.ts new file mode 100644 index 000000000..304be95ba --- /dev/null +++ b/packages/lib/server-only/field/get-completed-fields-for-document.ts @@ -0,0 +1,29 @@ +import { prisma } from '@documenso/prisma'; +import { SigningStatus } from '@documenso/prisma/client'; + +export type GetCompletedFieldsForDocumentOptions = { + documentId: number; +}; + +export const getCompletedFieldsForDocument = async ({ + documentId, +}: GetCompletedFieldsForDocumentOptions) => { + return await prisma.field.findMany({ + where: { + documentId, + Recipient: { + signingStatus: SigningStatus.SIGNED, + }, + inserted: true, + }, + include: { + Signature: true, + Recipient: { + select: { + name: true, + email: true, + }, + }, + }, + }); +}; diff --git a/packages/lib/server-only/field/get-completed-fields-for-token.ts b/packages/lib/server-only/field/get-completed-fields-for-token.ts new file mode 100644 index 000000000..d84fa1343 --- /dev/null +++ b/packages/lib/server-only/field/get-completed-fields-for-token.ts @@ -0,0 +1,33 @@ +import { prisma } from '@documenso/prisma'; +import { SigningStatus } from '@documenso/prisma/client'; + +export type GetCompletedFieldsForTokenOptions = { + token: string; +}; + +export const getCompletedFieldsForToken = async ({ token }: GetCompletedFieldsForTokenOptions) => { + return await prisma.field.findMany({ + where: { + Document: { + Recipient: { + some: { + token, + }, + }, + }, + Recipient: { + signingStatus: SigningStatus.SIGNED, + }, + inserted: true, + }, + include: { + Signature: true, + Recipient: { + select: { + name: true, + email: true, + }, + }, + }, + }); +}; diff --git a/packages/lib/server-only/template/create-document-from-template.ts b/packages/lib/server-only/template/create-document-from-template.ts index 8ae5fecaf..79a3f6f25 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -89,6 +89,10 @@ export const createDocumentFromTemplate = async ({ const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email); + if (!documentRecipient) { + throw new Error('Recipient not found.'); + } + return { type: field.type, page: field.page, @@ -99,7 +103,7 @@ export const createDocumentFromTemplate = async ({ customText: field.customText, inserted: field.inserted, documentId: document.id, - recipientId: documentRecipient?.id || null, + recipientId: documentRecipient.id, }; }), }); diff --git a/packages/lib/server-only/template/duplicate-template.ts b/packages/lib/server-only/template/duplicate-template.ts index 97b3f0a0b..963d78bde 100644 --- a/packages/lib/server-only/template/duplicate-template.ts +++ b/packages/lib/server-only/template/duplicate-template.ts @@ -81,6 +81,10 @@ export const duplicateTemplate = async ({ (doc) => doc.email === recipient?.email, ); + if (!duplicatedTemplateRecipient) { + throw new Error('Recipient not found.'); + } + return { type: field.type, page: field.page, @@ -91,7 +95,7 @@ export const duplicateTemplate = async ({ customText: field.customText, inserted: field.inserted, templateId: duplicatedTemplate.id, - recipientId: duplicatedTemplateRecipient?.id || null, + recipientId: duplicatedTemplateRecipient.id, }; }), }); diff --git a/packages/lib/types/fields.ts b/packages/lib/types/fields.ts new file mode 100644 index 000000000..1b999310d --- /dev/null +++ b/packages/lib/types/fields.ts @@ -0,0 +1,3 @@ +import type { getCompletedFieldsForToken } from '../server-only/field/get-completed-fields-for-token'; + +export type CompletedField = Awaited>[number]; diff --git a/packages/prisma/migrations/20240418140819_remove_impossible_field_optional_states/migration.sql b/packages/prisma/migrations/20240418140819_remove_impossible_field_optional_states/migration.sql new file mode 100644 index 000000000..ee027d90e --- /dev/null +++ b/packages/prisma/migrations/20240418140819_remove_impossible_field_optional_states/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - Made the column `recipientId` on table `Field` required. This step will fail if there are existing NULL values in that column. + +*/ +-- Drop all Fields where the recipientId is null +DELETE FROM "Field" WHERE "recipientId" IS NULL; + +-- AlterTable +ALTER TABLE "Field" ALTER COLUMN "recipientId" SET NOT NULL; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 97b6e9eeb..8acfbedfa 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -387,7 +387,7 @@ model Field { secondaryId String @unique @default(cuid()) documentId Int? templateId Int? - recipientId Int? + recipientId Int type FieldType page Int positionX Decimal @default(0) @@ -398,7 +398,7 @@ model Field { inserted Boolean Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) - Recipient Recipient? @relation(fields: [recipientId], references: [id], onDelete: Cascade) + Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade) Signature Signature? @@index([documentId]) diff --git a/packages/ui/components/field/field.tsx b/packages/ui/components/field/field.tsx index e40b2e3d9..ce62443de 100644 --- a/packages/ui/components/field/field.tsx +++ b/packages/ui/components/field/field.tsx @@ -19,6 +19,7 @@ export type FieldContainerPortalProps = { field: Field; className?: string; children: React.ReactNode; + cardClassName?: string; }; export function FieldContainerPortal({ @@ -44,7 +45,7 @@ export function FieldContainerPortal({ ); } -export function FieldRootContainer({ field, children }: FieldContainerPortalProps) { +export function FieldRootContainer({ field, children, cardClassName }: FieldContainerPortalProps) { const [isValidating, setIsValidating] = useState(false); const ref = React.useRef(null); @@ -78,6 +79,7 @@ export function FieldRootContainer({ field, children }: FieldContainerPortalProp { 'border-orange-300 ring-1 ring-orange-300': !field.inserted && isValidating, }, + cardClassName, )} ref={ref} data-inserted={field.inserted ? 'true' : 'false'} diff --git a/packages/ui/primitives/popover.tsx b/packages/ui/primitives/popover.tsx index e84f6cc6d..62462322b 100644 --- a/packages/ui/primitives/popover.tsx +++ b/packages/ui/primitives/popover.tsx @@ -30,4 +30,66 @@ const PopoverContent = React.forwardRef< PopoverContent.displayName = PopoverPrimitive.Content.displayName; -export { Popover, PopoverTrigger, PopoverContent }; +type PopoverHoverProps = { + trigger: React.ReactNode; + children: React.ReactNode; + contentProps?: React.ComponentPropsWithoutRef; +}; + +const PopoverHover = ({ trigger, children, contentProps }: PopoverHoverProps) => { + const [open, setOpen] = React.useState(false); + + const isControlled = React.useRef(false); + const isMouseOver = React.useRef(false); + + const onMouseEnter = () => { + isMouseOver.current = true; + + if (isControlled.current) { + return; + } + + setOpen(true); + }; + + const onMouseLeave = () => { + isMouseOver.current = false; + + if (isControlled.current) { + return; + } + + setTimeout(() => { + setOpen(isMouseOver.current); + }, 200); + }; + + const onOpenChange = (newOpen: boolean) => { + isControlled.current = newOpen; + + setOpen(newOpen); + }; + + return ( + + + {trigger} + + + + {children} + + + ); +}; + +export { Popover, PopoverTrigger, PopoverContent, PopoverHover };