Compare commits

...

1 Commits

Author SHA1 Message Date
172a5be737 fix: wip 2025-03-03 21:35:12 +11:00
32 changed files with 742 additions and 843 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

@ -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,14 @@ 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} )}
/> />
))} )}
<Form {...form}> <Form {...form}>
<fieldset <fieldset

View File

@ -254,19 +254,19 @@ 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 gap-x-1.5">
<Checkbox <Checkbox
className="h-4 w-4" className="h-3 w-3"
id={`checkbox-${index}`} id={`checkbox-${index}`}
checked={checkedValues.includes(itemValue)} checked={checkedValues.includes(itemValue)}
onCheckedChange={() => handleCheckboxChange(item.value, item.id)} onCheckedChange={() => handleCheckboxChange(item.value, item.id)}
/> />
<Label htmlFor={`checkbox-${index}`}> <Label htmlFor={`checkbox-${index}`} className="text-xs font-normal">
{item.value.includes('empty-value-') ? '' : item.value} {item.value.includes('empty-value-') ? '' : item.value}
</Label> </Label>
</div> </div>
@ -277,7 +277,7 @@ 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}`;
@ -290,7 +290,7 @@ export const DocumentSigningCheckboxField = ({
disabled={isLoading} disabled={isLoading}
onCheckedChange={() => void handleCheckboxOptionClick(item)} onCheckedChange={() => void handleCheckboxOptionClick(item)}
/> />
<Label htmlFor={`checkbox-${index}`} className="text-xs"> <Label htmlFor={`checkbox-${index}`} className="text-xs font-normal">
{item.value.includes('empty-value-') ? '' : item.value} {item.value.includes('empty-value-') ? '' : item.value}
</Label> </Label>
</div> </div>

View File

@ -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-muted-foreground dark:text-background/80 w-full 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

@ -136,12 +136,10 @@ export const DocumentSigningEmailField = ({
<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-muted-foreground dark:text-background/80 w-full 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

@ -182,12 +182,10 @@ export const DocumentSigningNameField = ({
<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-muted-foreground dark:text-background/80 w-full 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

@ -272,12 +272,10 @@ export const DocumentSigningNumberField = ({
<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-muted-foreground dark:text-background/80 w-full 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

@ -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';

View File

@ -157,17 +157,20 @@ export const DocumentSigningRadioField = ({
)} )}
{!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 gap-x-1.5">
<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-${index}`}
checked={item.checked} checked={item.checked}
/> />
<Label htmlFor={`option-${index}`}> <Label htmlFor={`option-${index}`} className="text-xs font-normal">
{item.value.includes('empty-value-') ? '' : item.value} {item.value.includes('empty-value-') ? '' : item.value}
</Label> </Label>
</div> </div>
@ -176,7 +179,7 @@ export const DocumentSigningRadioField = ({
)} )}
{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 gap-x-1.5">
<RadioGroupItem <RadioGroupItem
@ -185,7 +188,7 @@ export const DocumentSigningRadioField = ({
id={`option-${index}`} id={`option-${index}`}
checked={item.value === field.customText} checked={item.value === field.customText}
/> />
<Label htmlFor={`option-${index}`} className="text-xs"> <Label htmlFor={`option-${index}`} className="text-xs font-normal">
{item.value.includes('empty-value-') ? '' : item.value} {item.value.includes('empty-value-') ? '' : item.value}
</Label> </Label>
</div> </div>

View File

@ -277,12 +277,11 @@ export const DocumentSigningTextField = ({
<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-muted-foreground dark:text-background/80 w-full text-left text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{ {
'text-left': parsedFieldMeta?.textAlign === 'left', // Todo: Test
'text-center': '!text-center': parsedFieldMeta?.textAlign === 'center',
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center', '!text-right': parsedFieldMeta?.textAlign === 'right',
'text-right': parsedFieldMeta?.textAlign === 'right',
}, },
)} )}
> >
@ -304,11 +303,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}

View File

@ -1,171 +0,0 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta } 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;
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

@ -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,12 @@ 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}
/>
)} )}
<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

