mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 16:23:06 +10:00
chore: merged main
This commit is contained in:
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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',
|
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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) &&
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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('');
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -14,9 +14,13 @@ import { Input } from '@documenso/ui/primitives/input';
|
|||||||
export type SigningVolume = {
|
export type SigningVolume = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
email: string;
|
||||||
signingVolume: number;
|
signingVolume: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
planId: string;
|
planId: string;
|
||||||
|
userId?: number | null;
|
||||||
|
teamId?: number | null;
|
||||||
|
isTeam: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LeaderboardTableProps = {
|
type LeaderboardTableProps = {
|
||||||
|
|||||||
@ -2,7 +2,10 @@ import { Trans } from '@lingui/react/macro';
|
|||||||
|
|
||||||
import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume';
|
import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume';
|
||||||
|
|
||||||
import { AdminLeaderboardTable } from '~/components/tables/admin-leaderboard-table';
|
import {
|
||||||
|
AdminLeaderboardTable,
|
||||||
|
type SigningVolume,
|
||||||
|
} from '~/components/tables/admin-leaderboard-table';
|
||||||
|
|
||||||
import type { Route } from './+types/leaderboard';
|
import type { Route } from './+types/leaderboard';
|
||||||
|
|
||||||
@ -25,7 +28,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
const perPage = Number(url.searchParams.get('perPage')) || 10;
|
const perPage = Number(url.searchParams.get('perPage')) || 10;
|
||||||
const search = url.searchParams.get('search') || '';
|
const search = url.searchParams.get('search') || '';
|
||||||
|
|
||||||
const { leaderboard: signingVolume, totalPages } = await getSigningVolume({
|
const { leaderboard, totalPages } = await getSigningVolume({
|
||||||
search,
|
search,
|
||||||
page,
|
page,
|
||||||
perPage,
|
perPage,
|
||||||
@ -33,8 +36,14 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
sortOrder,
|
sortOrder,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const typedSigningVolume: SigningVolume[] = leaderboard.map((item) => ({
|
||||||
|
...item,
|
||||||
|
name: item.name || '',
|
||||||
|
createdAt: item.createdAt || new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
signingVolume,
|
signingVolume: typedSigningVolume,
|
||||||
totalPages,
|
totalPages,
|
||||||
page,
|
page,
|
||||||
perPage,
|
perPage,
|
||||||
@ -48,9 +57,11 @@ export default function Leaderboard({ loaderData }: Route.ComponentProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-4xl font-semibold">
|
<div className="flex items-center">
|
||||||
<Trans>Signing Volume</Trans>
|
<h2 className="text-4xl font-semibold">
|
||||||
</h2>
|
<Trans>Signing Volume</Trans>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<AdminLeaderboardTable
|
<AdminLeaderboardTable
|
||||||
signingVolume={signingVolume}
|
signingVolume={signingVolume}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
|
||||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import type { Route } from './+types/branding.logo.team.$teamId';
|
import type { Route } from './+types/branding.logo.team.$teamId';
|
||||||
@ -24,27 +24,29 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!settings || !settings.brandingEnabled) {
|
if (!settings || !settings.brandingLogo) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: 'Not found',
|
message: 'Logo not found',
|
||||||
},
|
},
|
||||||
{ status: 404 },
|
{ status: 404 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!settings.brandingLogo) {
|
if (!settings.brandingEnabled) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: 'Not found',
|
message: 'Branding is not enabled',
|
||||||
},
|
},
|
||||||
{ status: 404 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = await getFile(JSON.parse(settings.brandingLogo)).catch(() => null);
|
const file = await getFileServerSide(JSON.parse(settings.brandingLogo)).catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
});
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/sen
|
|||||||
import { SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-joined-email';
|
import { SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-joined-email';
|
||||||
import { SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-left-email';
|
import { SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-left-email';
|
||||||
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
||||||
|
import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook';
|
||||||
import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document';
|
import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -27,6 +28,7 @@ export const jobsClient = new JobClient([
|
|||||||
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
|
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
|
||||||
SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION,
|
SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION,
|
||||||
BULK_SEND_TEMPLATE_JOB_DEFINITION,
|
BULK_SEND_TEMPLATE_JOB_DEFINITION,
|
||||||
|
EXECUTE_WEBHOOK_JOB_DEFINITION,
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
export const jobs = jobsClient;
|
export const jobs = jobsClient;
|
||||||
|
|||||||
@ -0,0 +1,74 @@
|
|||||||
|
import { Prisma, WebhookCallStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import type { JobRunIO } from '../../client/_internal/job';
|
||||||
|
import type { TExecuteWebhookJobDefinition } from './execute-webhook';
|
||||||
|
|
||||||
|
export const run = async ({
|
||||||
|
payload,
|
||||||
|
io,
|
||||||
|
}: {
|
||||||
|
payload: TExecuteWebhookJobDefinition;
|
||||||
|
io: JobRunIO;
|
||||||
|
}) => {
|
||||||
|
const { event, webhookId, data } = payload;
|
||||||
|
|
||||||
|
const webhook = await prisma.webhook.findUniqueOrThrow({
|
||||||
|
where: {
|
||||||
|
id: webhookId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { webhookUrl: url, secret } = webhook;
|
||||||
|
|
||||||
|
await io.runTask('execute-webhook', async () => {
|
||||||
|
const payloadData = {
|
||||||
|
event,
|
||||||
|
payload: data,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
webhookEndpoint: url,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payloadData),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Documenso-Secret': secret ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
let responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput = Prisma.JsonNull;
|
||||||
|
|
||||||
|
try {
|
||||||
|
responseBody = JSON.parse(body);
|
||||||
|
} catch (err) {
|
||||||
|
responseBody = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.webhookCall.create({
|
||||||
|
data: {
|
||||||
|
url,
|
||||||
|
event,
|
||||||
|
status: response.ok ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED,
|
||||||
|
requestBody: payloadData as Prisma.InputJsonValue,
|
||||||
|
responseCode: response.status,
|
||||||
|
responseBody,
|
||||||
|
responseHeaders: Object.fromEntries(response.headers.entries()),
|
||||||
|
webhookId: webhook.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Webhook execution failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: response.ok,
|
||||||
|
status: response.status,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
34
packages/lib/jobs/definitions/internal/execute-webhook.ts
Normal file
34
packages/lib/jobs/definitions/internal/execute-webhook.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { WebhookTriggerEvents } from '@prisma/client';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
|
||||||
|
import { type JobDefinition } from '../../client/_internal/job';
|
||||||
|
|
||||||
|
const EXECUTE_WEBHOOK_JOB_DEFINITION_ID = 'internal.execute-webhook';
|
||||||
|
|
||||||
|
const EXECUTE_WEBHOOK_JOB_DEFINITION_SCHEMA = z.object({
|
||||||
|
event: z.nativeEnum(WebhookTriggerEvents),
|
||||||
|
webhookId: z.string(),
|
||||||
|
data: z.unknown(),
|
||||||
|
requestMetadata: ZRequestMetadataSchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TExecuteWebhookJobDefinition = z.infer<typeof EXECUTE_WEBHOOK_JOB_DEFINITION_SCHEMA>;
|
||||||
|
|
||||||
|
export const EXECUTE_WEBHOOK_JOB_DEFINITION = {
|
||||||
|
id: EXECUTE_WEBHOOK_JOB_DEFINITION_ID,
|
||||||
|
name: 'Execute Webhook',
|
||||||
|
version: '1.0.0',
|
||||||
|
trigger: {
|
||||||
|
name: EXECUTE_WEBHOOK_JOB_DEFINITION_ID,
|
||||||
|
schema: EXECUTE_WEBHOOK_JOB_DEFINITION_SCHEMA,
|
||||||
|
},
|
||||||
|
handler: async ({ payload, io }) => {
|
||||||
|
const handler = await import('./execute-webhook.handler');
|
||||||
|
|
||||||
|
await handler.run({ payload, io });
|
||||||
|
},
|
||||||
|
} as const satisfies JobDefinition<
|
||||||
|
typeof EXECUTE_WEBHOOK_JOB_DEFINITION_ID,
|
||||||
|
TExecuteWebhookJobDefinition
|
||||||
|
>;
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,16 +1,7 @@
|
|||||||
import { DocumentStatus, SubscriptionStatus } from '@prisma/client';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { DocumentStatus, SubscriptionStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
type GetSigningVolumeOptions = {
|
||||||
|
|
||||||
export type SigningVolume = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
signingVolume: number;
|
|
||||||
createdAt: Date;
|
|
||||||
planId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GetSigningVolumeOptions = {
|
|
||||||
search?: string;
|
search?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
perPage?: number;
|
perPage?: number;
|
||||||
@ -18,83 +9,187 @@ export type GetSigningVolumeOptions = {
|
|||||||
sortOrder?: 'asc' | 'desc';
|
sortOrder?: 'asc' | 'desc';
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getSigningVolume({
|
export const getSigningVolume = async ({
|
||||||
search = '',
|
search = '',
|
||||||
page = 1,
|
page = 1,
|
||||||
perPage = 10,
|
perPage = 10,
|
||||||
sortBy = 'signingVolume',
|
sortBy = 'signingVolume',
|
||||||
sortOrder = 'desc',
|
sortOrder = 'desc',
|
||||||
}: GetSigningVolumeOptions) {
|
}: GetSigningVolumeOptions) => {
|
||||||
const offset = Math.max(page - 1, 0) * perPage;
|
const validPage = Math.max(1, page);
|
||||||
|
const validPerPage = Math.max(1, perPage);
|
||||||
|
const skip = (validPage - 1) * validPerPage;
|
||||||
|
|
||||||
let findQuery = kyselyPrisma.$kysely
|
const activeSubscriptions = await prisma.subscription.findMany({
|
||||||
.selectFrom('Subscription as s')
|
where: {
|
||||||
.leftJoin('User as u', 's.userId', 'u.id')
|
status: SubscriptionStatus.ACTIVE,
|
||||||
.leftJoin('Team as t', 's.teamId', 't.id')
|
},
|
||||||
.leftJoin('Document as ud', (join) =>
|
select: {
|
||||||
join
|
id: true,
|
||||||
.onRef('u.id', '=', 'ud.userId')
|
planId: true,
|
||||||
.on('ud.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
userId: true,
|
||||||
.on('ud.deletedAt', 'is', null)
|
teamId: true,
|
||||||
.on('ud.teamId', 'is', null),
|
createdAt: true,
|
||||||
)
|
user: {
|
||||||
.leftJoin('Document as td', (join) =>
|
select: {
|
||||||
join
|
id: true,
|
||||||
.onRef('t.id', '=', 'td.teamId')
|
name: true,
|
||||||
.on('td.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
email: true,
|
||||||
.on('td.deletedAt', 'is', null),
|
createdAt: true,
|
||||||
)
|
},
|
||||||
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
|
},
|
||||||
.where((eb) =>
|
team: {
|
||||||
eb.or([
|
select: {
|
||||||
eb('u.name', 'ilike', `%${search}%`),
|
id: true,
|
||||||
eb('u.email', 'ilike', `%${search}%`),
|
name: true,
|
||||||
eb('t.name', 'ilike', `%${search}%`),
|
teamEmail: {
|
||||||
]),
|
select: {
|
||||||
)
|
email: true,
|
||||||
.select([
|
},
|
||||||
's.id as id',
|
},
|
||||||
's.createdAt as createdAt',
|
createdAt: true,
|
||||||
's.planId as planId',
|
},
|
||||||
sql<string>`COALESCE(u.name, t.name, u.email, 'Unknown')`.as('name'),
|
},
|
||||||
sql<number>`COUNT(DISTINCT ud.id) + COUNT(DISTINCT td.id)`.as('signingVolume'),
|
},
|
||||||
])
|
});
|
||||||
.groupBy(['s.id', 'u.name', 't.name', 'u.email']);
|
|
||||||
|
|
||||||
switch (sortBy) {
|
const userSubscriptionsMap = new Map();
|
||||||
case 'name':
|
const teamSubscriptionsMap = new Map();
|
||||||
findQuery = findQuery.orderBy('name', sortOrder);
|
|
||||||
break;
|
|
||||||
case 'createdAt':
|
|
||||||
findQuery = findQuery.orderBy('createdAt', sortOrder);
|
|
||||||
break;
|
|
||||||
case 'signingVolume':
|
|
||||||
findQuery = findQuery.orderBy('signingVolume', sortOrder);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
findQuery = findQuery.orderBy('signingVolume', 'desc');
|
|
||||||
}
|
|
||||||
|
|
||||||
findQuery = findQuery.limit(perPage).offset(offset);
|
activeSubscriptions.forEach((subscription) => {
|
||||||
|
const isTeam = !!subscription.teamId;
|
||||||
|
|
||||||
const countQuery = kyselyPrisma.$kysely
|
if (isTeam && subscription.teamId) {
|
||||||
.selectFrom('Subscription as s')
|
if (!teamSubscriptionsMap.has(subscription.teamId)) {
|
||||||
.leftJoin('User as u', 's.userId', 'u.id')
|
teamSubscriptionsMap.set(subscription.teamId, {
|
||||||
.leftJoin('Team as t', 's.teamId', 't.id')
|
id: subscription.id,
|
||||||
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
|
planId: subscription.planId,
|
||||||
.where((eb) =>
|
teamId: subscription.teamId,
|
||||||
eb.or([
|
name: subscription.team?.name || '',
|
||||||
eb('u.name', 'ilike', `%${search}%`),
|
email: subscription.team?.teamEmail?.email || `Team ${subscription.team?.id}`,
|
||||||
eb('u.email', 'ilike', `%${search}%`),
|
createdAt: subscription.team?.createdAt,
|
||||||
eb('t.name', 'ilike', `%${search}%`),
|
isTeam: true,
|
||||||
]),
|
subscriptionIds: [subscription.id],
|
||||||
)
|
});
|
||||||
.select(({ fn }) => [fn.countAll().as('count')]);
|
} else {
|
||||||
|
const existingTeam = teamSubscriptionsMap.get(subscription.teamId);
|
||||||
|
existingTeam.subscriptionIds.push(subscription.id);
|
||||||
|
}
|
||||||
|
} else if (subscription.userId) {
|
||||||
|
if (!userSubscriptionsMap.has(subscription.userId)) {
|
||||||
|
userSubscriptionsMap.set(subscription.userId, {
|
||||||
|
id: subscription.id,
|
||||||
|
planId: subscription.planId,
|
||||||
|
userId: subscription.userId,
|
||||||
|
name: subscription.user?.name || '',
|
||||||
|
email: subscription.user?.email || '',
|
||||||
|
createdAt: subscription.user?.createdAt,
|
||||||
|
isTeam: false,
|
||||||
|
subscriptionIds: [subscription.id],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const existingUser = userSubscriptionsMap.get(subscription.userId);
|
||||||
|
existingUser.subscriptionIds.push(subscription.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
|
const subscriptions = [
|
||||||
|
...Array.from(userSubscriptionsMap.values()),
|
||||||
|
...Array.from(teamSubscriptionsMap.values()),
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredSubscriptions = search
|
||||||
|
? subscriptions.filter((sub) => {
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
return (
|
||||||
|
sub.name?.toLowerCase().includes(searchLower) ||
|
||||||
|
sub.email?.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: subscriptions;
|
||||||
|
|
||||||
|
const signingVolume = await Promise.all(
|
||||||
|
filteredSubscriptions.map(async (subscription) => {
|
||||||
|
let signingVolume = 0;
|
||||||
|
|
||||||
|
if (subscription.userId && !subscription.isTeam) {
|
||||||
|
const personalCount = await prisma.document.count({
|
||||||
|
where: {
|
||||||
|
userId: subscription.userId,
|
||||||
|
status: DocumentStatus.COMPLETED,
|
||||||
|
teamId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
signingVolume += personalCount;
|
||||||
|
|
||||||
|
const userTeams = await prisma.teamMember.findMany({
|
||||||
|
where: {
|
||||||
|
userId: subscription.userId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
teamId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userTeams.length > 0) {
|
||||||
|
const teamIds = userTeams.map((team) => team.teamId);
|
||||||
|
const teamCount = await prisma.document.count({
|
||||||
|
where: {
|
||||||
|
teamId: {
|
||||||
|
in: teamIds,
|
||||||
|
},
|
||||||
|
status: DocumentStatus.COMPLETED,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
signingVolume += teamCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.teamId) {
|
||||||
|
const teamCount = await prisma.document.count({
|
||||||
|
where: {
|
||||||
|
teamId: subscription.teamId,
|
||||||
|
status: DocumentStatus.COMPLETED,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
signingVolume += teamCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...subscription,
|
||||||
|
signingVolume,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortedResults = [...signingVolume].sort((a, b) => {
|
||||||
|
if (sortBy === 'name') {
|
||||||
|
return sortOrder === 'asc'
|
||||||
|
? (a.name || '').localeCompare(b.name || '')
|
||||||
|
: (b.name || '').localeCompare(a.name || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortBy === 'createdAt') {
|
||||||
|
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||||
|
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||||
|
return sortOrder === 'asc' ? dateA - dateB : dateB - dateA;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortOrder === 'asc'
|
||||||
|
? a.signingVolume - b.signingVolume
|
||||||
|
: b.signingVolume - a.signingVolume;
|
||||||
|
});
|
||||||
|
|
||||||
|
const paginatedResults = sortedResults.slice(skip, skip + validPerPage);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(sortedResults.length / validPerPage);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
leaderboard: results,
|
leaderboard: paginatedResults,
|
||||||
totalPages: Math.ceil(Number(count) / perPage),
|
totalPages,
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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');
|
||||||
|
}
|
||||||
|
|||||||
395
packages/lib/server-only/pdf/legacy-insert-field-in-pdf.ts
Normal file
395
packages/lib/server-only/pdf/legacy-insert-field-in-pdf.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -1,54 +0,0 @@
|
|||||||
import { Prisma, type Webhook, WebhookCallStatus, type WebhookTriggerEvents } from '@prisma/client';
|
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
|
|
||||||
export type ExecuteWebhookOptions = {
|
|
||||||
event: WebhookTriggerEvents;
|
|
||||||
webhook: Webhook;
|
|
||||||
data: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const executeWebhook = async ({ event, webhook, data }: ExecuteWebhookOptions) => {
|
|
||||||
const { webhookUrl: url, secret } = webhook;
|
|
||||||
|
|
||||||
console.log('Executing webhook', { event, url });
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
event,
|
|
||||||
payload: data,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
webhookEndpoint: url,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Documenso-Secret': secret ?? '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const body = await response.text();
|
|
||||||
|
|
||||||
let responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput = Prisma.JsonNull;
|
|
||||||
|
|
||||||
try {
|
|
||||||
responseBody = JSON.parse(body);
|
|
||||||
} catch (err) {
|
|
||||||
responseBody = body;
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.webhookCall.create({
|
|
||||||
data: {
|
|
||||||
url,
|
|
||||||
event,
|
|
||||||
status: response.ok ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED,
|
|
||||||
requestBody: payload as Prisma.InputJsonValue,
|
|
||||||
responseCode: response.status,
|
|
||||||
responseBody,
|
|
||||||
responseHeaders: Object.fromEntries(response.headers.entries()),
|
|
||||||
webhookId: webhook.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
|
import { jobs } from '../../../jobs/client';
|
||||||
import { verify } from '../../crypto/verify';
|
import { verify } from '../../crypto/verify';
|
||||||
import { getAllWebhooksByEventTrigger } from '../get-all-webhooks-by-event-trigger';
|
import { getAllWebhooksByEventTrigger } from '../get-all-webhooks-by-event-trigger';
|
||||||
import { executeWebhook } from './execute-webhook';
|
|
||||||
import { ZTriggerWebhookBodySchema } from './schema';
|
import { ZTriggerWebhookBodySchema } from './schema';
|
||||||
|
|
||||||
export type HandlerTriggerWebhooksResponse =
|
export type HandlerTriggerWebhooksResponse =
|
||||||
@ -42,17 +42,20 @@ export const handlerTriggerWebhooks = async (req: Request) => {
|
|||||||
const allWebhooks = await getAllWebhooksByEventTrigger({ event, userId, teamId });
|
const allWebhooks = await getAllWebhooksByEventTrigger({ event, userId, teamId });
|
||||||
|
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
allWebhooks.map(async (webhook) =>
|
allWebhooks.map(async (webhook) => {
|
||||||
executeWebhook({
|
await jobs.triggerJob({
|
||||||
event,
|
name: 'internal.execute-webhook',
|
||||||
webhook,
|
payload: {
|
||||||
data,
|
event,
|
||||||
}),
|
webhookId: webhook.id,
|
||||||
),
|
data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ success: true, message: 'Webhooks executed successfully' },
|
{ success: true, message: 'Webhooks queued for execution' },
|
||||||
{ status: 200 },
|
{ status: 200 },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Document" ALTER COLUMN "useLegacyFieldInsertion" SET DEFAULT false;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Template" ALTER COLUMN "useLegacyFieldInsertion" SET DEFAULT false;
|
||||||
@ -351,20 +351,22 @@ 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
|
||||||
|
|
||||||
auditLogs DocumentAuditLog[]
|
auditLogs DocumentAuditLog[]
|
||||||
attachments Attachment[]
|
attachments Attachment[]
|
||||||
|
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)
|
||||||
|
|
||||||
@@unique([documentDataId])
|
@@unique([documentDataId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@ -731,6 +733,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)
|
||||||
|
|||||||
@ -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))',
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
170
packages/ui/components/document/document-read-only-fields.tsx
Normal file
170
packages/ui/components/document/document-read-only-fields.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
95
packages/ui/lib/recipient-colors.ts
Normal file
95
packages/ui/lib/recipient-colors.ts
Normal 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);
|
||||||
|
};
|
||||||
@ -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);
|
|
||||||
};
|
|
||||||
@ -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">
|
||||||
|
|||||||
@ -25,6 +25,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,
|
||||||
@ -61,7 +65,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 = {
|
||||||
@ -173,10 +176,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
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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) =>
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
188
packages/ui/primitives/document-flow/field-content.tsx
Normal file
188
packages/ui/primitives/document-flow/field-content.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -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, {
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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}`}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user