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 ? (
+

+ ) : (
+
+ {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 };