@ -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,7 @@ export default function TemplatePage() {
<DocumentReadOnlyFields <DocumentReadOnlyFields
fields={readOnlyFields} fields={readOnlyFields}
showFieldStatus={false} showFieldStatus={false}
showRecipientTooltip={true}
documentMeta={mockedDocumentMeta} documentMeta={mockedDocumentMeta}
/> />

View File

@ -17,7 +17,6 @@ import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server'; import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server'; import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers'; import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers';
import { getCertificatePdf } from '../htmltopdf/get-certificate-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';
@ -104,13 +103,14 @@ export const sealDocument = async ({
// !: Need to write the fields onto the document as a hard copy // !: Need to write the fields onto the document as a hard copy
const pdfData = await getFileServerSide(documentData); const pdfData = await getFileServerSide(documentData);
const certificateData = // debugging........
(document.team?.teamGlobalSettings?.includeSigningCertificate ?? true) // const certificateData =
? await getCertificatePdf({ // (document.team?.teamGlobalSettings?.includeSigningCertificate ?? true)
documentId, // ? await getCertificatePdf({
language: document.documentMeta?.language, // documentId,
}).catch(() => null) // language: document.documentMeta?.language,
: null; // }).catch(() => null)
// : null;
const doc = await PDFDocument.load(pdfData); const doc = await PDFDocument.load(pdfData);
@ -119,15 +119,15 @@ export const sealDocument = async ({
flattenForm(doc); flattenForm(doc);
flattenAnnotations(doc); flattenAnnotations(doc);
if (certificateData) { // if (certificateData) {
const certificate = await PDFDocument.load(certificateData); // const certificate = await PDFDocument.load(certificateData);
const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices()); // const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
certificatePages.forEach((page) => { // certificatePages.forEach((page) => {
doc.addPage(page); // doc.addPage(page);
}); // });
} // }
for (const field of fields) { for (const field of fields) {
await insertFieldInPDF(doc, field); await insertFieldInPDF(doc, field);

View File

@ -36,7 +36,8 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const isSignatureField = isSignatureFieldType(field.type); const isSignatureField = isSignatureFieldType(field.type);
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'; false; // todo
// true || process.env.DEBUG_PDF_INSERT === '1' || process.env.DEBUG_PDF_INSERT === 'true';
pdf.registerFontkit(fontkit); pdf.registerFontkit(fontkit);
@ -227,8 +228,13 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const selected: string[] = fromCheckboxValue(field.customText); const selected: string[] = fromCheckboxValue(field.customText);
const topPadding = 13;
const leftCheckboxPadding = 6;
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 +243,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 +251,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 +274,28 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const selected = field.customText.split(','); const selected = field.customText.split(',');
const topPadding = 13;
const leftRadioPadding = 6;
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,
@ -308,7 +321,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const meta = Parser ? Parser.safeParse(field.fieldMeta) : null; const meta = Parser ? Parser.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 const longestLineInTextForWidth = field.customText
.split('\n') .split('\n')
.sort((a, b) => b.length - a.length)[0]; .sort((a, b) => b.length - a.length)[0];
@ -325,41 +338,69 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize); textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
// 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; // Todo: Play around with this.
// Calculate X position based on text alignment with padding // Calculate X position based on text alignment with padding
let textX = fieldX + padding; // Left alignment starts after padding let textX = fieldX + padding; // Left alignment starts after padding
if (textAlign === 'center') { if (textAlign === 'center') {
textX = fieldX + (fieldWidth - textWidth) / 2; // Center alignment ignores padding textX = fieldX + (fieldWidth - textWidth) / 2; // Center alignment ignores padding
} else if (textAlign === 'right') { } else if (textAlign === 'right') {
textX = fieldX + fieldWidth - textWidth - padding; // Right alignment respects right padding 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;
let textFieldBoxX = textX;
const textField = pdf.getForm().createTextField(`text.${field.secondaryId}`);
if (pageRotationInDegrees !== 0) { if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation( const adjustedPosition = adjustPositionForRotation(
pageWidth, pageWidth,
pageHeight, pageHeight,
textX, textFieldBoxX,
textY, textFieldBoxY,
pageRotationInDegrees, pageRotationInDegrees,
); );
textX = adjustedPosition.xPos; textFieldBoxX = adjustedPosition.xPos;
textY = adjustedPosition.yPos; textFieldBoxY = adjustedPosition.yPos;
} }
page.drawText(field.customText, { if (isDebugMode) {
x: textX, page.drawRectangle({
y: textY, x: textFieldBoxX,
size: fontSize, y: textFieldBoxY,
font, width: textWidth,
height: textHeight,
borderColor: rgb(1, 0, 0),
borderWidth: 1,
rotate: degrees(pageRotationInDegrees),
});
}
// Set the position and size of the text field
textField.addToPage(page, {
x: textFieldBoxX,
y: textFieldBoxY,
width: fieldWidth,
height: fieldHeight,
borderWidth: 0, // Hide border.
borderColor: rgb(1, 1, 1), // Hide border.
backgroundColor: undefined, // Makes transparent so background doesn't cover other text.
rotate: degrees(pageRotationInDegrees), rotate: degrees(pageRotationInDegrees),
// Draw debug box if debug mode is enabled.
...(isDebugMode && {
borderColor: rgb(1, 0, 0),
borderWidth: 1,
}),
}); });
// Set properties for the text field
textField.setFontSize(fontSize);
textField.setText(field.customText);
}); });
return pdf; return pdf;

View File

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

View File

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

View File

@ -1,14 +1,12 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useState } from 'react';
import type { Field } from '@prisma/client'; import type { Field } 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 { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { useSignerColors } from '../../lib/signer-colors';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import { Card, CardContent } from '../../primitives/card';
export type FieldRootContainerProps = { export type FieldRootContainerProps = {
field: Field; field: Field;
@ -19,53 +17,6 @@ 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 +27,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,10 +45,12 @@ export function FieldContainerPortal({
); );
} }
export function FieldRootContainer({ field, children, cardClassName }: FieldContainerPortalProps) { export function FieldRootContainer({ field, children }: FieldContainerPortalProps) {
const [isValidating, setIsValidating] = useState(false); const [isValidating, setIsValidating] = useState(false);
const ref = React.useRef<HTMLDivElement>(null); const ref = React.useRef<HTMLDivElement>(null);
const signerStyles = useSignerColors(field.recipientId);
useEffect(() => { useEffect(() => {
if (!ref.current) { if (!ref.current) {
return; return;
@ -121,33 +71,36 @@ export function FieldRootContainer({ field, children, cardClassName }: FieldCont
}; };
}, []); }, []);
const parsedField = useMemo( // // todo: remove
() => (field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : null), // const parsedField = useMemo(
[field.fieldMeta], // () => (field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : null),
); // [field.fieldMeta],
const isCheckboxOrRadio = useMemo( // );
() => parsedField?.type === 'checkbox' || parsedField?.type === 'radio',
[parsedField],
);
const cardClassNames = useMemo( // // todo: remove
() => getCardClassNames(field, parsedField, isValidating, isCheckboxOrRadio, cardClassName), // const isCheckboxOrRadio = useMemo(
[field, parsedField, isValidating, isCheckboxOrRadio, cardClassName], // () => parsedField?.type === 'checkbox' || parsedField?.type === 'radio',
); // [parsedField],
// );
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 relative z-20 h-full w-full rounded-sm ring-2 transition-all',
'ring-signer-green bg-white/90',
'px-2', // This is specific to try sync with field insertion. See insert-field-in-pdf before changing this.
{
'flex items-center justify-center': !field.inserted,
},
)}
> >
<CardContent className="text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2"> {children}
{children} </div>
</CardContent>
</Card>
</FieldContainerPortal> </FieldContainerPortal>
); );
} }

