fix: rework fields (#1697)

Rework:
- Field styling to improve visibility
- Field insertions, better alignment, centering and overflows

## Changes

General changes:

- Set default text alignment to left if no meta found
- Reduce borders and rings around fields to allow smaller fields
- Removed lots of redundant duplicated code surrounding field rendering
- Make fields more consistent across viewing, editing and signing
- Add more transparency to fields to allow users to see under fields
- No more optional/required/etc colors when signing, required fields
will be highlighted as orange when form is "validating"

Highlighted internal changes:

- Utilize native PDF fields to insert text, instead of drawing text 
- Change font auto scaling to only apply to when the height overflows
AND no custom font is set

⚠️ Multiline changes:

Multi line is enabled for a field under these conditions

1. Field content exceeds field width
2. Field includes a new line
3. Field type is TEXT

## [BEFORE] Field UI Signing 


![image](https://github.com/user-attachments/assets/ea002743-faeb-477c-a239-3ed240b54f55)

## [AFTER] Field UI Signing 


![image](https://github.com/user-attachments/assets/0f8eb252-4cf3-4d96-8d4f-cd085881b78c)

## [BEFORE] Signing a checkbox


![image](https://github.com/user-attachments/assets/4567d745-e1da-4202-a758-5d3c178c930e)

![image](https://github.com/user-attachments/assets/c25068e7-fe80-40f5-b63a-e8a0d4b38b6c)

## [AFTER] Signing a checkbox


![image](https://github.com/user-attachments/assets/effa5e3d-385a-4c35-bc8a-405386dd27d6)

![image](https://github.com/user-attachments/assets/64be34a9-0b32-424d-9264-15361c03eca5)

## [BEFORE] What a 2nd recipient sees once someone else signed a
document


![image](https://github.com/user-attachments/assets/21c21ae2-fc62-4ccc-880a-46aab012aa70)

## [AFTER] What a 2nd recipient sees once someone else signed a document


![image](https://github.com/user-attachments/assets/ae51677b-f1d5-4008-a7fd-756533166542)

## **[BEFORE]** Inserting fields


![image](https://github.com/user-attachments/assets/1a8bb8da-9a15-4deb-bc28-eb349414465c)

## **[AFTER]** Inserting fields


![image](https://github.com/user-attachments/assets/c52c5238-9836-45aa-b8a4-bc24a3462f40)

## Overflows, multilines and field alignments testing

Debugging borders:
- Red border = The original field placement without any modifications
- Blue border = The available space to overflow

### Single line overflows and field alignments 

This is left aligned fields, overflow will always go to the end of the
page and will not wrap


![image](https://github.com/user-attachments/assets/47003658-783e-4f9c-adbf-c4686804d98f)

This is center aligned fields, the max width is the closest edge to the
page * 2


![image](https://github.com/user-attachments/assets/05a38093-75d6-4600-bae2-21ecff63e115)

This is right aligned text, the width will extend all the way to the
left hand side of the page


![image](https://github.com/user-attachments/assets/6a9d84a8-4166-4626-9fb3-1577fac2571e)

### Multiline line overflows and field alignments 

These are text fields that can be overflowed


![image](https://github.com/user-attachments/assets/f7b5456e-2c49-48b2-8d4c-ab1dc3401644)

Another example of left aligned text overflows with more text


![image](https://github.com/user-attachments/assets/3db6b35e-4c8d-4ffe-8036-0da760d9c167)
This commit is contained in:
David Nguyen
2025-04-23 21:40:42 +10:00
committed by GitHub
parent b94645a451
commit 193325717d
62 changed files with 1819 additions and 1070 deletions

View File

@ -114,7 +114,7 @@ export const TemplateBulkSendDialog = ({
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
{trigger ?? ( {trigger ?? (
<Button> <Button variant="outline">
<Upload className="mr-2 h-4 w-4" /> <Upload className="mr-2 h-4 w-4" />
<Trans>Bulk Send via CSV</Trans> <Trans>Bulk Send via CSV</Trans>
</Button> </Button>

View File

@ -16,7 +16,7 @@ import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/fi
import { base64 } from '@documenso/lib/universal/base64'; import { base64 } from '@documenso/lib/universal/base64';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers'; import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
import { useSignerColors } from '@documenso/ui/lib/signer-colors'; import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { FieldItem } from '@documenso/ui/primitives/document-flow/field-item'; import { FieldItem } from '@documenso/ui/primitives/document-flow/field-item';
@ -162,7 +162,7 @@ export const ConfigureFieldsView = ({
}); });
const selectedRecipientIndex = recipients.findIndex((r) => r.id === selectedRecipient?.id); const selectedRecipientIndex = recipients.findIndex((r) => r.id === selectedRecipient?.id);
const selectedSignerStyles = useSignerColors( const selectedRecipientStyles = useRecipientColors(
selectedRecipientIndex === -1 ? 0 : selectedRecipientIndex, selectedRecipientIndex === -1 ? 0 : selectedRecipientIndex,
); );
@ -505,7 +505,7 @@ export const ConfigureFieldsView = ({
<div <div
className={cn( className={cn(
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200 [container-type:size]', 'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200 [container-type:size]',
selectedSignerStyles.default.base, selectedRecipientStyles.base,
{ {
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds, '-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
'dark:text-black/60': isFieldWithinBounds, 'dark:text-black/60': isFieldWithinBounds,

View File

@ -20,6 +20,7 @@ import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields
import { validateFieldsInserted } from '@documenso/lib/utils/fields'; import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
@ -37,7 +38,6 @@ import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-sc
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider'; import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider'; import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog'; import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
import { DocumentReadOnlyFields } from '../general/document/document-read-only-fields';
import { EmbedClientLoading } from './embed-client-loading'; import { EmbedClientLoading } from './embed-client-loading';
import { EmbedDocumentCompleted } from './embed-document-completed'; import { EmbedDocumentCompleted } from './embed-document-completed';
import { EmbedDocumentFields } from './embed-document-fields'; import { EmbedDocumentFields } from './embed-document-fields';

View File

@ -9,6 +9,10 @@ import { z } from 'zod';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import type { TTemplate } from '@documenso/lib/types/template'; import type { TTemplate } from '@documenso/lib/types/template';
import {
DocumentReadOnlyFields,
mapFieldsWithRecipients,
} from '@documenso/ui/components/document/document-read-only-fields';
import { import {
DocumentFlowFormContainerActions, DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent, DocumentFlowFormContainerContent,
@ -16,7 +20,6 @@ import {
DocumentFlowFormContainerHeader, DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep, DocumentFlowFormContainerStep,
} from '@documenso/ui/primitives/document-flow/document-flow-root'; } from '@documenso/ui/primitives/document-flow/document-flow-root';
import { ShowFieldItem } from '@documenso/ui/primitives/document-flow/show-field-item';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { import {
Form, Form,
@ -97,14 +100,16 @@ export const DirectTemplateConfigureForm = ({
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} /> <DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
<DocumentFlowFormContainerContent> <DocumentFlowFormContainerContent>
{isDocumentPdfLoaded && {isDocumentPdfLoaded && (
directTemplateRecipient.fields.map((field, index) => ( <DocumentReadOnlyFields
<ShowFieldItem fields={mapFieldsWithRecipients(
key={index} directTemplateRecipient.fields,
field={field} recipientsWithBlankDirectRecipientEmail,
recipients={recipientsWithBlankDirectRecipientEmail} )}
/> recipientIds={recipients.map((recipient) => recipient.id)}
))} showRecipientColors={true}
/>
)}
<Form {...form}> <Form {...form}>
<fieldset <fieldset

View File

@ -97,6 +97,12 @@ export const DocumentSigningCheckboxField = ({
const onSign = async (authOptions?: TRecipientActionAuth) => { const onSign = async (authOptions?: TRecipientActionAuth) => {
try { try {
// Do nothing, this should only happen when the user clicks the field, but
// misses the checkbox which triggers this callback.
if (checkedValues.length === 0) {
return;
}
if (!isLengthConditionMet) { if (!isLengthConditionMet) {
return; return;
} }
@ -270,21 +276,26 @@ export const DocumentSigningCheckboxField = ({
{validationSign?.label} {checkboxValidationLength} {validationSign?.label} {checkboxValidationLength}
</FieldToolTip> </FieldToolTip>
)} )}
<div className="z-50 flex flex-col gap-y-2"> <div className="z-50 my-0.5 flex flex-col gap-y-1">
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => { {values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
const itemValue = item.value || `empty-value-${item.id}`; const itemValue = item.value || `empty-value-${item.id}`;
return ( return (
<div key={index} className="flex items-center gap-x-1.5"> <div key={index} className="flex items-center">
<Checkbox <Checkbox
className="h-4 w-4" className="h-3 w-3"
id={`checkbox-${index}`} id={`checkbox-${field.id}-${item.id}`}
checked={checkedValues.includes(itemValue)} checked={checkedValues.includes(itemValue)}
onCheckedChange={() => handleCheckboxChange(item.value, item.id)} onCheckedChange={() => handleCheckboxChange(item.value, item.id)}
/> />
<Label htmlFor={`checkbox-${index}`}> {!item.value.includes('empty-value-') && item.value && (
{item.value.includes('empty-value-') ? '' : item.value} <Label
</Label> htmlFor={`checkbox-${field.id}-${item.id}`}
className="text-foreground ml-1.5 text-xs font-normal"
>
{item.value}
</Label>
)}
</div> </div>
); );
})} })}
@ -293,22 +304,27 @@ export const DocumentSigningCheckboxField = ({
)} )}
{field.inserted && ( {field.inserted && (
<div className="flex flex-col gap-y-1"> <div className="my-0.5 flex flex-col gap-y-1">
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => { {values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
const itemValue = item.value || `empty-value-${item.id}`; const itemValue = item.value || `empty-value-${item.id}`;
return ( return (
<div key={index} className="flex items-center gap-x-1.5"> <div key={index} className="flex items-center">
<Checkbox <Checkbox
className="h-3 w-3" className="h-3 w-3"
id={`checkbox-${index}`} id={`checkbox-${field.id}-${item.id}`}
checked={parsedCheckedValues.includes(itemValue)} checked={parsedCheckedValues.includes(itemValue)}
disabled={isLoading} disabled={isLoading}
onCheckedChange={() => void handleCheckboxOptionClick(item)} onCheckedChange={() => void handleCheckboxOptionClick(item)}
/> />
<Label htmlFor={`checkbox-${index}`} className="text-xs"> {!item.value.includes('empty-value-') && item.value && (
{item.value.includes('empty-value-') ? '' : item.value} <Label
</Label> htmlFor={`checkbox-${field.id}-${item.id}`}
className="text-foreground ml-1.5 text-xs font-normal"
>
{item.value}
</Label>
)}
</div> </div>
); );
})} })}

View File

@ -281,7 +281,7 @@ export const DocumentSigningCompleteDialog = ({
<div className="flex w-full flex-1 flex-nowrap gap-4"> <div className="flex w-full flex-1 flex-nowrap gap-4">
<Button <Button
type="button" type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10" className="flex-1"
variant="secondary" variant="secondary"
onClick={() => setShowDialog(false)} onClick={() => setShowDialog(false)}
disabled={form.formState.isSubmitting} disabled={form.formState.isSubmitting}

View File

@ -142,7 +142,7 @@ export const DocumentSigningDateField = ({
)} )}
{!field.inserted && ( {!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300"> <p className="group-hover:text-primary text-foreground group-hover:text-recipient-green text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
<Trans>Date</Trans> <Trans>Date</Trans>
</p> </p>
)} )}
@ -151,12 +151,10 @@ export const DocumentSigningDateField = ({
<div className="flex h-full w-full items-center"> <div className="flex h-full w-full items-center">
<p <p
className={cn( className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200', 'text-foreground w-full whitespace-nowrap text-left text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{ {
'text-left': parsedFieldMeta?.textAlign === 'left', '!text-center': parsedFieldMeta?.textAlign === 'center',
'text-center': '!text-right': parsedFieldMeta?.textAlign === 'right',
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
}, },
)} )}
> >

View File

@ -177,15 +177,11 @@ export const DocumentSigningDropdownField = ({
)} )}
{!field.inserted && ( {!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200"> <p className="group-hover:text-primary text-foreground flex flex-col items-center justify-center duration-200">
<Select value={localChoice} onValueChange={handleSelectItem}> <Select value={localChoice} onValueChange={handleSelectItem}>
<SelectTrigger <SelectTrigger
className={cn( className={cn(
'text-muted-foreground z-10 h-full w-full border-none ring-0 focus:ring-0', 'text-foreground z-10 h-full w-full border-none ring-0 focus:border-none focus:ring-0',
{
'hover:text-red-300': parsedFieldMeta.required,
'hover:text-yellow-300': !parsedFieldMeta.required,
},
)} )}
> >
<SelectValue <SelectValue
@ -205,7 +201,7 @@ export const DocumentSigningDropdownField = ({
)} )}
{field.inserted && ( {field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200"> <p className="text-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{field.customText} {field.customText}
</p> </p>
)} )}

View File

@ -1,7 +1,6 @@
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router'; import { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
@ -14,10 +13,14 @@ import type {
TRemovedSignedFieldWithTokenMutationSchema, TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema'; } from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentSigningFieldContainer } from './document-signing-field-container'; import { DocumentSigningFieldContainer } from './document-signing-field-container';
import {
DocumentSigningFieldsInserted,
DocumentSigningFieldsLoader,
DocumentSigningFieldsUninserted,
} from './document-signing-fields';
import { useRequiredDocumentSigningContext } from './document-signing-provider'; import { useRequiredDocumentSigningContext } from './document-signing-provider';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
@ -120,34 +123,18 @@ export const DocumentSigningEmailField = ({
return ( return (
<DocumentSigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Email"> <DocumentSigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Email">
{isLoading && ( {isLoading && <DocumentSigningFieldsLoader />}
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && ( {!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300"> <DocumentSigningFieldsUninserted>
<Trans>Email</Trans> <Trans>Email</Trans>
</p> </DocumentSigningFieldsUninserted>
)} )}
{field.inserted && ( {field.inserted && (
<div className="flex h-full w-full items-center"> <DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
<p {field.customText}
className={cn( </DocumentSigningFieldsInserted>
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText}
</p>
</div>
)} )}
</DocumentSigningFieldContainer> </DocumentSigningFieldContainer>
); );

View File

@ -2,12 +2,14 @@ import React from 'react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { FieldType } from '@prisma/client'; import { FieldType } from '@prisma/client';
import { TooltipArrow } from '@radix-ui/react-tooltip';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldRootContainer } from '@documenso/ui/components/field/field'; import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { RECIPIENT_COLOR_STYLES } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
@ -128,57 +130,51 @@ export const DocumentSigningFieldContainer = ({
}; };
return ( return (
<div className={cn('[container-type:size]', { group: type === 'Checkbox' })}> <div className={cn('[container-type:size]')}>
<FieldRootContainer field={field}> <FieldRootContainer color={RECIPIENT_COLOR_STYLES.green} field={field}>
{!field.inserted && !loading && !readOnlyField && ( {!field.inserted && !loading && !readOnlyField && (
<button <button
type="submit" type="submit"
className="absolute inset-0 z-10 h-full w-full rounded-md border" className="absolute inset-0 z-10 h-full w-full rounded-[2px]"
onClick={async () => handleInsertField()} onClick={async () => handleInsertField()}
/> />
)} )}
{readOnlyField && ( {readOnlyField && (
<button className="bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100"> <button className="bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100">
<span className="bg-foreground/50 dark:bg-background/50 text-background dark:text-foreground rounded-xl p-2"> <span className="bg-foreground/50 text-background rounded-xl p-2">
<Trans>Read only field</Trans> <Trans>Read only field</Trans>
</span> </span>
</button> </button>
)} )}
{type === 'Date' && field.inserted && !loading && !readOnlyField && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
<Trans>Remove</Trans>
</button>
</TooltipTrigger>
{tooltipText && <TooltipContent className="max-w-xs">{tooltipText}</TooltipContent>}
</Tooltip>
)}
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && ( {type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<button <button
className="dark:bg-background absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100" className="absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
onClick={() => void onClearCheckBoxValues(type)} onClick={() => void onClearCheckBoxValues(type)}
> >
<span className="dark:text-muted-foreground/50 dark:hover:text-muted-foreground dark:hover:bg-foreground/10 rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"> <span className="rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</span> </span>
</button> </button>
)} )}
{type !== 'Date' && type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && ( {type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<button <Tooltip delayDuration={0}>
className="text-destructive bg-background/50 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100" <TooltipTrigger asChild>
onClick={onRemoveSignedFieldClick} <button className="absolute inset-0 z-10" onClick={onRemoveSignedFieldClick}></button>
> </TooltipTrigger>
<Trans>Remove</Trans>
</button> <TooltipContent
className="border-0 bg-orange-300 fill-orange-300 font-bold text-orange-900"
sideOffset={2}
>
{tooltipText && <p>{tooltipText}</p>}
<Trans>Remove</Trans>
<TooltipArrow />
</TooltipContent>
</Tooltip>
)} )}
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) && {(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&

View File

@ -0,0 +1,51 @@
import { Loader } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
export const DocumentSigningFieldsLoader = () => {
return (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
);
};
export const DocumentSigningFieldsUninserted = ({ children }: { children: React.ReactNode }) => {
return (
<p className="group-hover:text-primary text-foreground group-hover:text-recipient-green text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{children}
</p>
);
};
type DocumentSigningFieldsInsertedProps = {
children: React.ReactNode;
/**
* The text alignment of the field.
*
* Defaults to left.
*/
textAlign?: 'left' | 'center' | 'right';
};
export const DocumentSigningFieldsInserted = ({
children,
textAlign = 'left',
}: DocumentSigningFieldsInsertedProps) => {
return (
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-foreground w-full text-left text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'!text-center': textAlign === 'center',
'!text-right': textAlign === 'right',
},
)}
>
{children}
</p>
</div>
);
};

View File

@ -1,12 +1,12 @@
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router'; import { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZInitialsFieldMeta } from '@documenso/lib/types/field-meta';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@ -17,6 +17,11 @@ import type {
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentSigningFieldContainer } from './document-signing-field-container'; import { DocumentSigningFieldContainer } from './document-signing-field-container';
import {
DocumentSigningFieldsInserted,
DocumentSigningFieldsLoader,
DocumentSigningFieldsUninserted,
} from './document-signing-fields';
import { useRequiredDocumentSigningContext } from './document-signing-provider'; import { useRequiredDocumentSigningContext } from './document-signing-provider';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
@ -50,6 +55,9 @@ export const DocumentSigningInitialsField = ({
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading; const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
const safeFieldMeta = ZInitialsFieldMeta.safeParse(field.fieldMeta);
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
const onSign = async (authOptions?: TRecipientActionAuth) => { const onSign = async (authOptions?: TRecipientActionAuth) => {
try { try {
const value = initials ?? ''; const value = initials ?? '';
@ -122,22 +130,18 @@ export const DocumentSigningInitialsField = ({
onRemove={onRemove} onRemove={onRemove}
type="Initials" type="Initials"
> >
{isLoading && ( {isLoading && <DocumentSigningFieldsLoader />}
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && ( {!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300"> <DocumentSigningFieldsUninserted>
<Trans>Initials</Trans> <Trans>Initials</Trans>
</p> </DocumentSigningFieldsUninserted>
)} )}
{field.inserted && ( {field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200"> <DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
{field.customText} {field.customText}
</p> </DocumentSigningFieldsInserted>
)} )}
</DocumentSigningFieldContainer> </DocumentSigningFieldContainer>
); );

View File

@ -3,7 +3,6 @@ import { useState } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router'; import { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
@ -16,7 +15,6 @@ import type {
TRemovedSignedFieldWithTokenMutationSchema, TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema'; } from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
@ -25,6 +23,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container'; import { DocumentSigningFieldContainer } from './document-signing-field-container';
import {
DocumentSigningFieldsInserted,
DocumentSigningFieldsLoader,
DocumentSigningFieldsUninserted,
} from './document-signing-fields';
import { useRequiredDocumentSigningContext } from './document-signing-provider'; import { useRequiredDocumentSigningContext } from './document-signing-provider';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
@ -166,34 +169,18 @@ export const DocumentSigningNameField = ({
onRemove={onRemove} onRemove={onRemove}
type="Name" type="Name"
> >
{isLoading && ( {isLoading && <DocumentSigningFieldsLoader />}
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && ( {!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300"> <DocumentSigningFieldsUninserted>
<Trans>Name</Trans> <Trans>Name</Trans>
</p> </DocumentSigningFieldsUninserted>
)} )}
{field.inserted && ( {field.inserted && (
<div className="flex h-full w-full items-center"> <DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
<p {field.customText}
className={cn( </DocumentSigningFieldsInserted>
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText}
</p>
</div>
)} )}
<Dialog open={showFullNameModal} onOpenChange={setShowFullNameModal}> <Dialog open={showFullNameModal} onOpenChange={setShowFullNameModal}>
@ -202,7 +189,7 @@ export const DocumentSigningNameField = ({
<Trans> <Trans>
Sign as Sign as
<div> <div>
{recipient.name} <div className="text-muted-foreground">({recipient.email})</div> {recipient.name} <div className="text-foreground">({recipient.email})</div>
</div> </div>
</Trans> </Trans>
</DialogTitle> </DialogTitle>
@ -224,7 +211,7 @@ export const DocumentSigningNameField = ({
<div className="flex w-full flex-1 flex-nowrap gap-4"> <div className="flex w-full flex-1 flex-nowrap gap-4">
<Button <Button
type="button" type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10" className="flex-1"
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
setShowFullNameModal(false); setShowFullNameModal(false);

View File

@ -3,7 +3,6 @@ import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { Hash, Loader } from 'lucide-react';
import { useRevalidator } from 'react-router'; import { useRevalidator } from 'react-router';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number'; import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
@ -25,6 +24,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container'; import { DocumentSigningFieldContainer } from './document-signing-field-container';
import {
DocumentSigningFieldsInserted,
DocumentSigningFieldsLoader,
DocumentSigningFieldsUninserted,
} from './document-signing-fields';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
type ValidationErrors = { type ValidationErrors = {
@ -245,45 +249,16 @@ export const DocumentSigningNumberField = ({
onRemove={onRemove} onRemove={onRemove}
type="Number" type="Number"
> >
{isLoading && ( {isLoading && <DocumentSigningFieldsLoader />}
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && ( {!field.inserted && (
<p <DocumentSigningFieldsUninserted>{fieldDisplayName}</DocumentSigningFieldsUninserted>
className={cn(
'group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200',
{
'group-hover:text-yellow-300': !field.inserted && !parsedFieldMeta?.required,
'group-hover:text-red-300': !field.inserted && parsedFieldMeta?.required,
},
)}
>
<span className="flex items-center justify-center gap-x-1">
<Hash className="h-[clamp(0.625rem,20cqw,0.925rem)] w-[clamp(0.625rem,20cqw,0.925rem)]" />{' '}
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{fieldDisplayName}</span>
</span>
</p>
)} )}
{field.inserted && ( {field.inserted && (
<div className="flex h-full w-full items-center"> <DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
<p {field.customText}
className={cn( </DocumentSigningFieldsInserted>
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText}
</p>
</div>
)} )}
<Dialog open={showNumberModal} onOpenChange={setShowNumberModal}> <Dialog open={showNumberModal} onOpenChange={setShowNumberModal}>
@ -339,7 +314,7 @@ export const DocumentSigningNumberField = ({
<div className="flex w-full flex-1 flex-nowrap gap-4"> <div className="flex w-full flex-1 flex-nowrap gap-4">
<Button <Button
type="button" type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10" className="flex-1"
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
setShowNumberModal(false); setShowNumberModal(false);

View File

@ -19,6 +19,7 @@ import {
import type { CompletedField } from '@documenso/lib/types/fields'; import type { CompletedField } from '@documenso/lib/types/fields';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
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 { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
@ -36,7 +37,6 @@ import { DocumentSigningRadioField } from '~/components/general/document-signing
import { DocumentSigningRejectDialog } from '~/components/general/document-signing/document-signing-reject-dialog'; import { DocumentSigningRejectDialog } from '~/components/general/document-signing/document-signing-reject-dialog';
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field'; import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field'; import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider'; import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
@ -157,7 +157,11 @@ export const DocumentSigningPageView = ({
</div> </div>
</div> </div>
<DocumentReadOnlyFields documentMeta={documentMeta || undefined} fields={completedFields} /> <DocumentReadOnlyFields
documentMeta={documentMeta || undefined}
fields={completedFields}
showRecipientTooltip={true}
/>
{recipient.role !== RecipientRole.ASSISTANT && ( {recipient.role !== RecipientRole.ASSISTANT && (
<DocumentSigningAutoSign recipient={recipient} fields={fields} /> <DocumentSigningAutoSign recipient={recipient} fields={fields} />

View File

@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router'; import { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
@ -21,6 +20,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container'; import { DocumentSigningFieldContainer } from './document-signing-field-container';
import { DocumentSigningFieldsLoader } from './document-signing-fields';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
export type DocumentSigningRadioFieldProps = { export type DocumentSigningRadioFieldProps = {
@ -150,44 +150,52 @@ export const DocumentSigningRadioField = ({
return ( return (
<DocumentSigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Radio"> <DocumentSigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Radio">
{isLoading && ( {isLoading && <DocumentSigningFieldsLoader />}
<div className="bg-background absolute inset-0 z-20 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && ( {!field.inserted && (
<RadioGroup onValueChange={(value) => handleSelectItem(value)} className="z-10"> <RadioGroup
onValueChange={(value) => handleSelectItem(value)}
className="z-10 my-0.5 gap-y-1"
>
{values?.map((item, index) => ( {values?.map((item, index) => (
<div key={index} className="flex items-center gap-x-1.5"> <div key={index} className="flex items-center">
<RadioGroupItem <RadioGroupItem
className="h-4 w-4 shrink-0" className="h-3 w-3 shrink-0"
value={item.value} value={item.value}
id={`option-${index}`} id={`option-${field.id}-${item.id}`}
checked={item.checked} checked={item.checked}
/> />
{!item.value.includes('empty-value-') && item.value && (
<Label htmlFor={`option-${index}`}> <Label
{item.value.includes('empty-value-') ? '' : item.value} htmlFor={`option-${field.id}-${item.id}`}
</Label> className="text-foreground ml-1.5 text-xs font-normal"
>
{item.value}
</Label>
)}
</div> </div>
))} ))}
</RadioGroup> </RadioGroup>
)} )}
{field.inserted && ( {field.inserted && (
<RadioGroup className="gap-y-1"> <RadioGroup className="my-0.5 gap-y-1">
{values?.map((item, index) => ( {values?.map((item, index) => (
<div key={index} className="flex items-center gap-x-1.5"> <div key={index} className="flex items-center">
<RadioGroupItem <RadioGroupItem
className="h-3 w-3" className="h-3 w-3"
value={item.value} value={item.value}
id={`option-${index}`} id={`option-${field.id}-${item.id}`}
checked={item.value === field.customText} checked={item.value === field.customText}
/> />
<Label htmlFor={`option-${index}`} className="text-xs"> {!item.value.includes('empty-value-') && item.value && (
{item.value.includes('empty-value-') ? '' : item.value} <Label
</Label> htmlFor={`option-${field.id}-${item.id}`}
className="text-foreground ml-1.5 text-xs font-normal"
>
{item.value}
</Label>
)}
</div> </div>
))} ))}
</RadioGroup> </RadioGroup>

View File

@ -242,7 +242,7 @@ export const DocumentSigningSignatureField = ({
)} )}
{state === 'empty' && ( {state === 'empty' && (
<p className="group-hover:text-primary font-signature text-muted-foreground text-[clamp(0.575rem,25cqw,1.2rem)] text-xl duration-200 group-hover:text-yellow-300"> <p className="group-hover:text-primary font-signature text-muted-foreground group-hover:text-recipient-green text-[clamp(0.575rem,25cqw,1.2rem)] text-xl duration-200">
<Trans>Signature</Trans> <Trans>Signature</Trans>
</p> </p>
)} )}
@ -259,7 +259,7 @@ export const DocumentSigningSignatureField = ({
<div ref={containerRef} className="flex h-full w-full items-center justify-center p-2"> <div ref={containerRef} className="flex h-full w-full items-center justify-center p-2">
<p <p
ref={signatureRef} ref={signatureRef}
className="font-signature text-muted-foreground dark:text-background w-full overflow-hidden break-all text-center leading-tight duration-200" className="font-signature text-muted-foreground w-full overflow-hidden break-all text-center leading-tight duration-200"
style={{ fontSize: `${fontSize}rem` }} style={{ fontSize: `${fontSize}rem` }}
> >
{signature?.typedSignature} {signature?.typedSignature}
@ -291,7 +291,7 @@ export const DocumentSigningSignatureField = ({
<div className="flex w-full flex-1 flex-nowrap gap-4"> <div className="flex w-full flex-1 flex-nowrap gap-4">
<Button <Button
type="button" type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10" className="flex-1"
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
setShowSignatureModal(false); setShowSignatureModal(false);

View File

@ -3,7 +3,6 @@ import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro'; import { Plural, Trans } from '@lingui/react/macro';
import { Loader, Type } from 'lucide-react';
import { useRevalidator } from 'react-router'; import { useRevalidator } from 'react-router';
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text'; import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
@ -25,6 +24,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container'; import { DocumentSigningFieldContainer } from './document-signing-field-container';
import {
DocumentSigningFieldsInserted,
DocumentSigningFieldsLoader,
DocumentSigningFieldsUninserted,
} from './document-signing-fields';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
export type DocumentSigningTextFieldProps = { export type DocumentSigningTextFieldProps = {
@ -248,49 +252,20 @@ export const DocumentSigningTextField = ({
onRemove={onRemove} onRemove={onRemove}
type="Text" type="Text"
> >
{isLoading && ( {isLoading && <DocumentSigningFieldsLoader />}
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && ( {!field.inserted && (
<p <DocumentSigningFieldsUninserted>
className={cn( {fieldDisplayName || <Trans>Text</Trans>}
'group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200', </DocumentSigningFieldsUninserted>
{
'group-hover:text-yellow-300': !field.inserted && !parsedFieldMeta?.required,
'group-hover:text-red-300': !field.inserted && parsedFieldMeta?.required,
},
)}
>
<span className="flex items-center justify-center gap-x-1">
<Type className="h-[clamp(0.625rem,20cqw,0.925rem)] w-[clamp(0.625rem,20cqw,0.925rem)]" />
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">
{fieldDisplayName || <Trans>Text</Trans>}
</span>
</span>
</p>
)} )}
{field.inserted && ( {field.inserted && (
<div className="flex h-full w-full items-center"> <DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
<p {field.customText.length < 20
className={cn( ? field.customText
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200', : field.customText.substring(0, 20) + '...'}
{ </DocumentSigningFieldsInserted>
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText.length < 20
? field.customText
: field.customText.substring(0, 20) + '...'}
</p>
</div>
)} )}
<Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}> <Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}>
@ -304,11 +279,9 @@ export const DocumentSigningTextField = ({
id="custom-text" id="custom-text"
placeholder={parsedFieldMeta?.placeholder ?? _(msg`Enter your text here`)} placeholder={parsedFieldMeta?.placeholder ?? _(msg`Enter your text here`)}
className={cn('mt-2 w-full rounded-md', { className={cn('mt-2 w-full rounded-md', {
'border-2 border-red-300 ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200': 'border-2 border-red-300 text-left ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
userInputHasErrors, userInputHasErrors,
'text-left': parsedFieldMeta?.textAlign === 'left', 'text-center': parsedFieldMeta?.textAlign === 'center',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right', 'text-right': parsedFieldMeta?.textAlign === 'right',
})} })}
value={localText} value={localText}
@ -354,8 +327,8 @@ export const DocumentSigningTextField = ({
<div className="mt-4 flex w-full flex-1 flex-nowrap gap-4"> <div className="mt-4 flex w-full flex-1 flex-nowrap gap-4">
<Button <Button
type="button" type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary" variant="secondary"
className="flex-1"
onClick={() => { onClick={() => {
setShowCustomTextModal(false); setShowCustomTextModal(false);
setLocalCustomText(''); setLocalCustomText('');

View File

@ -1,171 +0,0 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
import { FieldType, SigningStatus } from '@prisma/client';
import { Clock, EyeOffIcon } from 'lucide-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 { 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 { cn } from '@documenso/ui/lib/utils';
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';
export type DocumentReadOnlyFieldsProps = {
fields: DocumentField[];
documentMeta?: DocumentMeta | TemplateMeta;
showFieldStatus?: boolean;
};
export const DocumentReadOnlyFields = ({
documentMeta,
fields,
showFieldStatus = true,
}: DocumentReadOnlyFieldsProps) => {
const { _ } = useLingui();
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-300/50 !shadow-none backdrop-blur-[1px] bg-gray-50 ring-0 ring-offset-0"
>
<div className="absolute -right-3 -top-3">
<PopoverHover
trigger={
<Avatar className="dark:border-foreground 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: '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>
<div className="text-muted-foreground dark:text-background/70 break-all text-sm">
{field.recipient.signingStatus === SigningStatus.SIGNED &&
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">
{field.signature?.typedSignature}
</p>
),
)
.with(
{
type: P.union(
FieldType.NAME,
FieldType.INITIALS,
FieldType.EMAIL,
FieldType.NUMBER,
FieldType.RADIO,
FieldType.CHECKBOX,
FieldType.DROPDOWN,
),
},
() => field.customText,
)
.with({ type: FieldType.TEXT }, () => field.customText.substring(0, 20) + '...')
.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()}
{field.recipient.signingStatus === SigningStatus.NOT_SIGNED && (
<p
className={cn('text-muted-foreground text-lg duration-200', {
'font-signature sm:text-xl md:text-2xl':
field.type === FieldType.SIGNATURE ||
field.type === FieldType.FREE_SIGNATURE,
})}
>
{parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type])}
</p>
)}
</div>
</FieldRootContainer>
),
)}
</ElementVisible>
);
};

View File

@ -0,0 +1,112 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AlertCircle } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { PopoverHover } from '@documenso/ui/primitives/popover';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type LegacyFieldWarningPopoverProps = {
type?: 'document' | 'template';
documentId?: number;
templateId?: number;
};
export const LegacyFieldWarningPopover = ({
type = 'document',
documentId,
templateId,
}: LegacyFieldWarningPopoverProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const revalidator = useRevalidator();
const { mutateAsync: updateTemplate, isPending: isUpdatingTemplate } =
trpc.template.updateTemplate.useMutation();
const { mutateAsync: updateDocument, isPending: isUpdatingDocument } =
trpc.document.updateDocument.useMutation();
const onUpdateFieldsClick = async () => {
if (type === 'document') {
if (!documentId) {
return;
}
await updateDocument({
documentId,
data: {
useLegacyFieldInsertion: false,
},
});
}
if (type === 'template') {
if (!templateId) {
return;
}
await updateTemplate({
templateId,
data: {
useLegacyFieldInsertion: false,
},
});
}
void revalidator.revalidate();
toast({
title: _(msg`Fields updated`),
description: _(
msg`The fields have been updated to the new field insertion method successfully`,
),
});
};
return (
<PopoverHover
side="bottom"
trigger={
<Button variant="outline" className="h-9 w-9 p-0">
<span className="sr-only">
{type === 'document' ? (
<Trans>Document is using legacy field insertion</Trans>
) : (
<Trans>Template is using legacy field insertion</Trans>
)}
</span>
<AlertCircle className="h-5 w-5" />
</Button>
}
>
<p className="text-muted-foreground text-sm">
{type === 'document' ? (
<Trans>
This document is using legacy field insertion, we recommend using the new field
insertion method for more accurate results.
</Trans>
) : (
<Trans>
This template is using legacy field insertion, we recommend using the new field
insertion method for more accurate results.
</Trans>
)}
</p>
<div className="mt-2 flex w-full items-center justify-end">
<Button
type="button"
size="sm"
loading={isUpdatingDocument || isUpdatingTemplate}
onClick={onUpdateFieldsClick}
>
<Trans>Update Fields</Trans>
</Button>
</div>
</PopoverHover>
);
};

View File

@ -13,6 +13,7 @@ import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/g
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility'; import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { Badge } from '@documenso/ui/primitives/badge'; import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -24,7 +25,6 @@ import { DocumentPageViewDropdown } from '~/components/general/document/document
import { DocumentPageViewInformation } from '~/components/general/document/document-page-view-information'; import { DocumentPageViewInformation } from '~/components/general/document/document-page-view-information';
import { DocumentPageViewRecentActivity } from '~/components/general/document/document-page-view-recent-activity'; import { DocumentPageViewRecentActivity } from '~/components/general/document/document-page-view-recent-activity';
import { DocumentPageViewRecipients } from '~/components/general/document/document-page-view-recipients'; import { DocumentPageViewRecipients } from '~/components/general/document/document-page-view-recipients';
import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog'; import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
import { import {
DocumentStatus as DocumentStatusComponent, DocumentStatus as DocumentStatusComponent,
@ -200,8 +200,14 @@ export default function DocumentPage() {
</CardContent> </CardContent>
</Card> </Card>
{document.status === DocumentStatus.PENDING && ( {document.status !== DocumentStatus.COMPLETED && (
<DocumentReadOnlyFields fields={fields} documentMeta={documentMeta || undefined} /> <DocumentReadOnlyFields
fields={fields}
documentMeta={documentMeta || undefined}
showRecipientTooltip={true}
showRecipientColors={true}
recipientIds={recipients.map((recipient) => recipient.id)}
/>
)} )}
<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">

View File

@ -14,6 +14,7 @@ import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentEditForm } from '~/components/general/document/document-edit-form'; import { DocumentEditForm } from '~/components/general/document/document-edit-form';
import { DocumentStatus } from '~/components/general/document/document-status'; import { DocumentStatus } from '~/components/general/document/document-status';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip'; import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader'; import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
@ -100,29 +101,43 @@ export default function DocumentEditPage() {
<Trans>Documents</Trans> <Trans>Documents</Trans>
</Link> </Link>
<h1 <div className="mt-4 flex w-full items-end justify-between">
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl" <div className="flex-1">
title={document.title} <h1
> className="block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
{document.title} title={document.title}
</h1> >
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6"> <div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" /> <DocumentStatus
inheritColor
status={document.status}
className="text-muted-foreground"
/>
{recipients.length > 0 && ( {recipients.length > 0 && (
<div className="text-muted-foreground flex items-center"> <div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" /> <Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip <StackAvatarsWithTooltip
recipients={recipients} recipients={recipients}
documentStatus={document.status} documentStatus={document.status}
position="bottom" position="bottom"
> >
<span> <span>
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} /> <Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
</span> </span>
</StackAvatarsWithTooltip> </StackAvatarsWithTooltip>
</div>
)}
</div>
</div>
{document.useLegacyFieldInsertion && (
<div>
<LegacyFieldWarningPopover type="document" documentId={document.id} />
</div> </div>
)} )}
</div> </div>

View File

@ -7,6 +7,7 @@ import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
@ -14,7 +15,6 @@ import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog'; import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog';
import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper'; import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper';
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog'; import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge'; import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
import { TemplatePageViewDocumentsTable } from '~/components/general/template/template-page-view-documents-table'; import { TemplatePageViewDocumentsTable } from '~/components/general/template/template-page-view-documents-table';
import { TemplatePageViewInformation } from '~/components/general/template/template-page-view-information'; import { TemplatePageViewInformation } from '~/components/general/template/template-page-view-information';
@ -151,6 +151,9 @@ export default function TemplatePage() {
<DocumentReadOnlyFields <DocumentReadOnlyFields
fields={readOnlyFields} fields={readOnlyFields}
showFieldStatus={false} showFieldStatus={false}
showRecipientTooltip={true}
showRecipientColors={true}
recipientIds={recipients.map((recipient) => recipient.id)}
documentMeta={mockedDocumentMeta} documentMeta={mockedDocumentMeta}
/> />

View File

@ -8,6 +8,7 @@ import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge'; import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
import { TemplateEditForm } from '~/components/general/template/template-edit-form'; import { TemplateEditForm } from '~/components/general/template/template-edit-form';
import { TemplateType } from '~/components/general/template/template-type'; import { TemplateType } from '~/components/general/template/template-type';
@ -91,8 +92,14 @@ export default function TemplateEditPage() {
</div> </div>
</div> </div>
<div className="mt-2 sm:mt-0 sm:self-end"> <div className="mt-2 flex items-center gap-2 sm:mt-0 sm:self-end">
<TemplateDirectLinkDialogWrapper template={template} /> <TemplateDirectLinkDialogWrapper template={template} />
{template.useLegacyFieldInsertion && (
<div>
<LegacyFieldWarningPopover type="template" templateId={template.id} />
</div>
)}
</div> </div>
</div> </div>

View File

@ -14,6 +14,7 @@ import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-s
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations'; import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
import { flattenForm } from '../../../server-only/pdf/flatten-form'; import { flattenForm } from '../../../server-only/pdf/flatten-form';
import { insertFieldInPDF } from '../../../server-only/pdf/insert-field-in-pdf'; import { insertFieldInPDF } from '../../../server-only/pdf/insert-field-in-pdf';
import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-field-in-pdf';
import { normalizeSignatureAppearances } from '../../../server-only/pdf/normalize-signature-appearances'; import { normalizeSignatureAppearances } from '../../../server-only/pdf/normalize-signature-appearances';
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
@ -167,7 +168,9 @@ export const run = async ({
for (const field of fields) { for (const field of fields) {
if (field.inserted) { if (field.inserted) {
await insertFieldInPDF(pdfDoc, field); document.useLegacyFieldInsertion
? await legacy_insertFieldInPDF(pdfDoc, field)
: await insertFieldInPDF(pdfDoc, field);
} }
} }

View File

@ -22,6 +22,7 @@ import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations'; import { flattenAnnotations } from '../pdf/flatten-annotations';
import { flattenForm } from '../pdf/flatten-form'; import { flattenForm } from '../pdf/flatten-form';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf'; import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
import { legacy_insertFieldInPDF } from '../pdf/legacy-insert-field-in-pdf';
import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances'; import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sendCompletedEmail } from './send-completed-email'; import { sendCompletedEmail } from './send-completed-email';
@ -146,7 +147,9 @@ export const sealDocument = async ({
} }
for (const field of fields) { for (const field of fields) {
await insertFieldInPDF(doc, field); document.useLegacyFieldInsertion
? await legacy_insertFieldInPDF(doc, field)
: await insertFieldInPDF(doc, field);
} }
// Re-flatten post-insertion to handle fields that create arcoFields // Re-flatten post-insertion to handle fields that create arcoFields

View File

@ -23,6 +23,7 @@ export type UpdateDocumentOptions = {
visibility?: DocumentVisibility | null; visibility?: DocumentVisibility | null;
globalAccessAuth?: TDocumentAccessAuthTypes | null; globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null; globalActionAuth?: TDocumentActionAuthTypes | null;
useLegacyFieldInsertion?: boolean;
}; };
requestMetadata: ApiRequestMetadata; requestMetadata: ApiRequestMetadata;
}; };
@ -118,6 +119,7 @@ export const updateDocument = async ({
// If no data just return the document since this function is normally chained after a meta update. // If no data just return the document since this function is normally chained after a meta update.
if (!data || Object.values(data).length === 0) { if (!data || Object.values(data).length === 0) {
console.log('no data');
return document; return document;
} }
@ -236,7 +238,7 @@ export const updateDocument = async ({
} }
// Early return if nothing is required. // Early return if nothing is required.
if (auditLogs.length === 0) { if (auditLogs.length === 0 && data.useLegacyFieldInsertion === undefined) {
return document; return document;
} }
@ -254,6 +256,7 @@ export const updateDocument = async ({
title: data.title, title: data.title,
externalId: data.externalId, externalId: data.externalId,
visibility: data.visibility as DocumentVisibility, visibility: data.visibility as DocumentVisibility,
useLegacyFieldInsertion: data.useLegacyFieldInsertion,
authOptions, authOptions,
}, },
}); });

View File

@ -1,8 +1,8 @@
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821 // https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
import fontkit from '@pdf-lib/fontkit'; import fontkit from '@pdf-lib/fontkit';
import { FieldType } from '@prisma/client'; import { FieldType } from '@prisma/client';
import type { PDFDocument } from 'pdf-lib'; import type { PDFDocument, PDFFont } from 'pdf-lib';
import { RotationTypes, degrees, radiansToDegrees, rgb } from 'pdf-lib'; import { RotationTypes, TextAlignment, degrees, radiansToDegrees, rgb } from 'pdf-lib';
import { P, match } from 'ts-pattern'; import { P, match } from 'ts-pattern';
import { import {
@ -34,6 +34,13 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
]); ]);
const isSignatureField = isSignatureFieldType(field.type); const isSignatureField = isSignatureFieldType(field.type);
/**
* Red box is the original field width, height and position.
*
* Blue box is the adjusted field width, height and position. It will represent
* where the text will overflow into.
*/
const isDebugMode = const isDebugMode =
// eslint-disable-next-line turbo/no-undeclared-env-vars // eslint-disable-next-line turbo/no-undeclared-env-vars
process.env.DEBUG_PDF_INSERT === '1' || process.env.DEBUG_PDF_INSERT === 'true'; process.env.DEBUG_PDF_INSERT === '1' || process.env.DEBUG_PDF_INSERT === 'true';
@ -227,8 +234,13 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const selected: string[] = fromCheckboxValue(field.customText); const selected: string[] = fromCheckboxValue(field.customText);
const topPadding = 12;
const leftCheckboxPadding = 8;
const leftCheckboxLabelPadding = 12;
const checkboxSpaceY = 13;
for (const [index, item] of (values ?? []).entries()) { for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * 16; const offsetY = index * checkboxSpaceY + topPadding;
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`); const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
@ -237,7 +249,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
} }
page.drawText(item.value.includes('empty-value-') ? '' : item.value, { page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + 16, x: fieldX + leftCheckboxPadding + leftCheckboxLabelPadding,
y: pageHeight - (fieldY + offsetY), y: pageHeight - (fieldY + offsetY),
size: 12, size: 12,
font, font,
@ -245,7 +257,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
}); });
checkbox.addToPage(page, { checkbox.addToPage(page, {
x: fieldX, x: fieldX + leftCheckboxPadding,
y: pageHeight - (fieldY + offsetY), y: pageHeight - (fieldY + offsetY),
height: 8, height: 8,
width: 8, width: 8,
@ -268,21 +280,28 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const selected = field.customText.split(','); const selected = field.customText.split(',');
const topPadding = 12;
const leftRadioPadding = 8;
const leftRadioLabelPadding = 12;
const radioSpaceY = 13;
for (const [index, item] of (values ?? []).entries()) { for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * 16; const offsetY = index * radioSpaceY + topPadding;
const radio = pdf.getForm().createRadioGroup(`radio.${field.secondaryId}.${index}`); const radio = pdf.getForm().createRadioGroup(`radio.${field.secondaryId}.${index}`);
// Draw label.
page.drawText(item.value.includes('empty-value-') ? '' : item.value, { page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + 16, x: fieldX + leftRadioPadding + leftRadioLabelPadding,
y: pageHeight - (fieldY + offsetY), y: pageHeight - (fieldY + offsetY),
size: 12, size: 12,
font, font,
rotate: degrees(pageRotationInDegrees), rotate: degrees(pageRotationInDegrees),
}); });
// Draw radio button.
radio.addOptionToPage(item.value, page, { radio.addOptionToPage(item.value, page, {
x: fieldX, x: fieldX + leftRadioPadding,
y: pageHeight - (fieldY + offsetY), y: pageHeight - (fieldY + offsetY),
height: 8, height: 8,
width: 8, width: 8,
@ -304,62 +323,144 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
} as const; } as const;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const Parser = fieldMetaParsers[field.type as keyof typeof fieldMetaParsers]; const fieldMetaParser = fieldMetaParsers[field.type as keyof typeof fieldMetaParsers];
const meta = Parser ? Parser.safeParse(field.fieldMeta) : null; const meta = fieldMetaParser ? fieldMetaParser.safeParse(field.fieldMeta) : null;
const customFontSize = meta?.success && meta.data.fontSize ? meta.data.fontSize : null; const customFontSize = meta?.success && meta.data.fontSize ? meta.data.fontSize : null;
const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'center'; const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'left';
const longestLineInTextForWidth = field.customText
.split('\n')
.sort((a, b) => b.length - a.length)[0];
let fontSize = customFontSize || maxFontSize; let fontSize = customFontSize || maxFontSize;
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize); const textWidth = font.widthOfTextAtSize(field.customText, fontSize);
const textHeight = font.heightAtSize(fontSize); const textHeight = font.heightAtSize(fontSize);
// Scale font only if no custom font and height exceeds field height.
if (!customFontSize) { if (!customFontSize) {
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1); const scalingFactor = Math.min(fieldHeight / textHeight, 1);
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize); fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
} }
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize); /**
* Calculate whether the field should be multiline.
*
* - True = text will overflow downwards.
* - False = text will overflow sideways.
*/
const isMultiline =
field.type === FieldType.TEXT &&
(textWidth > fieldWidth || field.customText.includes('\n'));
// Add padding similar to web display (roughly 0.5rem equivalent in PDF units) // Add padding similar to web display (roughly 0.5rem equivalent in PDF units)
const padding = 8; // PDF points, roughly equivalent to 0.5rem const padding = 8;
// Calculate X position based on text alignment with padding const textAlignmentOptions = getTextAlignmentOptions(textAlign, fieldX, isMultiline, padding);
let textX = fieldX + padding; // Left alignment starts after padding
if (textAlign === 'center') {
textX = fieldX + (fieldWidth - textWidth) / 2; // Center alignment ignores padding
} else if (textAlign === 'right') {
textX = fieldX + fieldWidth - textWidth - padding; // Right alignment respects right padding
}
let textY = fieldY + (fieldHeight - textHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system // Invert the Y axis since PDFs use a bottom-left coordinate system
textY = pageHeight - textY - textHeight; let textFieldBoxY = pageHeight - fieldY - fieldHeight;
const textFieldBoxX = textAlignmentOptions.xPos;
const textField = pdf.getForm().createTextField(`text.${field.secondaryId}`);
textField.setAlignment(textAlignmentOptions.textAlignment);
/**
* From now on we will adjust the field size and position so the text
* overflows correctly in the X or Y axis depending on the field type.
*/
let adjustedFieldWidth = fieldWidth - padding * 2; //
let adjustedFieldHeight = fieldHeight;
let adjustedFieldX = textFieldBoxX;
let adjustedFieldY = textFieldBoxY;
let textToInsert = field.customText;
// The padding to use when fields go off the page.
const pagePadding = 4;
// Handle multiline text, which will overflow on the Y axis.
if (isMultiline) {
textToInsert = breakLongString(textToInsert, adjustedFieldWidth, font, fontSize);
textField.enableMultiline();
textField.disableCombing();
textField.disableScrolling();
// Adjust the textFieldBox so it extends to the bottom of the page so text can wrap.
textFieldBoxY = pageHeight - fieldY - fieldHeight;
// Calculate how much PX from the current field to bottom of the page.
const fieldYOffset = pageHeight - (fieldY + fieldHeight) - pagePadding;
// Field height will be from current to bottom of page.
adjustedFieldHeight = fieldHeight + fieldYOffset;
// Need to move the field Y so it offsets the new field height.
adjustedFieldY = adjustedFieldY - fieldYOffset;
}
// Handle non-multiline text, which will overflow on the X axis.
if (!isMultiline) {
// Left align will extend all the way to the right of the page
if (textAlignmentOptions.textAlignment === TextAlignment.Left) {
adjustedFieldWidth = pageWidth - textFieldBoxX - pagePadding;
}
// Right align will extend all the way to the left of the page.
if (textAlignmentOptions.textAlignment === TextAlignment.Right) {
adjustedFieldWidth = textFieldBoxX + fieldWidth - pagePadding;
adjustedFieldX = adjustedFieldX - adjustedFieldWidth + fieldWidth;
}
// Center align will extend to the closest page edge, then use that * 2 as the width.
if (textAlignmentOptions.textAlignment === TextAlignment.Center) {
const fieldMidpoint = textFieldBoxX + fieldWidth / 2;
const isCloserToLeftEdge = fieldMidpoint < pageWidth / 2;
// If field is closer to left edge, the width must be based of the left.
if (isCloserToLeftEdge) {
adjustedFieldWidth = (textFieldBoxX - pagePadding) * 2 + fieldWidth;
adjustedFieldX = pagePadding;
}
// If field is closer to right edge, the width must be based of the right
if (!isCloserToLeftEdge) {
adjustedFieldWidth = (pageWidth - textFieldBoxX - pagePadding - fieldWidth / 2) * 2;
adjustedFieldX = pageWidth - adjustedFieldWidth - pagePadding;
}
}
}
if (pageRotationInDegrees !== 0) { if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation( const adjustedPosition = adjustPositionForRotation(
pageWidth, pageWidth,
pageHeight, pageHeight,
textX, adjustedFieldX,
textY, adjustedFieldY,
pageRotationInDegrees, pageRotationInDegrees,
); );
textX = adjustedPosition.xPos; adjustedFieldX = adjustedPosition.xPos;
textY = adjustedPosition.yPos; adjustedFieldY = adjustedPosition.yPos;
} }
page.drawText(field.customText, { // Set the position and size of the text field
x: textX, textField.addToPage(page, {
y: textY, x: adjustedFieldX,
size: fontSize, y: adjustedFieldY,
font, width: adjustedFieldWidth,
height: adjustedFieldHeight,
rotate: degrees(pageRotationInDegrees), rotate: degrees(pageRotationInDegrees),
// Hide borders.
borderWidth: 0,
borderColor: undefined,
backgroundColor: undefined,
...(isDebugMode ? { borderWidth: 1, borderColor: rgb(0, 0, 1) } : {}),
}); });
// Set properties for the text field
textField.setFontSize(fontSize);
textField.setText(textToInsert);
}); });
return pdf; return pdf;
@ -393,3 +494,138 @@ const adjustPositionForRotation = (
yPos, yPos,
}; };
}; };
const textAlignmentMap = {
left: TextAlignment.Left,
center: TextAlignment.Center,
right: TextAlignment.Right,
} as const;
/**
* Get the PDF-lib alignment position, and the X position of the field with padding included.
*
* @param textAlign - The text alignment of the field.
* @param fieldX - The X position of the field.
* @param isMultiline - Whether the field is multiline.
* @param padding - The padding of the field. Defaults to 8.
*
* @returns The X position and text alignment for the field.
*/
const getTextAlignmentOptions = (
textAlign: 'left' | 'center' | 'right',
fieldX: number,
isMultiline: boolean,
padding: number = 8,
) => {
const textAlignment = textAlignmentMap[textAlign];
// For multiline, it needs to be centered so we just basic left padding.
if (isMultiline) {
return {
xPos: fieldX + padding,
textAlignment,
};
}
return match(textAlign)
.with('left', () => ({
xPos: fieldX + padding,
textAlignment,
}))
.with('center', () => ({
xPos: fieldX,
textAlignment,
}))
.with('right', () => ({
xPos: fieldX - padding,
textAlignment,
}))
.exhaustive();
};
/**
* Break a long string into multiple lines so it fits within a given width,
* using natural word breaking similar to word processors.
*
* - Keeps words together when possible
* - Only breaks words when they're too long to fit on a line
* - Handles whitespace intelligently
*
* @param text - The text to break into lines
* @param maxWidth - The maximum width of each line in PX
* @param font - The PDF font object
* @param fontSize - The font size in points
* @returns Object containing the result string and line count
*/
function breakLongString(text: string, maxWidth: number, font: PDFFont, fontSize: number): string {
// Handle empty text
if (!text) {
return '';
}
const lines: string[] = [];
// Process each original line separately to preserve newlines
for (const paragraph of text.split('\n')) {
// If paragraph fits on one line or is empty, add it as-is
if (paragraph === '' || font.widthOfTextAtSize(paragraph, fontSize) <= maxWidth) {
lines.push(paragraph);
continue;
}
// Split paragraph into words
const words = paragraph.split(' ');
let currentLine = '';
for (const word of words) {
// Check if adding word to current line would exceed max width
const lineWithWord = currentLine.length === 0 ? word : `${currentLine} ${word}`;
if (font.widthOfTextAtSize(lineWithWord, fontSize) <= maxWidth) {
// Word fits, add it to current line
currentLine = lineWithWord;
} else {
// Word doesn't fit on current line
// First, save current line if it's not empty
if (currentLine.length > 0) {
lines.push(currentLine);
currentLine = '';
}
// Check if word fits on a line by itself
if (font.widthOfTextAtSize(word, fontSize) <= maxWidth) {
// Word fits on its own line
currentLine = word;
} else {
// Word is too long, need to break it character by character
let charLine = '';
// Process each character in the word
for (const char of word) {
const nextCharLine = charLine + char;
if (font.widthOfTextAtSize(nextCharLine, fontSize) <= maxWidth) {
// Character fits, add it
charLine = nextCharLine;
} else {
// Character doesn't fit, push current charLine and start a new one
lines.push(charLine);
charLine = char;
}
}
// Add any remaining characters as the current line
currentLine = charLine;
}
}
}
// Add the last line if not empty
if (currentLine.length > 0) {
lines.push(currentLine);
}
}
return lines.join('\n');
}

View File

@ -0,0 +1,395 @@
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
import fontkit from '@pdf-lib/fontkit';
import { FieldType } from '@prisma/client';
import type { PDFDocument } from 'pdf-lib';
import { RotationTypes, degrees, radiansToDegrees, rgb } from 'pdf-lib';
import { P, match } from 'ts-pattern';
import {
DEFAULT_HANDWRITING_FONT_SIZE,
DEFAULT_STANDARD_FONT_SIZE,
MIN_HANDWRITING_FONT_SIZE,
MIN_STANDARD_FONT_SIZE,
} from '@documenso/lib/constants/pdf';
import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import {
ZCheckboxFieldMeta,
ZDateFieldMeta,
ZEmailFieldMeta,
ZInitialsFieldMeta,
ZNameFieldMeta,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta';
export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
const [fontCaveat, fontNoto] = await Promise.all([
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/caveat.ttf`).then(async (res) => res.arrayBuffer()),
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) => res.arrayBuffer()),
]);
const isSignatureField = isSignatureFieldType(field.type);
const isDebugMode =
// eslint-disable-next-line turbo/no-undeclared-env-vars
process.env.DEBUG_PDF_INSERT === '1' || process.env.DEBUG_PDF_INSERT === 'true';
pdf.registerFontkit(fontkit);
const pages = pdf.getPages();
const minFontSize = isSignatureField ? MIN_HANDWRITING_FONT_SIZE : MIN_STANDARD_FONT_SIZE;
const maxFontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
const page = pages.at(field.page - 1);
if (!page) {
throw new Error(`Page ${field.page} does not exist`);
}
const pageRotation = page.getRotation();
let pageRotationInDegrees = match(pageRotation.type)
.with(RotationTypes.Degrees, () => pageRotation.angle)
.with(RotationTypes.Radians, () => radiansToDegrees(pageRotation.angle))
.exhaustive();
// Round to the closest multiple of 90 degrees.
pageRotationInDegrees = Math.round(pageRotationInDegrees / 90) * 90;
const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270;
let { width: pageWidth, height: pageHeight } = page.getSize();
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
// However when we load the PDF in the backend, the rotation is applied.
//
// To account for this, we swap the width and height for pages that are rotated by 90/270
// degrees. This is so we can calculate the virtual position the field was placed if it
// was correctly oriented in the frontend.
//
// Then when we insert the fields, we apply a transformation to the position of the field
// so it is rotated correctly.
if (isPageRotatedToLandscape) {
[pageWidth, pageHeight] = [pageHeight, pageWidth];
}
const fieldWidth = pageWidth * (Number(field.width) / 100);
const fieldHeight = pageHeight * (Number(field.height) / 100);
const fieldX = pageWidth * (Number(field.positionX) / 100);
const fieldY = pageHeight * (Number(field.positionY) / 100);
// Draw debug box if debug mode is enabled
if (isDebugMode) {
let debugX = fieldX;
let debugY = pageHeight - fieldY - fieldHeight; // Invert Y for PDF coordinates
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
debugX,
debugY,
pageRotationInDegrees,
);
debugX = adjustedPosition.xPos;
debugY = adjustedPosition.yPos;
}
page.drawRectangle({
x: debugX,
y: debugY,
width: fieldWidth,
height: fieldHeight,
borderColor: rgb(1, 0, 0), // Red
borderWidth: 1,
rotate: degrees(pageRotationInDegrees),
});
}
const font = await pdf.embedFont(
isSignatureField ? fontCaveat : fontNoto,
isSignatureField ? { features: { calt: false } } : undefined,
);
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
await pdf.embedFont(fontCaveat);
}
await match(field)
.with(
{
type: P.union(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE),
},
async (field) => {
if (field.signature?.signatureImageAsBase64) {
const image = await pdf.embedPng(field.signature?.signatureImageAsBase64 ?? '');
let imageWidth = image.width;
let imageHeight = image.height;
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
imageWidth = imageWidth * scalingFactor;
imageHeight = imageHeight * scalingFactor;
let imageX = fieldX + (fieldWidth - imageWidth) / 2;
let imageY = fieldY + (fieldHeight - imageHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system
imageY = pageHeight - imageY - imageHeight;
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
imageX,
imageY,
pageRotationInDegrees,
);
imageX = adjustedPosition.xPos;
imageY = adjustedPosition.yPos;
}
page.drawImage(image, {
x: imageX,
y: imageY,
width: imageWidth,
height: imageHeight,
rotate: degrees(pageRotationInDegrees),
});
} else {
const signatureText = field.signature?.typedSignature ?? '';
const longestLineInTextForWidth = signatureText
.split('\n')
.sort((a, b) => b.length - a.length)[0];
let fontSize = maxFontSize;
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
let textHeight = font.heightAtSize(fontSize);
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
textHeight = font.heightAtSize(fontSize);
let textX = fieldX + (fieldWidth - textWidth) / 2;
let textY = fieldY + (fieldHeight - textHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system
textY = pageHeight - textY - textHeight;
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
textX,
textY,
pageRotationInDegrees,
);
textX = adjustedPosition.xPos;
textY = adjustedPosition.yPos;
}
page.drawText(signatureText, {
x: textX,
y: textY,
size: fontSize,
font,
rotate: degrees(pageRotationInDegrees),
});
}
},
)
.with({ type: FieldType.CHECKBOX }, (field) => {
const meta = ZCheckboxFieldMeta.safeParse(field.fieldMeta);
if (!meta.success) {
console.error(meta.error);
throw new Error('Invalid checkbox field meta');
}
const values = meta.data.values?.map((item) => ({
...item,
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
}));
const selected: string[] = fromCheckboxValue(field.customText);
for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * 16;
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
if (selected.includes(item.value)) {
checkbox.check();
}
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + 16,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
checkbox.addToPage(page, {
x: fieldX,
y: pageHeight - (fieldY + offsetY),
height: 8,
width: 8,
});
}
})
.with({ type: FieldType.RADIO }, (field) => {
const meta = ZRadioFieldMeta.safeParse(field.fieldMeta);
if (!meta.success) {
console.error(meta.error);
throw new Error('Invalid radio field meta');
}
const values = meta?.data.values?.map((item) => ({
...item,
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
}));
const selected = field.customText.split(',');
for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * 16;
const radio = pdf.getForm().createRadioGroup(`radio.${field.secondaryId}.${index}`);
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + 16,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
radio.addOptionToPage(item.value, page, {
x: fieldX,
y: pageHeight - (fieldY + offsetY),
height: 8,
width: 8,
});
if (selected.includes(item.value)) {
radio.select(item.value);
}
}
})
.otherwise((field) => {
const fieldMetaParsers = {
[FieldType.TEXT]: ZTextFieldMeta,
[FieldType.NUMBER]: ZNumberFieldMeta,
[FieldType.DATE]: ZDateFieldMeta,
[FieldType.EMAIL]: ZEmailFieldMeta,
[FieldType.NAME]: ZNameFieldMeta,
[FieldType.INITIALS]: ZInitialsFieldMeta,
} as const;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const Parser = fieldMetaParsers[field.type as keyof typeof fieldMetaParsers];
const meta = Parser ? Parser.safeParse(field.fieldMeta) : null;
const customFontSize = meta?.success && meta.data.fontSize ? meta.data.fontSize : null;
const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'center';
const longestLineInTextForWidth = field.customText
.split('\n')
.sort((a, b) => b.length - a.length)[0];
let fontSize = customFontSize || maxFontSize;
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
const textHeight = font.heightAtSize(fontSize);
if (!customFontSize) {
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
}
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
// Add padding similar to web display (roughly 0.5rem equivalent in PDF units)
const padding = 8; // PDF points, roughly equivalent to 0.5rem
// Calculate X position based on text alignment with padding
let textX = fieldX + padding; // Left alignment starts after padding
if (textAlign === 'center') {
textX = fieldX + (fieldWidth - textWidth) / 2; // Center alignment ignores padding
} else if (textAlign === 'right') {
textX = fieldX + fieldWidth - textWidth - padding; // Right alignment respects right padding
}
let textY = fieldY + (fieldHeight - textHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system
textY = pageHeight - textY - textHeight;
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
textX,
textY,
pageRotationInDegrees,
);
textX = adjustedPosition.xPos;
textY = adjustedPosition.yPos;
}
page.drawText(field.customText, {
x: textX,
y: textY,
size: fontSize,
font,
rotate: degrees(pageRotationInDegrees),
});
});
return pdf;
};
const adjustPositionForRotation = (
pageWidth: number,
pageHeight: number,
xPos: number,
yPos: number,
pageRotationInDegrees: number,
) => {
if (pageRotationInDegrees === 270) {
xPos = pageWidth - xPos;
[xPos, yPos] = [yPos, xPos];
}
if (pageRotationInDegrees === 90) {
yPos = pageHeight - yPos;
[xPos, yPos] = [yPos, xPos];
}
// Invert all the positions since it's rotated by 180 degrees.
if (pageRotationInDegrees === 180) {
xPos = pageWidth - xPos;
yPos = pageHeight - yPos;
}
return {
xPos,
yPos,
};
};

View File

@ -77,6 +77,7 @@ export const createDocumentFromTemplateLegacy = async ({
title: template.title, title: template.title,
visibility: template.team?.teamGlobalSettings?.documentVisibility, visibility: template.team?.teamGlobalSettings?.documentVisibility,
documentDataId: documentData.id, documentDataId: documentData.id,
useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false,
recipients: { recipients: {
create: template.recipients.map((recipient) => ({ create: template.recipients.map((recipient) => ({
email: recipient.email, email: recipient.email,

View File

@ -384,6 +384,7 @@ export const createDocumentFromTemplate = async ({
globalActionAuth: templateAuthOptions.globalActionAuth, globalActionAuth: templateAuthOptions.globalActionAuth,
}), }),
visibility: template.visibility || template.team?.teamGlobalSettings?.documentVisibility, visibility: template.visibility || template.team?.teamGlobalSettings?.documentVisibility,
useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false,
documentMeta: { documentMeta: {
create: { create: {
subject: override?.subject || template.templateMeta?.subject, subject: override?.subject || template.templateMeta?.subject,

View File

@ -20,6 +20,7 @@ export type UpdateTemplateOptions = {
publicTitle?: string; publicTitle?: string;
publicDescription?: string; publicDescription?: string;
type?: Template['type']; type?: Template['type'];
useLegacyFieldInsertion?: boolean;
}; };
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>; meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
}; };
@ -102,6 +103,7 @@ export const updateTemplate = async ({
visibility: data?.visibility, visibility: data?.visibility,
publicDescription: data?.publicDescription, publicDescription: data?.publicDescription,
publicTitle: data?.publicTitle, publicTitle: data?.publicTitle,
useLegacyFieldInsertion: data?.useLegacyFieldInsertion,
authOptions, authOptions,
templateMeta: { templateMeta: {
upsert: { upsert: {

View File

@ -83,6 +83,7 @@ export const ZDocumentLiteSchema = DocumentSchema.pick({
deletedAt: true, deletedAt: true,
teamId: true, teamId: true,
templateId: true, templateId: true,
useLegacyFieldInsertion: true,
}); });
/** /**
@ -105,6 +106,7 @@ export const ZDocumentManySchema = DocumentSchema.pick({
deletedAt: true, deletedAt: true,
teamId: true, teamId: true,
templateId: true, templateId: true,
useLegacyFieldInsertion: true,
}).extend({ }).extend({
user: UserSchema.pick({ user: UserSchema.pick({
id: true, id: true,

View File

@ -83,6 +83,7 @@ export const ZTemplateLiteSchema = TemplateSchema.pick({
updatedAt: true, updatedAt: true,
publicTitle: true, publicTitle: true,
publicDescription: true, publicDescription: true,
useLegacyFieldInsertion: true,
}); });
/** /**
@ -102,6 +103,7 @@ export const ZTemplateManySchema = TemplateSchema.pick({
updatedAt: true, updatedAt: true,
publicTitle: true, publicTitle: true,
publicDescription: true, publicDescription: true,
useLegacyFieldInsertion: true,
}).extend({ }).extend({
team: TeamSchema.pick({ team: TeamSchema.pick({
id: true, id: true,

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Document" ADD COLUMN "useLegacyFieldInsertion" BOOLEAN NOT NULL DEFAULT true;
-- AlterTable
ALTER TABLE "Template" ADD COLUMN "useLegacyFieldInsertion" BOOLEAN NOT NULL DEFAULT true;

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Document" ALTER COLUMN "useLegacyFieldInsertion" SET DEFAULT false;
-- AlterTable
ALTER TABLE "Template" ALTER COLUMN "useLegacyFieldInsertion" SET DEFAULT false;

View File

@ -327,18 +327,21 @@ model Document {
fields Field[] fields Field[]
shareLinks DocumentShareLink[] shareLinks DocumentShareLink[]
documentDataId String documentDataId String
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
documentMeta DocumentMeta? documentMeta DocumentMeta?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
completedAt DateTime? completedAt DateTime?
deletedAt DateTime? deletedAt DateTime?
teamId Int? teamId Int?
team Team? @relation(fields: [teamId], references: [id])
templateId Int? templateId Int?
template Template? @relation(fields: [templateId], references: [id], onDelete: SetNull)
source DocumentSource source DocumentSource
useLegacyFieldInsertion Boolean @default(false)
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
team Team? @relation(fields: [teamId], references: [id])
template Template? @relation(fields: [templateId], references: [id], onDelete: SetNull)
auditLogs DocumentAuditLog[] auditLogs DocumentAuditLog[]
@@unique([documentDataId]) @@unique([documentDataId])
@ -706,6 +709,8 @@ model Template {
publicTitle String @default("") publicTitle String @default("")
publicDescription String @default("") publicDescription String @default("")
useLegacyFieldInsertion Boolean @default(false)
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade) templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

@ -107,6 +107,14 @@ module.exports = {
900: '#364772', 900: '#364772',
950: '#252d46', 950: '#252d46',
}, },
recipient: {
green: 'hsl(var(--recipient-green))',
blue: 'hsl(var(--recipient-blue))',
purple: 'hsl(var(--recipient-purple))',
orange: 'hsl(var(--recipient-orange))',
yellow: 'hsl(var(--recipient-yellow))',
pink: 'hsl(var(--recipient-pink))',
},
}, },
backgroundImage: { backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',

View File

@ -44,6 +44,7 @@ export const ZUpdateDocumentRequestSchema = z.object({
visibility: ZDocumentVisibilitySchema.optional(), visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullish(), globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullish(),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullish(), globalActionAuth: ZDocumentActionAuthTypesSchema.nullish(),
useLegacyFieldInsertion: z.boolean().optional(),
}) })
.optional(), .optional(),
meta: z meta: z

View File

@ -153,6 +153,7 @@ export const ZUpdateTemplateRequestSchema = z.object({
) )
.optional(), .optional(),
type: z.nativeEnum(TemplateType).optional(), type: z.nativeEnum(TemplateType).optional(),
useLegacyFieldInsertion: z.boolean().optional(),
}) })
.optional(), .optional(),
meta: z meta: z

View File

@ -0,0 +1,170 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, Field, Recipient, TemplateMeta } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import { Clock, EyeOffIcon } from 'lucide-react';
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 { getRecipientColorStyles } from '../../lib/recipient-colors';
import { FieldContent } from '../../primitives/document-flow/field-content';
export type DocumentReadOnlyFieldsProps = {
fields: DocumentField[];
documentMeta?: DocumentMeta | TemplateMeta;
showFieldStatus?: boolean;
/**
* Required if you want to show colors.
*
* Can't derive this from the fields because sometimes recipients don't have fields
* yet.
*/
recipientIds?: number[];
/**
* Whether to show the recipient tooltip.
*
* @default false
*/
showRecipientTooltip?: boolean;
/**
* Whether to color code the recipient fields.
*
* @default false
*/
showRecipientColors?: boolean;
};
export const mapFieldsWithRecipients = (
fields: Field[],
recipients: Recipient[],
): DocumentField[] => {
return fields.map((field) => {
const recipient = recipients.find((recipient) => recipient.id === field.recipientId) || {
id: field.recipientId,
name: 'Unknown',
email: 'Unknown',
signingStatus: SigningStatus.NOT_SIGNED,
};
return { ...field, recipient, signature: null };
});
};
export const DocumentReadOnlyFields = ({
documentMeta,
fields,
recipientIds = [],
showFieldStatus = true,
showRecipientTooltip = false,
showRecipientColors = false,
}: DocumentReadOnlyFieldsProps) => {
const { _ } = useLingui();
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}
color={
showRecipientColors
? getRecipientColorStyles(
Math.max(
recipientIds.findIndex((id) => id === field.recipientId),
0,
),
)
: undefined
}
>
{showRecipientTooltip && (
<div className="absolute -right-3 -top-3">
<PopoverHover
trigger={
<Avatar className="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,71 +1,18 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useState } from 'react';
import type { Field } from '@prisma/client'; import { type Field, FieldType } from '@prisma/client';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords'; import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import type { RecipientColorStyles } from '../../lib/recipient-colors';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import { Card, CardContent } from '../../primitives/card';
export type FieldRootContainerProps = {
field: Field;
children: React.ReactNode;
};
export type FieldContainerPortalProps = { export type FieldContainerPortalProps = {
field: Field; field: Field;
className?: string; className?: string;
children: React.ReactNode; 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({ export function FieldContainerPortal({
@ -76,13 +23,10 @@ export function FieldContainerPortal({
const coords = useFieldPageCoords(field); const coords = useFieldPageCoords(field);
const isCheckboxOrRadioField = field.type === 'CHECKBOX' || field.type === 'RADIO'; const isCheckboxOrRadioField = field.type === 'CHECKBOX' || field.type === 'RADIO';
const isFieldSigned = field.inserted;
const style = { const style = {
top: `${coords.y}px`, top: `${coords.y}px`,
left: `${coords.x}px`, left: `${coords.x}px`,
// height: `${coords.height}px`,
// width: `${coords.width}px`,
...(!isCheckboxOrRadioField && { ...(!isCheckboxOrRadioField && {
height: `${coords.height}px`, height: `${coords.height}px`,
width: `${coords.width}px`, width: `${coords.width}px`,
@ -97,7 +41,14 @@ export function FieldContainerPortal({
); );
} }
export function FieldRootContainer({ field, children, cardClassName }: FieldContainerPortalProps) { export type FieldRootContainerProps = {
field: Field;
color?: RecipientColorStyles;
children: React.ReactNode;
className?: string;
};
export function FieldRootContainer({ field, children, color, className }: FieldRootContainerProps) {
const [isValidating, setIsValidating] = useState(false); const [isValidating, setIsValidating] = useState(false);
const ref = React.useRef<HTMLDivElement>(null); const ref = React.useRef<HTMLDivElement>(null);
@ -121,33 +72,26 @@ 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],
);
const cardClassNames = useMemo(
() => getCardClassNames(field, parsedField, isValidating, isCheckboxOrRadio, cardClassName),
[field, parsedField, isValidating, isCheckboxOrRadio, cardClassName],
);
return ( return (
<FieldContainerPortal field={field}> <FieldContainerPortal field={field}>
<Card <div
id={`field-${field.id}`} id={`field-${field.id}`}
ref={ref} ref={ref}
data-field-type={field.type} data-field-type={field.type}
data-inserted={field.inserted ? 'true' : 'false'} data-inserted={field.inserted ? 'true' : 'false'}
className={cardClassNames} className={cn(
'field--FieldRootContainer field-card-container dark-mode-disabled group relative z-20 flex h-full w-full items-center rounded-[2px] bg-white/90 ring-2 ring-gray-200 transition-all',
color?.base,
{
'px-2': field.type !== FieldType.SIGNATURE && field.type !== FieldType.FREE_SIGNATURE,
'justify-center': !field.inserted,
'ring-orange-300': isValidating && isFieldUnsignedAndRequired(field),
},
className,
)}
> >
<CardContent className="text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2"> {children}
{children} </div>
</CardContent>
</Card>
</FieldContainerPortal> </FieldContainerPortal>
); );
} }

View File

@ -0,0 +1,95 @@
// !: We declare all of our classes here since TailwindCSS will remove any unused CSS classes,
// !: therefore doing this at runtime is not possible without whitelisting a set of classnames.
// !:
// !: This will later be improved as we move to a CSS variable approach and rotate the lightness
export type RecipientColorMap = Record<number, RecipientColorStyles>;
export type RecipientColorStyles = {
base: string;
fieldItem: string;
fieldItemInitials: string;
comboxBoxTrigger: string;
comboxBoxItem: string;
};
// !: values of the declared variable to do all the background, border and shadow styles.
export const RECIPIENT_COLOR_STYLES = {
green: {
base: 'ring-recipient-green hover:bg-recipient-green/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-green',
comboxBoxTrigger:
'ring-2 ring-recipient-green hover:bg-recipient-green/15 active:bg-recipient-green/15 shadow-[0_0_0_5px_hsl(var(--recipient-green)/10%),0_0_0_2px_hsl(var(--recipient-green)/60%),0_0_0_0.5px_hsl(var(--recipient-green))]',
comboxBoxItem: 'hover:bg-recipient-green/15 active:bg-recipient-green/15',
},
blue: {
base: 'ring-recipient-blue hover:bg-recipient-blue/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-blue',
comboxBoxTrigger:
'ring-2 ring-recipient-blue hover:bg-recipient-blue/15 active:bg-recipient-blue/15 shadow-[0_0_0_5px_hsl(var(--recipient-blue)/10%),0_0_0_2px_hsl(var(--recipient-blue)/60%),0_0_0_0.5px_hsl(var(--recipient-blue))]',
comboxBoxItem: 'ring-recipient-blue hover:bg-recipient-blue/15 active:bg-recipient-blue/15',
},
purple: {
base: 'ring-recipient-purple hover:bg-recipient-purple/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-purple',
comboxBoxTrigger:
'ring-2 ring-recipient-purple hover:bg-recipient-purple/15 active:bg-recipient-purple/15 shadow-[0_0_0_5px_hsl(var(--recipient-purple)/10%),0_0_0_2px_hsl(var(--recipient-purple)/60%),0_0_0_0.5px_hsl(var(--recipient-purple))]',
comboxBoxItem: 'hover:bg-recipient-purple/15 active:bg-recipient-purple/15',
},
orange: {
base: 'ring-recipient-orange hover:bg-recipient-orange/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-orange',
comboxBoxTrigger:
'ring-2 ring-recipient-orange hover:bg-recipient-orange/15 active:bg-recipient-orange/15 shadow-[0_0_0_5px_hsl(var(--recipient-orange)/10%),0_0_0_2px_hsl(var(--recipient-orange)/60%),0_0_0_0.5px_hsl(var(--recipient-orange))]',
comboxBoxItem: 'hover:bg-recipient-orange/15 active:bg-recipient-orange/15',
},
yellow: {
base: 'ring-recipient-yellow hover:bg-recipient-yellow/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-yellow',
comboxBoxTrigger:
'ring-2 ring-recipient-yellow hover:bg-recipient-yellow/15 active:bg-recipient-yellow/15 shadow-[0_0_0_5px_hsl(var(--recipient-yellow)/10%),0_0_0_2px_hsl(var(--recipient-yellow)/60%),0_0_0_0.5px_hsl(var(--recipient-yellow))]',
comboxBoxItem: 'hover:bg-recipient-yellow/15 active:bg-recipient-yellow/15',
},
pink: {
base: 'ring-recipient-pink hover:bg-recipient-pink/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-pink',
comboxBoxTrigger:
'ring-2 ring-recipient-pink hover:bg-recipient-pink/15 active:bg-recipient-pink/15 shadow-[0_0_0_5px_hsl(var(--recipient-pink)/10%),0_0_0_2px_hsl(var(--recipient-pink)/60%),0_0_0_0.5px_hsl(var(--recipient-pink',
comboxBoxItem: 'hover:bg-recipient-pink/15 active:bg-recipient-pink/15',
},
} satisfies Record<string, RecipientColorStyles>;
export type CombinedStylesKey = keyof typeof RECIPIENT_COLOR_STYLES;
export const AVAILABLE_RECIPIENT_COLORS = [
'green',
'blue',
'purple',
'orange',
'yellow',
'pink',
] satisfies CombinedStylesKey[];
export const useRecipientColors = (index: number) => {
const key = AVAILABLE_RECIPIENT_COLORS[index % AVAILABLE_RECIPIENT_COLORS.length];
return RECIPIENT_COLOR_STYLES[key];
};
export const getRecipientColorStyles = (index: number) => {
// Disabling the rule since the hook doesn't do anything special and can
// be used universally.
// eslint-disable-next-line react-hooks/rules-of-hooks
return useRecipientColors(index);
};

View File

@ -1,108 +0,0 @@
// !: We declare all of our classes here since TailwindCSS will remove any unused CSS classes,
// !: therefore doing this at runtime is not possible without whitelisting a set of classnames.
// !:
// !: This will later be improved as we move to a CSS variable approach and rotate the lightness
// !: values of the declared variable to do all the background, border and shadow styles.
export const SIGNER_COLOR_STYLES = {
green: {
default: {
background: 'bg-[hsl(var(--signer-green))]',
base: 'rounded-lg shadow-[0_0_0_5px_hsl(var(--signer-green)/10%),0_0_0_2px_hsl(var(--signer-green)/60%),0_0_0_0.5px_hsl(var(--signer-green))]',
fieldItem:
'group/field-item p-2 border-none ring-none hover:bg-gradient-to-r hover:from-[hsl(var(--signer-green))]/10 hover:to-[hsl(var(--signer-green))]/10',
fieldItemInitials:
'opacity-0 transition duration-200 group-hover/field-item:opacity-100 group-hover/field-item:bg-[hsl(var(--signer-green))]',
comboxBoxItem:
'hover:bg-[hsl(var(--signer-green)/15%)] active:bg-[hsl(var(--signer-green)/15%)]',
},
},
blue: {
default: {
background: 'bg-[hsl(var(--signer-blue))]',
base: 'rounded-lg shadow-[0_0_0_5px_hsl(var(--signer-blue)/10%),0_0_0_2px_hsl(var(--signer-blue)/60%),0_0_0_0.5px_hsl(var(--signer-blue))]',
fieldItem:
'group/field-item p-2 border-none ring-none hover:bg-gradient-to-r hover:from-[hsl(var(--signer-blue))]/10 hover:to-[hsl(var(--signer-blue))]/10',
fieldItemInitials:
'opacity-0 transition duration-200 group-hover/field-item:opacity-100 group-hover/field-item:bg-[hsl(var(--signer-blue))]',
comboxBoxItem:
'hover:bg-[hsl(var(--signer-blue)/15%)] active:bg-[hsl(var(--signer-blue)/15%)]',
},
},
purple: {
default: {
background: 'bg-[hsl(var(--signer-purple))]',
base: 'rounded-lg shadow-[0_0_0_5px_hsl(var(--signer-purple)/10%),0_0_0_2px_hsl(var(--signer-purple)/60%),0_0_0_0.5px_hsl(var(--signer-purple))]',
fieldItem:
'group/field-item p-2 border-none ring-none hover:bg-gradient-to-r hover:from-[hsl(var(--signer-purple))]/10 hover:to-[hsl(var(--signer-purple))]/10',
fieldItemInitials:
'opacity-0 transition duration-200 group-hover/field-item:opacity-100 group-hover/field-item:bg-[hsl(var(--signer-purple))]',
comboxBoxItem:
'hover:bg-[hsl(var(--signer-purple)/15%)] active:bg-[hsl(var(--signer-purple)/15%)]',
},
},
orange: {
default: {
background: 'bg-[hsl(var(--signer-orange))]',
base: 'rounded-lg shadow-[0_0_0_5px_hsl(var(--signer-orange)/10%),0_0_0_2px_hsl(var(--signer-orange)/60%),0_0_0_0.5px_hsl(var(--signer-orange))]',
fieldItem:
'group/field-item p-2 border-none ring-none hover:bg-gradient-to-r hover:from-[hsl(var(--signer-orange))]/10 hover:to-[hsl(var(--signer-orange))]/10',
fieldItemInitials:
'opacity-0 transition duration-200 group-hover/field-item:opacity-100 group-hover/field-item:bg-[hsl(var(--signer-orange))]',
comboxBoxItem:
'hover:bg-[hsl(var(--signer-orange)/15%)] active:bg-[hsl(var(--signer-orange)/15%)]',
},
},
yellow: {
default: {
background: 'bg-[hsl(var(--signer-yellow))]',
base: 'rounded-lg shadow-[0_0_0_5px_hsl(var(--signer-yellow)/10%),0_0_0_2px_hsl(var(--signer-yellow)/60%),0_0_0_0.5px_hsl(var(--signer-yellow))]',
fieldItem:
'group/field-item p-2 border-none ring-none hover:bg-gradient-to-r hover:from-[hsl(var(--signer-yellow))]/10 hover:to-[hsl(var(--signer-yellow))]/10',
fieldItemInitials:
'opacity-0 transition duration-200 group-hover/field-item:opacity-100 group-hover/field-item:bg-[hsl(var(--signer-yellow))]',
comboxBoxItem:
'hover:bg-[hsl(var(--signer-yellow)/15%)] active:bg-[hsl(var(--signer-yellow)/15%)]',
},
},
pink: {
default: {
background: 'bg-[hsl(var(--signer-pink))]',
base: 'rounded-lg shadow-[0_0_0_5px_hsl(var(--signer-pink)/10%),0_0_0_2px_hsl(var(--signer-pink)/60%),0_0_0_0.5px_hsl(var(--signer-pink))]',
fieldItem:
'group/field-item p-2 border-none ring-none hover:bg-gradient-to-r hover:from-[hsl(var(--signer-pink))]/10 hover:to-[hsl(var(--signer-pink))]/10',
fieldItemInitials:
'opacity-0 transition duration-200 group-hover/field-item:opacity-100 group-hover/field-item:bg-[hsl(var(--signer-pink))]',
comboxBoxItem:
'hover:bg-[hsl(var(--signer-pink)/15%)] active:bg-[hsl(var(--signer-pink)/15%)]',
},
},
};
export type CombinedStylesKey = keyof typeof SIGNER_COLOR_STYLES;
export const AVAILABLE_SIGNER_COLORS = [
'green',
'blue',
'purple',
'orange',
'yellow',
'pink',
] satisfies CombinedStylesKey[];
export const useSignerColors = (index: number) => {
const key = AVAILABLE_SIGNER_COLORS[index % AVAILABLE_SIGNER_COLORS.length];
return SIGNER_COLOR_STYLES[key];
};
export const getSignerColorStyles = (index: number) => {
// Disabling the rule since the hook doesn't do anything special and can
// be used universally.
// eslint-disable-next-line react-hooks/rules-of-hooks
return useSignerColors(index);
};

View File

@ -3,9 +3,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { Prisma } from '@prisma/client';
import type { Field, Recipient } from '@prisma/client'; import type { Field, Recipient } from '@prisma/client';
import { FieldType, RecipientRole, SendStatus } from '@prisma/client'; import { FieldType, Prisma, RecipientRole, SendStatus } from '@prisma/client';
import { import {
CalendarDays, CalendarDays,
CheckSquare, CheckSquare,
@ -38,7 +37,7 @@ import {
} from '@documenso/lib/utils/recipients'; } from '@documenso/lib/utils/recipients';
import { FieldToolTip } from '../../components/field/field-tooltip'; import { FieldToolTip } from '../../components/field/field-tooltip';
import { useSignerColors } from '../../lib/signer-colors'; import { useRecipientColors } from '../../lib/recipient-colors';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import { Alert, AlertDescription } from '../alert'; import { Alert, AlertDescription } from '../alert';
import { Card, CardContent } from '../card'; import { Card, CardContent } from '../card';
@ -175,9 +174,10 @@ export const AddFieldsFormPartial = ({
null, null,
); );
const selectedSignerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id); const selectedSignerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id);
const selectedSignerStyles = useSignerColors( const selectedSignerStyles = useRecipientColors(
selectedSignerIndex === -1 ? 0 : selectedSignerIndex, selectedSignerIndex === -1 ? 0 : selectedSignerIndex,
); );
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const filterFieldsWithEmptyValues = (fields: typeof localFields, fieldType: string) => const filterFieldsWithEmptyValues = (fields: typeof localFields, fieldType: string) =>
@ -593,13 +593,12 @@ export const AddFieldsFormPartial = ({
{selectedField && ( {selectedField && (
<div <div
className={cn( className={cn(
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200 [container-type:size]', 'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
selectedSignerStyles.default.base, selectedSignerStyles?.base,
{ {
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds, '-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
'dark:text-black/60': isFieldWithinBounds, 'dark:text-black/60': isFieldWithinBounds,
}, },
// selectedField === FieldType.SIGNATURE && fontCaveat.className,
)} )}
style={{ style={{
top: coords.y, top: coords.y,
@ -646,7 +645,6 @@ export const AddFieldsFormPartial = ({
setCurrentField(field); setCurrentField(field);
handleAdvancedSettings(); handleAdvancedSettings();
}} }}
hideRecipients={hideRecipients}
hasErrors={!!hasFieldError} hasErrors={!!hasFieldError}
active={activeFieldId === field.formId} active={activeFieldId === field.formId}
onFieldActivate={() => setActiveFieldId(field.formId)} onFieldActivate={() => setActiveFieldId(field.formId)}
@ -677,7 +675,6 @@ export const AddFieldsFormPartial = ({
<Card <Card
className={cn( className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50', 'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)} )}
> >
<CardContent className="flex flex-col items-center justify-center px-6 py-4"> <CardContent className="flex flex-col items-center justify-center px-6 py-4">
@ -702,7 +699,6 @@ export const AddFieldsFormPartial = ({
<Card <Card
className={cn( className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50', 'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)} )}
> >
<CardContent className="flex flex-col items-center justify-center px-6 py-4"> <CardContent className="flex flex-col items-center justify-center px-6 py-4">
@ -728,7 +724,6 @@ export const AddFieldsFormPartial = ({
<Card <Card
className={cn( className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50', 'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)} )}
> >
<CardContent className="flex flex-col items-center justify-center px-6 py-4"> <CardContent className="flex flex-col items-center justify-center px-6 py-4">
@ -754,7 +749,6 @@ export const AddFieldsFormPartial = ({
<Card <Card
className={cn( className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50', 'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)} )}
> >
<CardContent className="p-4"> <CardContent className="p-4">
@ -780,7 +774,6 @@ export const AddFieldsFormPartial = ({
<Card <Card
className={cn( className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50', 'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)} )}
> >
<CardContent className="p-4"> <CardContent className="p-4">
@ -806,7 +799,6 @@ export const AddFieldsFormPartial = ({
<Card <Card
className={cn( className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50', 'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)} )}
> >
<CardContent className="p-4"> <CardContent className="p-4">
@ -832,7 +824,6 @@ export const AddFieldsFormPartial = ({
<Card <Card
className={cn( className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50', 'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)} )}
> >
<CardContent className="p-4"> <CardContent className="p-4">
@ -858,7 +849,6 @@ export const AddFieldsFormPartial = ({
<Card <Card
className={cn( className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50', 'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)} )}
> >
<CardContent className="p-4"> <CardContent className="p-4">
@ -884,7 +874,6 @@ export const AddFieldsFormPartial = ({
<Card <Card
className={cn( className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50', 'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)} )}
> >
<CardContent className="p-4"> <CardContent className="p-4">
@ -911,7 +900,6 @@ export const AddFieldsFormPartial = ({
<Card <Card
className={cn( className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50', 'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)} )}
> >
<CardContent className="p-4"> <CardContent className="p-4">

View File

@ -24,6 +24,10 @@ import {
DocumentGlobalAuthActionSelect, DocumentGlobalAuthActionSelect,
DocumentGlobalAuthActionTooltip, DocumentGlobalAuthActionTooltip,
} from '@documenso/ui/components/document/document-global-auth-action-select'; } from '@documenso/ui/components/document/document-global-auth-action-select';
import {
DocumentReadOnlyFields,
mapFieldsWithRecipients,
} from '@documenso/ui/components/document/document-read-only-fields';
import { import {
DocumentVisibilitySelect, DocumentVisibilitySelect,
DocumentVisibilityTooltip, DocumentVisibilityTooltip,
@ -59,7 +63,6 @@ import {
DocumentFlowFormContainerHeader, DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep, DocumentFlowFormContainerStep,
} from './document-flow-root'; } from './document-flow-root';
import { ShowFieldItem } from './show-field-item';
import type { DocumentFlowStep } from './types'; import type { DocumentFlowStep } from './types';
export type AddSettingsFormProps = { export type AddSettingsFormProps = {
@ -154,10 +157,13 @@ export const AddSettingsFormPartial = ({
/> />
<DocumentFlowFormContainerContent> <DocumentFlowFormContainerContent>
{isDocumentPdfLoaded && {isDocumentPdfLoaded && (
fields.map((field, index) => ( <DocumentReadOnlyFields
<ShowFieldItem key={index} field={field} recipients={recipients} /> showRecipientColors={true}
))} recipientIds={recipients.map((recipient) => recipient.id)}
fields={mapFieldsWithRecipients(fields, recipients)}
/>
)}
<Form {...form}> <Form {...form}>
<fieldset <fieldset

View File

@ -23,6 +23,10 @@ import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/re
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select'; import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import {
DocumentReadOnlyFields,
mapFieldsWithRecipients,
} from '../../components/document/document-read-only-fields';
import { Button } from '../button'; import { Button } from '../button';
import { Checkbox } from '../checkbox'; import { Checkbox } from '../checkbox';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
@ -40,7 +44,6 @@ import {
DocumentFlowFormContainerHeader, DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep, DocumentFlowFormContainerStep,
} from './document-flow-root'; } from './document-flow-root';
import { ShowFieldItem } from './show-field-item';
import { SigningOrderConfirmation } from './signing-order-confirmation'; import { SigningOrderConfirmation } from './signing-order-confirmation';
import type { DocumentFlowStep } from './types'; import type { DocumentFlowStep } from './types';
@ -368,10 +371,13 @@ export const AddSignersFormPartial = ({
description={documentFlow.description} description={documentFlow.description}
/> />
<DocumentFlowFormContainerContent> <DocumentFlowFormContainerContent>
{isDocumentPdfLoaded && {isDocumentPdfLoaded && (
fields.map((field, index) => ( <DocumentReadOnlyFields
<ShowFieldItem key={index} field={field} recipients={recipients} /> showRecipientColors={true}
))} recipientIds={recipients.map((recipient) => recipient.id)}
fields={mapFieldsWithRecipients(fields, recipients)}
/>
)}
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}> <AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}> <Form {...form}>

View File

@ -16,6 +16,10 @@ import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { CopyTextButton } from '../../components/common/copy-text-button'; import { CopyTextButton } from '../../components/common/copy-text-button';
import { DocumentEmailCheckboxes } from '../../components/document/document-email-checkboxes'; import { DocumentEmailCheckboxes } from '../../components/document/document-email-checkboxes';
import {
DocumentReadOnlyFields,
mapFieldsWithRecipients,
} from '../../components/document/document-read-only-fields';
import { AvatarWithText } from '../avatar'; import { AvatarWithText } from '../avatar';
import { FormErrorMessage } from '../form/form-error-message'; import { FormErrorMessage } from '../form/form-error-message';
import { Input } from '../input'; import { Input } from '../input';
@ -31,7 +35,6 @@ import {
DocumentFlowFormContainerHeader, DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep, DocumentFlowFormContainerStep,
} from './document-flow-root'; } from './document-flow-root';
import { ShowFieldItem } from './show-field-item';
import type { DocumentFlowStep } from './types'; import type { DocumentFlowStep } from './types';
export type AddSubjectFormProps = { export type AddSubjectFormProps = {
@ -103,10 +106,13 @@ export const AddSubjectFormPartial = ({
/> />
<DocumentFlowFormContainerContent> <DocumentFlowFormContainerContent>
<div className="flex flex-col"> <div className="flex flex-col">
{isDocumentPdfLoaded && {isDocumentPdfLoaded && (
fields.map((field, index) => ( <DocumentReadOnlyFields
<ShowFieldItem key={index} field={field} recipients={recipients} /> showRecipientColors={true}
))} recipientIds={recipients.map((recipient) => recipient.id)}
fields={mapFieldsWithRecipients(fields, recipients)}
/>
)}
<Tabs <Tabs
onValueChange={(value) => onValueChange={(value) =>

View File

@ -1,46 +0,0 @@
import { ZCheckboxFieldMeta } from '@documenso/lib/types/field-meta';
import type { TCheckboxFieldMeta } from '@documenso/lib/types/field-meta';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { Label } from '@documenso/ui/primitives/label';
import { FieldIcon } from '../field-icon';
import type { TDocumentFlowFormSchema } from '../types';
type Field = TDocumentFlowFormSchema['fields'][0];
export type CheckboxFieldProps = {
field: Field;
};
export const CheckboxField = ({ field }: CheckboxFieldProps) => {
let parsedFieldMeta: TCheckboxFieldMeta | undefined = undefined;
if (field.fieldMeta) {
parsedFieldMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
}
if (parsedFieldMeta && (!parsedFieldMeta.values || parsedFieldMeta.values.length === 0)) {
return <FieldIcon fieldMeta={field.fieldMeta} type={field.type} />;
}
return (
<div className="flex flex-col gap-y-1">
{!parsedFieldMeta?.values ? (
<FieldIcon fieldMeta={field.fieldMeta} type={field.type} />
) : (
parsedFieldMeta.values.map((item: { value: string; checked: boolean }, index: number) => (
<div key={index} className="flex items-center gap-x-1.5">
<Checkbox
className="dark:border-field-border h-3 w-3 bg-white"
id={`checkbox-${index}`}
checked={item.checked}
/>
<Label htmlFor={`checkbox-${index}`} className="text-xs font-normal text-black">
{item.value}
</Label>
</div>
))
)}
</div>
);
};

View File

@ -1,49 +0,0 @@
import { ZRadioFieldMeta } from '@documenso/lib/types/field-meta';
import type { TRadioFieldMeta } from '@documenso/lib/types/field-meta';
import { Label } from '@documenso/ui/primitives/label';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { FieldIcon } from '../field-icon';
import type { TDocumentFlowFormSchema } from '../types';
type Field = TDocumentFlowFormSchema['fields'][0];
export type RadioFieldProps = {
field: Field;
};
export const RadioField = ({ field }: RadioFieldProps) => {
let parsedFieldMeta: TRadioFieldMeta | undefined = undefined;
if (field.fieldMeta) {
parsedFieldMeta = ZRadioFieldMeta.parse(field.fieldMeta);
}
if (parsedFieldMeta && (!parsedFieldMeta.values || parsedFieldMeta.values.length === 0)) {
return <FieldIcon fieldMeta={field.fieldMeta} type={field.type} />;
}
return (
<div className="flex flex-col gap-y-2">
{!parsedFieldMeta?.values ? (
<FieldIcon fieldMeta={field.fieldMeta} type={field.type} />
) : (
<RadioGroup className="gap-y-1">
{parsedFieldMeta.values?.map((item, index) => (
<div key={index} className="flex items-center gap-x-1.5">
<RadioGroupItem
className="dark:border-field-border pointer-events-none h-3 w-3"
value={item.value}
id={`option-${index}`}
checked={item.checked}
/>
<Label htmlFor={`option-${index}`} className="text-xs font-normal text-black">
{item.value}
</Label>
</div>
))}
</RadioGroup>
)}
</div>
);
};

View File

@ -0,0 +1,188 @@
import { useLingui } from '@lingui/react';
import type { DocumentMeta, Signature, TemplateMeta } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { ChevronDown } from 'lucide-react';
import {
DEFAULT_DOCUMENT_DATE_FORMAT,
convertToLocalSystemFormat,
} from '@documenso/lib/constants/date-formats';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
import { cn } from '../../lib/utils';
import { Checkbox } from '../checkbox';
import { Label } from '../label';
import { RadioGroup, RadioGroupItem } from '../radio-group';
import { FRIENDLY_FIELD_TYPE } from './types';
type FieldIconProps = {
/**
* Loose field type since this is used for partial fields.
*/
field: {
inserted?: boolean;
customText?: string;
type: FieldType;
fieldMeta?: TFieldMetaSchema | null;
signature?: Signature | null;
};
documentMeta?: DocumentMeta | TemplateMeta;
};
/**
* Renders the content inside field containers prior to sealing.
*/
export const FieldContent = ({ field, documentMeta }: FieldIconProps) => {
const { _ } = useLingui();
const { type, fieldMeta } = field;
// Only render checkbox if values exist, otherwise render the empty checkbox field content.
if (
field.type === FieldType.CHECKBOX &&
field.fieldMeta?.type === 'checkbox' &&
field.fieldMeta.values &&
field.fieldMeta.values.length > 0
) {
let checkedValues: string[] = [];
try {
checkedValues = fromCheckboxValue(field.customText ?? '');
} catch (err) {
// Do nothing.
console.error(err);
}
return (
<div className="flex flex-col gap-y-1 py-0.5">
{field.fieldMeta.values.map((item, index) => (
<div key={index} className="flex items-center">
<Checkbox
className="h-3 w-3"
id={`checkbox-${index}`}
checked={checkedValues.includes(
item.value === '' ? `empty-value-${index + 1}` : item.value, // I got no idea...
)}
/>
{item.value && (
<Label
htmlFor={`checkbox-${index}`}
className="text-foreground ml-1.5 text-xs font-normal"
>
{item.value}
</Label>
)}
</div>
))}
</div>
);
}
// Only render radio if values exist, otherwise render the empty radio field content.
if (
field.type === FieldType.RADIO &&
field.fieldMeta?.type === 'radio' &&
field.fieldMeta.values &&
field.fieldMeta.values.length > 0
) {
return (
<div className="flex flex-col gap-y-2 py-0.5">
<RadioGroup className="gap-y-1">
{field.fieldMeta.values.map((item, index) => (
<div key={index} className="flex items-center">
<RadioGroupItem
className="pointer-events-none h-3 w-3"
value={item.value}
id={`option-${index}`}
checked={item.value === field.customText}
/>
{item.value && (
<Label
htmlFor={`option-${index}`}
className="text-foreground ml-1.5 text-xs font-normal"
>
{item.value}
</Label>
)}
</div>
))}
</RadioGroup>
</div>
);
}
if (
field.type === FieldType.DROPDOWN &&
field.fieldMeta?.type === 'dropdown' &&
!field.inserted
) {
return (
<div className="text-field-card-foreground flex flex-row items-center py-0.5 text-[clamp(0.07rem,25cqw,0.825rem)] text-sm">
<p>Select</p>
<ChevronDown className="h-4 w-4" />
</div>
);
}
if (
field.type === FieldType.SIGNATURE &&
field.signature?.signatureImageAsBase64 &&
field.inserted
) {
return (
<img
src={field.signature.signatureImageAsBase64}
alt="Signature"
className="h-full w-full object-contain"
/>
);
}
let textToDisplay = fieldMeta?.label || _(FRIENDLY_FIELD_TYPE[type]) || '';
const isSignatureField =
field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE;
// Trim default labels.
if (textToDisplay.length > 20) {
textToDisplay = textToDisplay.substring(0, 20) + '...';
}
if (field.inserted) {
if (field.customText) {
textToDisplay = field.customText;
}
if (field.type === FieldType.DATE) {
textToDisplay = convertToLocalSystemFormat(
field.customText ?? '',
documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
);
}
if (isSignatureField && field.signature?.typedSignature) {
textToDisplay = field.signature.typedSignature;
}
}
const textAlign = fieldMeta && 'textAlign' in fieldMeta ? fieldMeta.textAlign : 'left';
return (
<div
className={cn(
'text-field-card-foreground flex h-full w-full items-center justify-center gap-x-1.5 overflow-clip whitespace-nowrap text-center text-[clamp(0.07rem,25cqw,0.825rem)]',
{
// Using justify instead of align because we also vertically center the text.
'justify-start': field.inserted && !isSignatureField && textAlign === 'left',
'justify-end': field.inserted && !isSignatureField && textAlign === 'right',
'font-signature text-[clamp(0.07rem,25cqw,1.125rem)]': isSignatureField,
},
)}
>
{textToDisplay}
</div>
);
};

View File

@ -1,72 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { FieldType } from '@prisma/client';
import {
CalendarDays,
CheckSquare,
ChevronDown,
Contact,
Disc,
Hash,
Mail,
Type,
User,
} from 'lucide-react';
import type { TFieldMetaSchema as FieldMetaType } from '@documenso/lib/types/field-meta';
import { cn } from '../../lib/utils';
type FieldIconProps = {
fieldMeta: FieldMetaType;
type: FieldType;
};
const fieldIcons = {
[FieldType.INITIALS]: { icon: Contact, label: 'Initials' },
[FieldType.EMAIL]: { icon: Mail, label: 'Email' },
[FieldType.NAME]: { icon: User, label: 'Name' },
[FieldType.DATE]: { icon: CalendarDays, label: 'Date' },
[FieldType.TEXT]: { icon: Type, label: 'Text' },
[FieldType.NUMBER]: { icon: Hash, label: 'Number' },
[FieldType.RADIO]: { icon: Disc, label: 'Radio' },
[FieldType.CHECKBOX]: { icon: CheckSquare, label: 'Checkbox' },
[FieldType.DROPDOWN]: { icon: ChevronDown, label: 'Select' },
};
export const FieldIcon = ({ fieldMeta, type }: FieldIconProps) => {
if (type === 'SIGNATURE' || type === 'FREE_SIGNATURE') {
return (
<div
className={cn(
'text-field-card-foreground font-signature flex items-center justify-center gap-x-1 text-[clamp(0.575rem,25cqw,1.2rem)]',
)}
>
<Trans>Signature</Trans>
</div>
);
} else {
const Icon = fieldIcons[type]?.icon;
let label;
if (fieldMeta && (type === 'TEXT' || type === 'NUMBER')) {
if (type === 'TEXT' && 'text' in fieldMeta && fieldMeta.text && !fieldMeta.label) {
label =
fieldMeta.text.length > 20 ? fieldMeta.text.substring(0, 20) + '...' : fieldMeta.text;
} else if (fieldMeta.label) {
label =
fieldMeta.label.length > 20 ? fieldMeta.label.substring(0, 20) + '...' : fieldMeta.label;
} else {
label = fieldIcons[type]?.label;
}
} else {
label = fieldIcons[type]?.label;
}
return (
<div className="text-field-card-foreground flex items-center justify-center gap-x-1.5 text-[clamp(0.425rem,25cqw,0.825rem)]">
<Icon className="h-[clamp(0.625rem,20cqw,0.925rem)] w-[clamp(0.625rem,20cqw,0.925rem)]" />{' '}
{label}
</div>
);
}
};

View File

@ -229,11 +229,19 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
return ( return (
<div ref={ref} className="flex h-full flex-col"> <div ref={ref} className="flex h-full flex-col">
<DocumentFlowFormContainerHeader title={title} description={description} /> <DocumentFlowFormContainerHeader title={title} description={description} />
<DocumentFlowFormContainerContent> <DocumentFlowFormContainerContent>
{isDocumentPdfLoaded && {isDocumentPdfLoaded &&
fields.map((field, index) => ( fields.map((localField, index) => (
<span key={index} className="opacity-75 active:pointer-events-none"> <span key={index} className="opacity-75 active:pointer-events-none">
<FieldItem key={index} field={field} disabled={true} /> <FieldItem
key={index}
field={localField}
disabled={true}
fieldClassName={
localField.formId === field.formId ? 'ring-red-400' : 'ring-neutral-200'
}
/>
</span> </span>
))} ))}

View File

@ -4,23 +4,21 @@ import { FieldType } from '@prisma/client';
import { CopyPlus, Settings2, Trash } from 'lucide-react'; import { CopyPlus, Settings2, Trash } from 'lucide-react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Rnd } from 'react-rnd'; import { Rnd } from 'react-rnd';
import { match } from 'ts-pattern';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta'; import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '@documenso/lib/types/field-meta'; import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '@documenso/lib/types/field-meta';
import { useSignerColors } from '../../lib/signer-colors'; import { useRecipientColors } from '../../lib/recipient-colors';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import { CheckboxField } from './advanced-fields/checkbox'; import { FieldContent } from './field-content';
import { RadioField } from './advanced-fields/radio';
import { FieldIcon } from './field-icon';
import type { TDocumentFlowFormSchema } from './types'; import type { TDocumentFlowFormSchema } from './types';
type Field = TDocumentFlowFormSchema['fields'][0]; type Field = TDocumentFlowFormSchema['fields'][0];
export type FieldItemProps = { export type FieldItemProps = {
field: Field; field: Field;
fieldClassName?: string;
passive?: boolean; passive?: boolean;
disabled?: boolean; disabled?: boolean;
minHeight?: number; minHeight?: number;
@ -35,14 +33,17 @@ export type FieldItemProps = {
onFocus?: () => void; onFocus?: () => void;
onBlur?: () => void; onBlur?: () => void;
recipientIndex?: number; recipientIndex?: number;
hideRecipients?: boolean;
hasErrors?: boolean; hasErrors?: boolean;
active?: boolean; active?: boolean;
onFieldActivate?: () => void; onFieldActivate?: () => void;
onFieldDeactivate?: () => void; onFieldDeactivate?: () => void;
}; };
/**
* The item when editing fields??
*/
export const FieldItem = ({ export const FieldItem = ({
fieldClassName,
field, field,
passive, passive,
disabled, disabled,
@ -58,7 +59,6 @@ export const FieldItem = ({
onBlur, onBlur,
onAdvancedSettings, onAdvancedSettings,
recipientIndex = 0, recipientIndex = 0,
hideRecipients = false,
hasErrors, hasErrors,
active, active,
onFieldActivate, onFieldActivate,
@ -73,7 +73,7 @@ export const FieldItem = ({
const [settingsActive, setSettingsActive] = useState(false); const [settingsActive, setSettingsActive] = useState(false);
const $el = useRef(null); const $el = useRef(null);
const signerStyles = useSignerColors(recipientIndex); const signerStyles = useRecipientColors(recipientIndex);
const advancedField = [ const advancedField = [
'NUMBER', 'NUMBER',
@ -209,7 +209,7 @@ export const FieldItem = ({
return createPortal( return createPortal(
<Rnd <Rnd
key={coords.pageX + coords.pageY + coords.pageHeight + coords.pageWidth} key={coords.pageX + coords.pageY + coords.pageHeight + coords.pageWidth}
className={cn('group', { className={cn('dark-mode-disabled group', {
'pointer-events-none': passive, 'pointer-events-none': passive,
'pointer-events-none cursor-not-allowed opacity-75': disabled, 'pointer-events-none cursor-not-allowed opacity-75': disabled,
'z-50': active && !disabled, 'z-50': active && !disabled,
@ -260,11 +260,12 @@ export const FieldItem = ({
<div <div
className={cn( className={cn(
'relative flex h-full w-full items-center justify-center bg-white', 'group/field-item relative flex h-full w-full items-center justify-center rounded-[2px] bg-white/90 px-2 ring-2 transition-colors',
!hasErrors && signerStyles.default.base, !hasErrors && signerStyles.base,
!hasErrors && signerStyles.default.fieldItem, !hasErrors && signerStyles.fieldItem,
fieldClassName,
{ {
'rounded-lg border border-red-400 bg-red-400/20 shadow-[0_0_0_5px_theme(colors.red.500/10%),0_0_0_2px_theme(colors.red.500/40%),0_0_0_0.5px_theme(colors.red.500)]': 'rounded-[2px] border bg-red-400/20 shadow-[0_0_0_5px_theme(colors.red.500/10%),0_0_0_2px_theme(colors.red.500/40%),0_0_0_0.5px_theme(colors.red.500)] ring-red-400':
hasErrors, hasErrors,
}, },
!fixedSize && '[container-type:size]', !fixedSize && '[container-type:size]',
@ -279,37 +280,31 @@ export const FieldItem = ({
ref={$el} ref={$el}
data-field-id={field.nativeId} data-field-id={field.nativeId}
> >
{match(field.type) <FieldContent field={field} />
.with('CHECKBOX', () => <CheckboxField field={field} />)
.with('RADIO', () => <RadioField field={field} />)
.otherwise(() => (
<FieldIcon fieldMeta={field.fieldMeta} type={field.type} />
))}
{!hideRecipients && ( {/* On hover, display recipient initials on side of field. */}
<div className="absolute -right-5 top-0 z-20 hidden h-full w-5 items-center justify-center group-hover:flex"> <div className="absolute -right-5 top-0 z-20 hidden h-full w-5 items-center justify-center group-hover:flex">
<div <div
className={cn( className={cn(
'flex h-5 w-5 flex-col items-center justify-center rounded-r-md text-[0.5rem] font-bold text-white', 'flex h-5 w-5 flex-col items-center justify-center rounded-r-md text-[0.5rem] font-bold text-white opacity-0 transition duration-200 group-hover/field-item:opacity-100',
signerStyles.default.fieldItemInitials, signerStyles.fieldItemInitials,
{ {
'!opacity-50': disabled || passive, '!opacity-50': disabled || passive,
}, },
)} )}
> >
{(field.signerEmail?.charAt(0)?.toUpperCase() ?? '') + {(field.signerEmail?.charAt(0)?.toUpperCase() ?? '') +
(field.signerEmail?.charAt(1)?.toUpperCase() ?? '')} (field.signerEmail?.charAt(1)?.toUpperCase() ?? '')}
</div>
</div> </div>
)} </div>
</div> </div>
{!disabled && settingsActive && ( {!disabled && settingsActive && (
<div className="z-[60] mt-1 flex justify-center"> <div className="absolute z-[60] mt-1 flex w-full items-center justify-center">
<div className="dark:bg-background group flex items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5"> <div className="group flex items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5">
{advancedField && ( {advancedField && (
<button <button
className="dark:text-muted-foreground/50 dark:hover:text-muted-foreground dark:hover:bg-foreground/10 rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100" className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onAdvancedSettings} onClick={onAdvancedSettings}
onTouchEnd={onAdvancedSettings} onTouchEnd={onAdvancedSettings}
> >
@ -318,7 +313,7 @@ export const FieldItem = ({
)} )}
<button <button
className="dark:text-muted-foreground/50 dark:hover:text-muted-foreground dark:hover:bg-foreground/10 rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100" className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onDuplicate} onClick={onDuplicate}
onTouchEnd={onDuplicate} onTouchEnd={onDuplicate}
> >
@ -326,7 +321,7 @@ export const FieldItem = ({
</button> </button>
<button <button
className="dark:text-muted-foreground/50 dark:hover:text-muted-foreground dark:hover:bg-foreground/10 rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100" className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onRemove} onClick={onRemove}
onTouchEnd={onRemove} onTouchEnd={onRemove}
> >

View File

@ -1,49 +0,0 @@
import { useLingui } from '@lingui/react';
import { FieldType, type Prisma } from '@prisma/client';
import { createPortal } from 'react-dom';
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import { cn } from '../../lib/utils';
import { Card, CardContent } from '../card';
import { FRIENDLY_FIELD_TYPE } from './types';
export type ShowFieldItemProps = {
field: Prisma.FieldGetPayload<null>;
recipients: Prisma.RecipientGetPayload<null>[];
};
export const ShowFieldItem = ({ field }: ShowFieldItemProps) => {
const { _ } = useLingui();
const coords = useFieldPageCoords(field);
return createPortal(
<div
className={cn('pointer-events-none absolute z-10 opacity-75')}
style={{
top: `${coords.y}px`,
left: `${coords.x}px`,
height: `${coords.height}px`,
width: `${coords.width}px`,
}}
>
<Card className={cn('bg-background h-full w-full [container-type:size]')}>
<CardContent
className={cn(
'text-muted-foreground/50 flex h-full w-full flex-col items-center justify-center p-0 text-[clamp(0.575rem,1.8cqw,1.2rem)] leading-none',
field.type === FieldType.SIGNATURE && 'font-signature',
)}
>
{parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type])}
{/* <p className="text-muted-foreground/50 w-full truncate text-center text-xs">
{signerEmail}
</p> */}
</CardContent>
</Card>
</div>,
document.body,
);
};

View File

@ -12,7 +12,12 @@ export const ElementVisible = ({ target, children }: ElementVisibleProps) => {
const observer = new MutationObserver((_mutations) => { const observer = new MutationObserver((_mutations) => {
const $el = document.querySelector(target); const $el = document.querySelector(target);
setVisible(!!$el); // Wait a fraction of a second to allow the scrollbar to load if it exists.
// If we don't wait, then the elements on the first page will be
// shifted across.
setTimeout(() => {
setVisible(!!$el);
}, 100);
}); });
observer.observe(document.body, { observer.observe(document.body, {

View File

@ -30,11 +30,12 @@ PopoverContent.displayName = PopoverPrimitive.Content.displayName;
type PopoverHoverProps = { type PopoverHoverProps = {
trigger: React.ReactNode; trigger: React.ReactNode;
side?: 'top' | 'bottom' | 'left' | 'right';
children: React.ReactNode; children: React.ReactNode;
contentProps?: React.ComponentPropsWithoutRef<typeof PopoverContent>; contentProps?: React.ComponentPropsWithoutRef<typeof PopoverContent>;
}; };
const PopoverHover = ({ trigger, children, contentProps }: PopoverHoverProps) => { const PopoverHover = ({ trigger, children, contentProps, side = 'top' }: PopoverHoverProps) => {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const isControlled = React.useRef(false); const isControlled = React.useRef(false);
@ -79,7 +80,7 @@ const PopoverHover = ({ trigger, children, contentProps }: PopoverHoverProps) =>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
side="top" side={side}
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
{...contentProps} {...contentProps}

View File

@ -9,7 +9,7 @@ import { sortBy } from 'remeda';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { getSignerColorStyles } from '../lib/signer-colors'; import { getRecipientColorStyles } from '../lib/recipient-colors';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { Button } from './button'; import { Button } from './button';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from './command'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from './command';
@ -78,12 +78,12 @@ export const RecipientSelector = ({
role="combobox" role="combobox"
className={cn( className={cn(
'bg-background text-muted-foreground hover:text-foreground justify-between font-normal', 'bg-background text-muted-foreground hover:text-foreground justify-between font-normal',
getSignerColorStyles( getRecipientColorStyles(
Math.max( Math.max(
recipients.findIndex((r) => r.id === selectedRecipient?.id), recipients.findIndex((r) => r.id === selectedRecipient?.id),
0, 0,
), ),
).default.base, ).base,
className, className,
)} )}
> >
@ -131,12 +131,12 @@ export const RecipientSelector = ({
key={recipient.id} key={recipient.id}
className={cn( className={cn(
'px-2 last:mb-1 [&:not(:first-child)]:mt-1', 'px-2 last:mb-1 [&:not(:first-child)]:mt-1',
getSignerColorStyles( getRecipientColorStyles(
Math.max( Math.max(
recipients.findIndex((r) => r.id === recipient.id), recipients.findIndex((r) => r.id === recipient.id),
0, 0,
), ),
).default.comboxBoxItem, ).comboxBoxItem,
{ {
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT, 'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
}, },

View File

@ -53,7 +53,7 @@ import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/type
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { getSignerColorStyles, useSignerColors } from '../../lib/signer-colors'; import { getRecipientColorStyles, useRecipientColors } from '../../lib/recipient-colors';
import type { FieldFormType } from '../document-flow/add-fields'; import type { FieldFormType } from '../document-flow/add-fields';
import { FieldAdvancedSettings } from '../document-flow/field-item-advanced-settings'; import { FieldAdvancedSettings } from '../document-flow/field-item-advanced-settings';
import { Form } from '../form/form'; import { Form } from '../form/form';
@ -68,7 +68,6 @@ const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
export type AddTemplateFieldsFormProps = { export type AddTemplateFieldsFormProps = {
documentFlow: DocumentFlowStep; documentFlow: DocumentFlowStep;
hideRecipients?: boolean;
recipients: Recipient[]; recipients: Recipient[];
fields: Field[]; fields: Field[];
onSubmit: (_data: TAddTemplateFieldsFormSchema) => void; onSubmit: (_data: TAddTemplateFieldsFormSchema) => void;
@ -77,7 +76,6 @@ export type AddTemplateFieldsFormProps = {
export const AddTemplateFieldsFormPartial = ({ export const AddTemplateFieldsFormPartial = ({
documentFlow, documentFlow,
hideRecipients = false,
recipients, recipients,
fields, fields,
onSubmit, onSubmit,
@ -136,7 +134,7 @@ export const AddTemplateFieldsFormPartial = ({
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false); const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const selectedSignerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id); const selectedSignerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id);
const selectedSignerStyles = useSignerColors( const selectedSignerStyles = useRecipientColors(
selectedSignerIndex === -1 ? 0 : selectedSignerIndex, selectedSignerIndex === -1 ? 0 : selectedSignerIndex,
); );
@ -505,8 +503,8 @@ export const AddTemplateFieldsFormPartial = ({
{selectedField && ( {selectedField && (
<div <div
className={cn( className={cn(
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200 [container-type:size]', 'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
selectedSignerStyles.default.base, selectedSignerStyles?.base,
{ {
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds, '-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
'dark:text-black/60': isFieldWithinBounds, 'dark:text-black/60': isFieldWithinBounds,
@ -549,7 +547,6 @@ export const AddTemplateFieldsFormPartial = ({
setCurrentField(field); setCurrentField(field);
handleAdvancedSettings(); handleAdvancedSettings();
}} }}
hideRecipients={hideRecipients}
active={activeFieldId === field.formId} active={activeFieldId === field.formId}
onFieldActivate={() => setActiveFieldId(field.formId)} onFieldActivate={() => setActiveFieldId(field.formId)}
onFieldDeactivate={() => setActiveFieldId(null)} onFieldDeactivate={() => setActiveFieldId(null)}
@ -557,99 +554,98 @@ export const AddTemplateFieldsFormPartial = ({
); );
})} })}
{!hideRecipients && ( <Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}> <PopoverTrigger asChild>
<PopoverTrigger asChild> <Button
<Button type="button"
type="button" variant="outline"
variant="outline" role="combobox"
role="combobox" className={cn(
className={cn( 'bg-background text-muted-foreground hover:text-foreground mb-12 mt-2 justify-between font-normal',
'bg-background text-muted-foreground hover:text-foreground mb-12 mt-2 justify-between font-normal', selectedSignerStyles?.comboxBoxTrigger,
selectedSignerStyles.default.base, )}
)} >
> {selectedSigner?.email && (
{selectedSigner?.email && ( <span className="flex-1 truncate text-left">
<span className="flex-1 truncate text-left"> {selectedSigner?.name} ({selectedSigner?.email})
{selectedSigner?.name} ({selectedSigner?.email}) </span>
</span> )}
)}
{!selectedSigner?.email && ( {!selectedSigner?.email && (
<span className="gradie flex-1 truncate text-left"> <span className="gradie flex-1 truncate text-left">
{selectedSigner?.email} {selectedSigner?.email}
</span> </span>
)} )}
<ChevronsUpDown className="ml-2 h-4 w-4" /> <ChevronsUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0" align="start"> <PopoverContent className="p-0" align="start">
<Command value={selectedSigner?.email}> <Command value={selectedSigner?.email}>
<CommandInput /> <CommandInput />
<CommandEmpty> <CommandEmpty>
<span className="text-muted-foreground inline-block px-4"> <span className="text-muted-foreground inline-block px-4">
<Trans>No recipient matching this description was found.</Trans> <Trans>No recipient matching this description was found.</Trans>
</span> </span>
</CommandEmpty> </CommandEmpty>
{recipientsByRoleToDisplay.map(([role, roleRecipients], roleIndex) => ( {/* Note: This is duplicated in `add-fields.tsx` */}
<CommandGroup key={roleIndex}> {recipientsByRoleToDisplay.map(([role, roleRecipients], roleIndex) => (
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium"> <CommandGroup key={roleIndex}>
{_(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)} <div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
{_(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
</div>
{roleRecipients.length === 0 && (
<div
key={`${role}-empty`}
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
>
<Trans>No recipients with this role</Trans>
</div> </div>
)}
{roleRecipients.length === 0 && ( {roleRecipients.map((recipient) => (
<div <CommandItem
key={`${role}-empty`} key={recipient.id}
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs" className={cn(
'px-2 last:mb-1 [&:not(:first-child)]:mt-1',
getRecipientColorStyles(
Math.max(
recipients.findIndex((r) => r.id === recipient.id),
0,
),
)?.comboxBoxItem,
)}
onSelect={() => {
setSelectedSigner(recipient);
setShowRecipientsSelector(false);
}}
>
<span
className={cn('text-foreground/70 truncate', {
'text-foreground/80': recipient === selectedSigner,
})}
> >
<Trans>No recipients with this role</Trans> {recipient.name && (
</div> <span title={`${recipient.name} (${recipient.email})`}>
)} {recipient.name} ({recipient.email})
</span>
{roleRecipients.map((recipient) => (
<CommandItem
key={recipient.id}
className={cn(
'px-2 last:mb-1 [&:not(:first-child)]:mt-1',
getSignerColorStyles(
Math.max(
recipients.findIndex((r) => r.id === recipient.id),
0,
),
).default.comboxBoxItem,
)} )}
onSelect={() => {
setSelectedSigner(recipient);
setShowRecipientsSelector(false);
}}
>
<span
className={cn('text-foreground/70 truncate', {
'text-foreground/80': recipient === selectedSigner,
})}
>
{recipient.name && (
<span title={`${recipient.name} (${recipient.email})`}>
{recipient.name} ({recipient.email})
</span>
)}
{!recipient.name && ( {!recipient.name && (
<span title={recipient.email}>{recipient.email}</span> <span title={recipient.email}>{recipient.email}</span>
)} )}
</span> </span>
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>
))} ))}
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
)}
<Form {...form}> <Form {...form}>
<div className="-mx-2 flex-1 overflow-y-auto px-2"> <div className="-mx-2 flex-1 overflow-y-auto px-2">

View File

@ -25,6 +25,10 @@ import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-messa
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { toast } from '@documenso/ui/primitives/use-toast'; import { toast } from '@documenso/ui/primitives/use-toast';
import {
DocumentReadOnlyFields,
mapFieldsWithRecipients,
} from '../../components/document/document-read-only-fields';
import { Checkbox } from '../checkbox'; import { Checkbox } from '../checkbox';
import { import {
DocumentFlowFormContainerActions, DocumentFlowFormContainerActions,
@ -33,7 +37,6 @@ import {
DocumentFlowFormContainerHeader, DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep, DocumentFlowFormContainerStep,
} from '../document-flow/document-flow-root'; } from '../document-flow/document-flow-root';
import { ShowFieldItem } from '../document-flow/show-field-item';
import { SigningOrderConfirmation } from '../document-flow/signing-order-confirmation'; import { SigningOrderConfirmation } from '../document-flow/signing-order-confirmation';
import type { DocumentFlowStep } from '../document-flow/types'; import type { DocumentFlowStep } from '../document-flow/types';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
@ -391,10 +394,13 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
description={documentFlow.description} description={documentFlow.description}
/> />
<DocumentFlowFormContainerContent> <DocumentFlowFormContainerContent>
{isDocumentPdfLoaded && {isDocumentPdfLoaded && (
fields.map((field, index) => ( <DocumentReadOnlyFields
<ShowFieldItem key={index} field={field} recipients={recipients} /> showRecipientColors={true}
))} recipientIds={recipients.map((recipient) => recipient.id)}
fields={mapFieldsWithRecipients(fields, recipients)}
/>
)}
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}> <AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}> <Form {...form}>
@ -500,6 +506,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
ref={provided.innerRef} ref={provided.innerRef}
className="flex w-full flex-col gap-y-2" className="flex w-full flex-col gap-y-2"
> >
{/* todo */}
{signers.map((signer, index) => ( {signers.map((signer, index) => (
<Draggable <Draggable
key={`${signer.id}-${signer.signingOrder}`} key={`${signer.id}-${signer.signingOrder}`}

View File

@ -50,6 +50,10 @@ import {
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { DocumentEmailCheckboxes } from '../../components/document/document-email-checkboxes'; import { DocumentEmailCheckboxes } from '../../components/document/document-email-checkboxes';
import {
DocumentReadOnlyFields,
mapFieldsWithRecipients,
} from '../../components/document/document-read-only-fields';
import { DocumentSignatureSettingsTooltip } from '../../components/document/document-signature-settings-tooltip'; import { DocumentSignatureSettingsTooltip } from '../../components/document/document-signature-settings-tooltip';
import { Combobox } from '../combobox'; import { Combobox } from '../combobox';
import { import {
@ -59,7 +63,6 @@ import {
DocumentFlowFormContainerHeader, DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep, DocumentFlowFormContainerStep,
} from '../document-flow/document-flow-root'; } from '../document-flow/document-flow-root';
import { ShowFieldItem } from '../document-flow/show-field-item';
import type { DocumentFlowStep } from '../document-flow/types'; import type { DocumentFlowStep } from '../document-flow/types';
import { Input } from '../input'; import { Input } from '../input';
import { MultiSelectCombobox } from '../multi-select-combobox'; import { MultiSelectCombobox } from '../multi-select-combobox';
@ -153,10 +156,13 @@ export const AddTemplateSettingsFormPartial = ({
/> />
<DocumentFlowFormContainerContent> <DocumentFlowFormContainerContent>
{isDocumentPdfLoaded && {isDocumentPdfLoaded && (
fields.map((field, index) => ( <DocumentReadOnlyFields
<ShowFieldItem key={index} field={field} recipients={recipients} /> showRecipientColors={true}
))} recipientIds={recipients.map((recipient) => recipient.id)}
fields={mapFieldsWithRecipients(fields, recipients)}
/>
)}
<Form {...form}> <Form {...form}>
<fieldset <fieldset

View File

@ -3,7 +3,8 @@
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
:root { :root,
.dark-mode-disabled {
--background: 0 0% 100%; --background: 0 0% 100%;
--foreground: 222.2 47.4% 11.2%; --foreground: 222.2 47.4% 11.2%;
@ -48,12 +49,12 @@
--gold: 47.9 95.8% 53.1%; --gold: 47.9 95.8% 53.1%;
--signer-green: 100 48% 55%; --recipient-green: 100 48% 55%;
--signer-blue: 212 56% 50%; --recipient-blue: 212 56% 50%;
--signer-purple: 266 100% 64%; --recipient-purple: 266 100% 64%;
--signer-orange: 36 92% 54%; --recipient-orange: 36 92% 54%;
--signer-yellow: 51 100% 43%; --recipient-yellow: 51 100% 43%;
--signer-pink: 313 65% 57%; --recipient-pink: 313 65% 57%;
/* Base - Neutral */ /* Base - Neutral */
--new-neutral-50: 0, 0%, 96%; --new-neutral-50: 0, 0%, 96%;
@ -190,7 +191,9 @@
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-feature-settings: 'rlig' 1, 'calt' 1; font-feature-settings:
'rlig' 1,
'calt' 1;
} }
} }
@ -210,8 +213,12 @@
} }
.gradient-border-mask::before { .gradient-border-mask::before {
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); mask:
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: exclude; mask-composite: exclude;
-webkit-mask-composite: xor; -webkit-mask-composite: xor;
} }