mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 16:23:06 +10:00
feat: add visible completed fields (#1109)
## Description Added the ability for recipients to see fields from other recipients who have completed the document when they are signing the document Added the ability for the document owner to see fields from recipients who have completed the field on the document page view (only visible when the document is pending) ## 🚨🚨 Migrations🚨🚨 - Drop all `Fields` that do not have a `Recipient` set (not sure how it was possible in the first place) - Remove optional `Recipient` field on `Field` which doesn't make sense <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary by CodeRabbit - **New Features** - Enhanced document viewing by adding read-only fields based on document status. - Improved signing page by fetching and displaying completed fields for tokens. - Updated avatar component to show recipient status with tooltips for better user interaction. - **Bug Fixes** - Made `recipientId` a required field in the database to ensure data consistency. - **Refactor** - Optimized popover functionality in UI components for better performance and user experience. - **Documentation** - Added detailed component and function descriptions for new features in the system. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@ -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 { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
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 { 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 { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
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 { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||||
import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
|
import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
|
||||||
|
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
|
||||||
import {
|
import {
|
||||||
DocumentStatus as DocumentStatusComponent,
|
DocumentStatus as DocumentStatusComponent,
|
||||||
FRIENDLY_STATUS_MAP,
|
FRIENDLY_STATUS_MAP,
|
||||||
@ -84,11 +86,16 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
documentMeta.password = securePassword;
|
documentMeta.password = securePassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
const recipients = await getRecipientsForDocument({
|
const [recipients, completedFields] = await Promise.all([
|
||||||
|
getRecipientsForDocument({
|
||||||
documentId,
|
documentId,
|
||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
});
|
}),
|
||||||
|
getCompletedFieldsForDocument({
|
||||||
|
documentId,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const documentWithRecipients = {
|
const documentWithRecipients = {
|
||||||
...document,
|
...document,
|
||||||
@ -155,6 +162,13 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
||||||
|
|||||||
@ -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 { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
||||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
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 { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
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 requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
||||||
|
|
||||||
const [document, fields, recipient] = await Promise.all([
|
const [document, fields, recipient, completedFields] = await Promise.all([
|
||||||
getDocumentAndSenderByToken({
|
getDocumentAndSenderByToken({
|
||||||
token,
|
token,
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
@ -45,6 +46,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
}).catch(() => null),
|
}).catch(() => null),
|
||||||
getFieldsForToken({ token }),
|
getFieldsForToken({ token }),
|
||||||
getRecipientByToken({ token }).catch(() => null),
|
getRecipientByToken({ token }).catch(() => null),
|
||||||
|
getCompletedFieldsForToken({ token }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -125,7 +127,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
signature={user?.email === recipient.email ? user.signature : undefined}
|
signature={user?.email === recipient.email ? user.signature : undefined}
|
||||||
>
|
>
|
||||||
<DocumentAuthProvider document={document} recipient={recipient} user={user}>
|
<DocumentAuthProvider document={document} recipient={recipient} user={user}>
|
||||||
<SigningPageView recipient={recipient} document={document} fields={fields} />
|
<SigningPageView
|
||||||
|
recipient={recipient}
|
||||||
|
document={document}
|
||||||
|
fields={fields}
|
||||||
|
completedFields={completedFields}
|
||||||
|
/>
|
||||||
</DocumentAuthProvider>
|
</DocumentAuthProvider>
|
||||||
</SigningProvider>
|
</SigningProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
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 { 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 type { Field, Recipient } from '@documenso/prisma/client';
|
||||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
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 { truncateTitle } from '~/helpers/truncate-title';
|
||||||
|
|
||||||
import { DateField } from './date-field';
|
import { DateField } from './date-field';
|
||||||
@ -23,9 +25,15 @@ export type SigningPageViewProps = {
|
|||||||
document: DocumentAndSender;
|
document: DocumentAndSender;
|
||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
fields: Field[];
|
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 truncatedTitle = truncateTitle(document.title);
|
||||||
|
|
||||||
const { documentData, documentMeta } = document;
|
const { documentData, documentMeta } = document;
|
||||||
@ -70,6 +78,8 @@ export const SigningPageView = ({ document, recipient, fields }: SigningPageView
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DocumentReadOnlyFields fields={completedFields} />
|
||||||
|
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
{fields.map((field) =>
|
{fields.map((field) =>
|
||||||
match(field.type)
|
match(field.type)
|
||||||
|
|||||||
@ -1,12 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRef, useState } from 'react';
|
|
||||||
|
|
||||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import type { DocumentStatus, Recipient } from '@documenso/prisma/client';
|
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 { AvatarWithRecipient } from './avatar-with-recipient';
|
||||||
import { StackAvatar } from './stack-avatar';
|
import { StackAvatar } from './stack-avatar';
|
||||||
@ -25,11 +23,6 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
position,
|
position,
|
||||||
children,
|
children,
|
||||||
}: StackAvatarsWithTooltipProps) => {
|
}: StackAvatarsWithTooltipProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const isControlled = useRef(false);
|
|
||||||
const isMouseOverTimeout = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
const waitingRecipients = recipients.filter(
|
const waitingRecipients = recipients.filter(
|
||||||
(recipient) => getRecipientType(recipient) === 'waiting',
|
(recipient) => getRecipientType(recipient) === 'waiting',
|
||||||
);
|
);
|
||||||
@ -46,55 +39,13 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
(recipient) => getRecipientType(recipient) === 'unsigned',
|
(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 (
|
return (
|
||||||
<Popover open={open} onOpenChange={onOpenChange}>
|
<PopoverHover
|
||||||
<PopoverTrigger
|
trigger={children || <StackAvatars recipients={recipients} />}
|
||||||
className="flex cursor-pointer"
|
contentProps={{
|
||||||
onMouseEnter={onMouseEnter}
|
className: 'flex flex-col gap-y-5 py-2',
|
||||||
onMouseLeave={onMouseLeave}
|
side: position,
|
||||||
>
|
}}
|
||||||
{children || <StackAvatars recipients={recipients} />}
|
|
||||||
</PopoverTrigger>
|
|
||||||
|
|
||||||
<PopoverContent
|
|
||||||
side={position}
|
|
||||||
onMouseEnter={onMouseEnter}
|
|
||||||
onMouseLeave={onMouseLeave}
|
|
||||||
className="flex flex-col gap-y-5 py-2"
|
|
||||||
>
|
>
|
||||||
{completedRecipients.length > 0 && (
|
{completedRecipients.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
@ -156,7 +107,6 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</PopoverContent>
|
</PopoverHover>
|
||||||
</Popover>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
112
apps/web/src/components/document/document-read-only-fields.tsx
Normal file
112
apps/web/src/components/document/document-read-only-fields.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -89,6 +89,10 @@ export const createDocumentFromTemplate = async ({
|
|||||||
|
|
||||||
const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email);
|
const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email);
|
||||||
|
|
||||||
|
if (!documentRecipient) {
|
||||||
|
throw new Error('Recipient not found.');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: field.type,
|
type: field.type,
|
||||||
page: field.page,
|
page: field.page,
|
||||||
@ -99,7 +103,7 @@ export const createDocumentFromTemplate = async ({
|
|||||||
customText: field.customText,
|
customText: field.customText,
|
||||||
inserted: field.inserted,
|
inserted: field.inserted,
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
recipientId: documentRecipient?.id || null,
|
recipientId: documentRecipient.id,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -81,6 +81,10 @@ export const duplicateTemplate = async ({
|
|||||||
(doc) => doc.email === recipient?.email,
|
(doc) => doc.email === recipient?.email,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!duplicatedTemplateRecipient) {
|
||||||
|
throw new Error('Recipient not found.');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: field.type,
|
type: field.type,
|
||||||
page: field.page,
|
page: field.page,
|
||||||
@ -91,7 +95,7 @@ export const duplicateTemplate = async ({
|
|||||||
customText: field.customText,
|
customText: field.customText,
|
||||||
inserted: field.inserted,
|
inserted: field.inserted,
|
||||||
templateId: duplicatedTemplate.id,
|
templateId: duplicatedTemplate.id,
|
||||||
recipientId: duplicatedTemplateRecipient?.id || null,
|
recipientId: duplicatedTemplateRecipient.id,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
3
packages/lib/types/fields.ts
Normal file
3
packages/lib/types/fields.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import type { getCompletedFieldsForToken } from '../server-only/field/get-completed-fields-for-token';
|
||||||
|
|
||||||
|
export type CompletedField = Awaited<ReturnType<typeof getCompletedFieldsForToken>>[number];
|
||||||
@ -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;
|
||||||
@ -387,7 +387,7 @@ model Field {
|
|||||||
secondaryId String @unique @default(cuid())
|
secondaryId String @unique @default(cuid())
|
||||||
documentId Int?
|
documentId Int?
|
||||||
templateId Int?
|
templateId Int?
|
||||||
recipientId Int?
|
recipientId Int
|
||||||
type FieldType
|
type FieldType
|
||||||
page Int
|
page Int
|
||||||
positionX Decimal @default(0)
|
positionX Decimal @default(0)
|
||||||
@ -398,7 +398,7 @@ model Field {
|
|||||||
inserted Boolean
|
inserted Boolean
|
||||||
Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||||
Template Template? @relation(fields: [templateId], 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?
|
Signature Signature?
|
||||||
|
|
||||||
@@index([documentId])
|
@@index([documentId])
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export type FieldContainerPortalProps = {
|
|||||||
field: Field;
|
field: Field;
|
||||||
className?: string;
|
className?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
cardClassName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function FieldContainerPortal({
|
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 [isValidating, setIsValidating] = useState(false);
|
||||||
|
|
||||||
const ref = React.useRef<HTMLDivElement>(null);
|
const ref = React.useRef<HTMLDivElement>(null);
|
||||||
@ -78,6 +79,7 @@ export function FieldRootContainer({ field, children }: FieldContainerPortalProp
|
|||||||
{
|
{
|
||||||
'border-orange-300 ring-1 ring-orange-300': !field.inserted && isValidating,
|
'border-orange-300 ring-1 ring-orange-300': !field.inserted && isValidating,
|
||||||
},
|
},
|
||||||
|
cardClassName,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-inserted={field.inserted ? 'true' : 'false'}
|
data-inserted={field.inserted ? 'true' : 'false'}
|
||||||
|
|||||||
@ -30,4 +30,66 @@ const PopoverContent = React.forwardRef<
|
|||||||
|
|
||||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||||
|
|
||||||
export { Popover, PopoverTrigger, PopoverContent };
|
type PopoverHoverProps = {
|
||||||
|
trigger: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
contentProps?: React.ComponentPropsWithoutRef<typeof PopoverContent>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PopoverHover = ({ trigger, children, contentProps }: PopoverHoverProps) => {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const isControlled = React.useRef(false);
|
||||||
|
const isMouseOver = React.useRef<boolean>(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 (
|
||||||
|
<Popover open={open} onOpenChange={onOpenChange}>
|
||||||
|
<PopoverTrigger
|
||||||
|
className="flex cursor-pointer outline-none"
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
>
|
||||||
|
{trigger}
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
side="top"
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
{...contentProps}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverHover };
|
||||||
|
|||||||
Reference in New Issue
Block a user