View File

@ -2,16 +2,14 @@
// !: therefore doing this at runtime is not possible without whitelisting a set of classnames. // !: 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 // !: 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. // !: values of the declared variable to do all the background, border and shadow styles.
export const SIGNER_COLOR_STYLES = { export const SIGNER_COLOR_STYLES = {
green: { green: {
default: { default: {
background: 'bg-[hsl(var(--signer-green))]', base: 'ring-signer-green hover:bg-signer-green/30',
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 rounded-sm',
fieldItem: fieldItemInitials: 'group-hover/field-item:bg-[hsl(var(--signer-green))]',
'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: comboxBoxItem:
'hover:bg-[hsl(var(--signer-green)/15%)] active:bg-[hsl(var(--signer-green)/15%)]', 'hover:bg-[hsl(var(--signer-green)/15%)] active:bg-[hsl(var(--signer-green)/15%)]',
}, },
@ -19,12 +17,9 @@ export const SIGNER_COLOR_STYLES = {
blue: { blue: {
default: { default: {
background: 'bg-[hsl(var(--signer-blue))]', base: 'ring-signer-blue hover:bg-signer-blue/30',
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 rounded-sm',
fieldItem: fieldItemInitials: 'group-hover/field-item:bg-[hsl(var(--signer-blue))]',
'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: comboxBoxItem:
'hover:bg-[hsl(var(--signer-blue)/15%)] active:bg-[hsl(var(--signer-blue)/15%)]', 'hover:bg-[hsl(var(--signer-blue)/15%)] active:bg-[hsl(var(--signer-blue)/15%)]',
}, },
@ -32,12 +27,9 @@ export const SIGNER_COLOR_STYLES = {
purple: { purple: {
default: { default: {
background: 'bg-[hsl(var(--signer-purple))]', base: 'ring-signer-purple hover:bg-signer-purple/30',
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 rounded-sm',
fieldItem: fieldItemInitials: 'group-hover/field-item:bg-[hsl(var(--signer-purple))]',
'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: comboxBoxItem:
'hover:bg-[hsl(var(--signer-purple)/15%)] active:bg-[hsl(var(--signer-purple)/15%)]', 'hover:bg-[hsl(var(--signer-purple)/15%)] active:bg-[hsl(var(--signer-purple)/15%)]',
}, },
@ -45,12 +37,9 @@ export const SIGNER_COLOR_STYLES = {
orange: { orange: {
default: { default: {
background: 'bg-[hsl(var(--signer-orange))]', base: 'ring-signer-orange hover:bg-signer-orange/30',
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 rounded-sm',
fieldItem: fieldItemInitials: 'group-hover/field-item:bg-[hsl(var(--signer-orange))]',
'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: comboxBoxItem:
'hover:bg-[hsl(var(--signer-orange)/15%)] active:bg-[hsl(var(--signer-orange)/15%)]', 'hover:bg-[hsl(var(--signer-orange)/15%)] active:bg-[hsl(var(--signer-orange)/15%)]',
}, },
@ -58,12 +47,9 @@ export const SIGNER_COLOR_STYLES = {
yellow: { yellow: {
default: { default: {
background: 'bg-[hsl(var(--signer-yellow))]', base: 'ring-signer-yellow hover:bg-signer-yellow/30',
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 rounded-sm',
fieldItem: fieldItemInitials: 'group-hover/field-item:bg-[hsl(var(--signer-yellow))]',
'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: comboxBoxItem:
'hover:bg-[hsl(var(--signer-yellow)/15%)] active:bg-[hsl(var(--signer-yellow)/15%)]', 'hover:bg-[hsl(var(--signer-yellow)/15%)] active:bg-[hsl(var(--signer-yellow)/15%)]',
}, },
@ -71,12 +57,9 @@ export const SIGNER_COLOR_STYLES = {
pink: { pink: {
default: { default: {
background: 'bg-[hsl(var(--signer-pink))]', base: 'ring-signer-pink hover:bg-signer-pink/30',
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 rounded-sm',
fieldItem: fieldItemInitials: 'group-hover/field-item:bg-[hsl(var(--signer-pink))]',
'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: comboxBoxItem:
'hover:bg-[hsl(var(--signer-pink)/15%)] active:bg-[hsl(var(--signer-pink)/15%)]', 'hover:bg-[hsl(var(--signer-pink)/15%)] active:bg-[hsl(var(--signer-pink)/15%)]',
}, },

View File

@ -88,7 +88,6 @@ export type FieldFormType = {
export type AddFieldsFormProps = { export type AddFieldsFormProps = {
documentFlow: DocumentFlowStep; documentFlow: DocumentFlowStep;
hideRecipients?: boolean;
recipients: Recipient[]; recipients: Recipient[];
fields: Field[]; fields: Field[];
onSubmit: (_data: TAddFieldsFormSchema) => void; onSubmit: (_data: TAddFieldsFormSchema) => void;
@ -100,7 +99,6 @@ export type AddFieldsFormProps = {
export const AddFieldsFormPartial = ({ export const AddFieldsFormPartial = ({
documentFlow, documentFlow,
hideRecipients = false,
recipients, recipients,
fields, fields,
onSubmit, onSubmit,
@ -657,7 +655,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)}
@ -666,125 +663,123 @@ export const AddFieldsFormPartial = ({
); );
})} })}
{!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.default.base,
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="flex-1 truncate text-left">{selectedSigner?.email}</span> <span className="flex-1 truncate text-left">{selectedSigner?.email}</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) => ( {recipientsByRoleToDisplay.map(([role, roleRecipients], roleIndex) => (
<CommandGroup key={roleIndex}> <CommandGroup key={roleIndex}>
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium"> <div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
{_(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)} {_(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',
getSignerColorStyles(
Math.max(
recipients.findIndex((r) => r.id === recipient.id),
0,
),
).default.comboxBoxItem,
{
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
},
)}
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,
{
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
},
)} )}
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>
<div className="ml-auto flex items-center justify-center"> <div className="ml-auto flex items-center justify-center">
{recipient.sendStatus !== SendStatus.SENT ? ( {recipient.sendStatus !== SendStatus.SENT ? (
<Check <Check
aria-hidden={recipient !== selectedSigner} aria-hidden={recipient !== selectedSigner}
className={cn('h-4 w-4 flex-shrink-0', { className={cn('h-4 w-4 flex-shrink-0', {
'opacity-0': recipient !== selectedSigner, 'opacity-0': recipient !== selectedSigner,
'opacity-100': recipient === selectedSigner, 'opacity-100': recipient === selectedSigner,
})} })}
/> />
) : ( ) : (
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<Info className="ml-2 h-4 w-4" /> <Info className="ml-2 h-4 w-4" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs"> <TooltipContent className="text-muted-foreground max-w-xs">
<Trans> <Trans>
This document has already been sent to this recipient. You This document has already been sent to this recipient. You can
can no longer edit this recipient. no longer edit this recipient.
</Trans> </Trans>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
</div> </div>
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>
))} ))}
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
)}
<Form {...form}> <Form {...form}>
<FormField <FormField

View File

@ -21,6 +21,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,
@ -54,7 +58,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 = {
@ -145,10 +148,9 @@ export const AddSettingsFormPartial = ({
/> />
<DocumentFlowFormContainerContent> <DocumentFlowFormContainerContent>
{isDocumentPdfLoaded && {isDocumentPdfLoaded && (
fields.map((field, index) => ( <DocumentReadOnlyFields fields={mapFieldsWithRecipients(fields, recipients)} />
<ShowFieldItem key={index} field={field} recipients={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';
@ -39,7 +43,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';
@ -361,10 +364,9 @@ export const AddSignersFormPartial = ({
description={documentFlow.description} description={documentFlow.description}
/> />
<DocumentFlowFormContainerContent> <DocumentFlowFormContainerContent>
{isDocumentPdfLoaded && {isDocumentPdfLoaded && (
fields.map((field, index) => ( <DocumentReadOnlyFields fields={mapFieldsWithRecipients(fields, recipients)} />
<ShowFieldItem key={index} field={field} recipients={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 = {
@ -101,10 +104,9 @@ export const AddSubjectFormPartial = ({
/> />
<DocumentFlowFormContainerContent> <DocumentFlowFormContainerContent>
<div className="flex flex-col"> <div className="flex flex-col">
{isDocumentPdfLoaded && {isDocumentPdfLoaded && (
fields.map((field, index) => ( <DocumentReadOnlyFields fields={mapFieldsWithRecipients(fields, recipients)} />
<ShowFieldItem key={index} field={field} recipients={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,166 @@
import { useLingui } from '@lingui/react';
import type { DocumentMeta, Field, Signature } 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 { 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 = {
field: Field & { signature?: Signature };
documentMeta?: DocumentMeta;
};
/**
* 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 gap-x-1.5">
<Checkbox
className="h-3 w-3"
id={`checkbox-${index}`}
checked={checkedValues.includes(item.value)}
/>
<Label htmlFor={`checkbox-${index}`} className="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 gap-x-1.5">
<RadioGroupItem
className="pointer-events-none h-3 w-3"
value={item.value}
id={`option-${index}`}
checked={item.value === field.customText}
/>
<Label htmlFor={`option-${index}`} className="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 dark:invert"
/>
);
}
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 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

@ -4,7 +4,6 @@ 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';
@ -12,9 +11,7 @@ import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '@documenso/lib/types/field-
import { useSignerColors } from '../../lib/signer-colors'; import { useSignerColors } from '../../lib/signer-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];
@ -35,13 +32,15 @@ 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 = ({
field, field,
passive, passive,
@ -58,7 +57,6 @@ export const FieldItem = ({
onBlur, onBlur,
onAdvancedSettings, onAdvancedSettings,
recipientIndex = 0, recipientIndex = 0,
hideRecipients = false,
hasErrors, hasErrors,
active, active,
onFieldActivate, onFieldActivate,
@ -260,11 +258,11 @@ export const FieldItem = ({
<div <div
className={cn( className={cn(
'relative flex h-full w-full items-center justify-center bg-white', 'relative flex h-full w-full items-center justify-center bg-white/90 ring-2 transition-colors',
!hasErrors && signerStyles.default.base, !hasErrors && signerStyles.default.base,
!hasErrors && signerStyles.default.fieldItem, !hasErrors && signerStyles.default.fieldItem,
{ {
'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-sm 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)]':
hasErrors, hasErrors,
}, },
!fixedSize && '[container-type:size]', !fixedSize && '[container-type:size]',
@ -279,33 +277,27 @@ 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.default.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="dark:bg-background group flex items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5">
{advancedField && ( {advancedField && (
<button <button

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

@ -69,7 +69,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;
@ -79,7 +78,6 @@ export type AddTemplateFieldsFormProps = {
export const AddTemplateFieldsFormPartial = ({ export const AddTemplateFieldsFormPartial = ({
documentFlow, documentFlow,
hideRecipients = false,
recipients, recipients,
fields, fields,
onSubmit, onSubmit,
@ -483,12 +481,6 @@ export const AddTemplateFieldsFormPartial = ({
form.setValue('fields', updatedFields); form.setValue('fields', updatedFields);
}; };
const isTypedSignatureEnabled = form.watch('typedSignatureEnabled');
const handleTypedSignatureChange = (value: boolean) => {
form.setValue('typedSignatureEnabled', value, { shouldDirty: true });
};
return ( return (
<> <>
{showAdvancedSettings && currentField ? ( {showAdvancedSettings && currentField ? (
@ -559,7 +551,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)}
@ -567,99 +558,97 @@ 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.default.base,
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) => ( {recipientsByRoleToDisplay.map(([role, roleRecipients], roleIndex) => (
<CommandGroup key={roleIndex}> <CommandGroup key={roleIndex}>
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium"> <div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
{_(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)} {_(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',
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,
})}
> >
<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}>
<FormField <FormField

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';
@ -386,10 +389,9 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
description={documentFlow.description} description={documentFlow.description}
/> />
<DocumentFlowFormContainerContent> <DocumentFlowFormContainerContent>
{isDocumentPdfLoaded && {isDocumentPdfLoaded && (
fields.map((field, index) => ( <DocumentReadOnlyFields fields={mapFieldsWithRecipients(fields, recipients)} />
<ShowFieldItem key={index} field={field} recipients={recipients} /> )}
))}
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}> <AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}> <Form {...form}>
@ -447,6 +449,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

@ -46,6 +46,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 { Combobox } from '../combobox'; import { Combobox } from '../combobox';
import { import {
DocumentFlowFormContainerActions, DocumentFlowFormContainerActions,
@ -54,7 +58,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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
@ -146,10 +149,9 @@ export const AddTemplateSettingsFormPartial = ({
/> />
<DocumentFlowFormContainerContent> <DocumentFlowFormContainerContent>
{isDocumentPdfLoaded && {isDocumentPdfLoaded && (
fields.map((field, index) => ( <DocumentReadOnlyFields fields={mapFieldsWithRecipients(fields, recipients)} />
<ShowFieldItem key={index} field={field} recipients={recipients} /> )}
))}
<Form {...form}> <Form {...form}>
<fieldset <fieldset