This commit is contained in:
David Nguyen
2025-03-03 21:35:12 +11:00
parent 25bb6ffe77
commit 172a5be737
32 changed files with 742 additions and 843 deletions

View File

@ -0,0 +1,148 @@
import { useEffect, useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, Field, Recipient } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import { Clock, EyeOffIcon } from 'lucide-react';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge';
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';
import { FieldContent } from '../../primitives/document-flow/field-content';
export type DocumentReadOnlyFieldsProps = {
fields: DocumentField[];
documentMeta?: DocumentMeta;
showFieldStatus?: boolean;
/**
* Whether to show the recipient tooltip.
*
* @default false
*/
showRecipientTooltip?: boolean;
};
export const mapFieldsWithRecipients = (
fields: Field[],
recipients: Recipient[],
): DocumentField[] => {
return fields.map((field) => {
const recipient = recipients.find((recipient) => recipient.id === field.recipientId) || {
name: 'Unknown',
email: 'Unknown',
signingStatus: SigningStatus.NOT_SIGNED,
};
return { ...field, recipient, signature: null };
});
};
export const DocumentReadOnlyFields = ({
documentMeta,
fields,
showFieldStatus = true,
showRecipientTooltip = false,
}: DocumentReadOnlyFieldsProps) => {
const { _ } = useLingui();
const [hiddenFieldIds, setHiddenFieldIds] = useState<Record<string, boolean>>({});
const handleHideField = (fieldId: string) => {
setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true }));
};
const isMounted = useIsMounted();
useEffect(() => {
console.log(isMounted);
}, [isMounted]);
if (!isMounted) {
return null;
}
return (
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map(
(field) =>
!hiddenFieldIds[field.secondaryId] && (
<FieldRootContainer field={field} key={field.id}>
{showRecipientTooltip && (
<div className="absolute -right-3 -top-3">
<PopoverHover
trigger={
<Avatar className="dark:border-foreground h-6 w-6 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: 'relative flex w-fit flex-col p-4 text-sm',
}}
>
{showFieldStatus && (
<Badge
className="mx-auto mb-1 py-0.5"
variant={
field.recipient.signingStatus === SigningStatus.SIGNED
? 'default'
: 'secondary'
}
>
{field.recipient.signingStatus === SigningStatus.SIGNED ? (
<>
<SignatureIcon className="mr-1 h-3 w-3" />
<Trans>Signed</Trans>
</>
) : (
<>
<Clock className="mr-1 h-3 w-3" />
<Trans>Pending</Trans>
</>
)}
</Badge>
)}
<p className="text-center font-semibold">
<span>
{parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type])} field
</span>
</p>
<p className="text-muted-foreground mt-1 text-center text-xs">
{field.recipient.name
? `${field.recipient.name} (${field.recipient.email})`
: field.recipient.email}{' '}
</p>
<button
className="absolute right-0 top-0 my-1 p-2 focus:outline-none focus-visible:ring-0"
onClick={() => handleHideField(field.secondaryId)}
title="Hide field"
>
<EyeOffIcon className="h-3 w-3" />
</button>
</PopoverHover>
</div>
)}
<FieldContent field={field} documentMeta={documentMeta} />
</FieldRootContainer>
),
)}
</ElementVisible>
);
};

View File

@ -1,14 +1,12 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useState } from 'react';
import type { Field } from '@prisma/client';
import { createPortal } from 'react-dom';
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { useSignerColors } from '../../lib/signer-colors';
import { cn } from '../../lib/utils';
import { Card, CardContent } from '../../primitives/card';
export type FieldRootContainerProps = {
field: Field;
@ -19,53 +17,6 @@ export type FieldContainerPortalProps = {
field: Field;
className?: string;
children: React.ReactNode;
cardClassName?: string;
};
const getCardClassNames = (
field: Field,
parsedField: TFieldMetaSchema | null,
isValidating: boolean,
checkBoxOrRadio: boolean,
cardClassName?: string,
) => {
const baseClasses =
'field--FieldRootContainer field-card-container relative z-20 h-full w-full transition-all';
const insertedClasses =
'bg-primary/20 border-primary ring-primary/20 ring-offset-primary/20 ring-2 ring-offset-2 dark:shadow-none';
const nonRequiredClasses =
'border-yellow-300 shadow-none ring-2 ring-yellow-100 ring-offset-2 ring-offset-yellow-100 dark:border-2';
const validatingClasses = 'border-orange-300 ring-1 ring-orange-300';
const requiredClasses =
'border-red-500 shadow-none ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 hover:text-red-500';
const requiredCheckboxRadioClasses = 'border-dashed border-red-500';
if (checkBoxOrRadio) {
return cn(
{
[insertedClasses]: field.inserted,
'ring-offset-yellow-200 border-dashed border-yellow-300 ring-2 ring-yellow-200 ring-offset-2 dark:shadow-none':
!field.inserted && !parsedField?.required,
'shadow-none': !field.inserted,
[validatingClasses]: !field.inserted && isValidating,
[requiredCheckboxRadioClasses]: !field.inserted && parsedField?.required,
},
cardClassName,
);
}
return cn(
baseClasses,
{
[insertedClasses]: field.inserted,
[nonRequiredClasses]: !field.inserted && !parsedField?.required,
'shadow-none': !field.inserted && checkBoxOrRadio,
[validatingClasses]: !field.inserted && isValidating,
[requiredClasses]: !field.inserted && parsedField?.required && !checkBoxOrRadio,
},
cardClassName,
);
};
export function FieldContainerPortal({
@ -76,13 +27,10 @@ export function FieldContainerPortal({
const coords = useFieldPageCoords(field);
const isCheckboxOrRadioField = field.type === 'CHECKBOX' || field.type === 'RADIO';
const isFieldSigned = field.inserted;
const style = {
top: `${coords.y}px`,
left: `${coords.x}px`,
// height: `${coords.height}px`,
// width: `${coords.width}px`,
...(!isCheckboxOrRadioField && {
height: `${coords.height}px`,
width: `${coords.width}px`,
@ -97,10 +45,12 @@ export function FieldContainerPortal({
);
}
export function FieldRootContainer({ field, children, cardClassName }: FieldContainerPortalProps) {
export function FieldRootContainer({ field, children }: FieldContainerPortalProps) {
const [isValidating, setIsValidating] = useState(false);
const ref = React.useRef<HTMLDivElement>(null);
const signerStyles = useSignerColors(field.recipientId);
useEffect(() => {
if (!ref.current) {
return;
@ -121,33 +71,36 @@ export function FieldRootContainer({ field, children, cardClassName }: FieldCont
};
}, []);
const parsedField = useMemo(
() => (field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : null),
[field.fieldMeta],
);
const isCheckboxOrRadio = useMemo(
() => parsedField?.type === 'checkbox' || parsedField?.type === 'radio',
[parsedField],
);
// // todo: remove
// const parsedField = useMemo(
// () => (field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : null),
// [field.fieldMeta],
// );
const cardClassNames = useMemo(
() => getCardClassNames(field, parsedField, isValidating, isCheckboxOrRadio, cardClassName),
[field, parsedField, isValidating, isCheckboxOrRadio, cardClassName],
);
// // todo: remove
// const isCheckboxOrRadio = useMemo(
// () => parsedField?.type === 'checkbox' || parsedField?.type === 'radio',
// [parsedField],
// );
return (
<FieldContainerPortal field={field}>
<Card
<div
id={`field-${field.id}`}
ref={ref}
data-field-type={field.type}
data-inserted={field.inserted ? 'true' : 'false'}
className={cardClassNames}
className={cn(
'field--FieldRootContainer field-card-container relative z-20 h-full w-full rounded-sm ring-2 transition-all',
'ring-signer-green bg-white/90',
'px-2', // This is specific to try sync with field insertion. See insert-field-in-pdf before changing this.
{
'flex items-center justify-center': !field.inserted,
},
)}
>
<CardContent className="text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2">
{children}
</CardContent>
</Card>
{children}
</div>
</FieldContainerPortal>
);
}