Merge branch 'main' into feat/signing-reminders

This commit is contained in:
Ephraim Atta-Duncan
2025-08-22 05:05:00 +00:00
977 changed files with 92471 additions and 41466 deletions

View File

@ -1,51 +1,75 @@
import { forwardRef } from 'react';
import React from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export const DocumentGlobalAuthAccessSelect = forwardRef<HTMLButtonElement, SelectProps>(
(props, ref) => {
const { _ } = useLingui();
export interface DocumentGlobalAuthAccessSelectProps {
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
disabled?: boolean;
placeholder?: string;
}
return (
<Select {...props}>
<SelectTrigger ref={ref} className="bg-background text-muted-foreground">
<SelectValue
data-testid="documentAccessSelectValue"
placeholder={_(msg`No restrictions`)}
/>
</SelectTrigger>
export const DocumentGlobalAuthAccessSelect = ({
value,
defaultValue,
onValueChange,
disabled,
placeholder,
}: DocumentGlobalAuthAccessSelectProps) => {
const { _ } = useLingui();
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>
<Trans>No restrictions</Trans>
</SelectItem>
// Convert auth types to MultiSelect options
const authOptions: Option[] = [
{
value: '-1',
label: _(msg`No restrictions`),
},
...Object.values(DocumentAccessAuth).map((authType) => ({
value: authType,
label: DOCUMENT_AUTH_TYPES[authType].value,
})),
];
{Object.values(DocumentAccessAuth).map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
);
},
);
// Convert string array to Option array for MultiSelect
const selectedOptions =
(value
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
// Convert default value to Option array
const defaultOptions =
(defaultValue
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
const handleChange = (options: Option[]) => {
const values = options.map((option) => option.value);
onValueChange?.(values);
};
return (
<MultiSelect
value={selectedOptions}
defaultOptions={defaultOptions}
options={authOptions}
onChange={handleChange}
disabled={disabled}
placeholder={placeholder || _(msg`Select access methods`)}
className="bg-background text-muted-foreground"
hideClearAllButton={false}
data-testid="documentAccessSelectValue"
/>
);
};
DocumentGlobalAuthAccessSelect.displayName = 'DocumentGlobalAuthAccessSelect';
@ -63,7 +87,11 @@ export const DocumentGlobalAuthAccessTooltip = () => (
</h2>
<p>
<Trans>The authentication required for recipients to view the document.</Trans>
<Trans>The authentication methods required for recipients to view the document.</Trans>
</p>
<p className="mt-2">
<Trans>Multiple access methods can be selected.</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">

View File

@ -1,54 +1,75 @@
import { forwardRef } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { DocumentActionAuth, DocumentAuth } from '@documenso/lib/types/document-auth';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export const DocumentGlobalAuthActionSelect = forwardRef<HTMLButtonElement, SelectProps>(
(props, ref) => {
const { _ } = useLingui();
export interface DocumentGlobalAuthActionSelectProps {
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
disabled?: boolean;
placeholder?: string;
}
return (
<Select {...props}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue
ref={ref}
data-testid="documentActionSelectValue"
placeholder={_(msg`No restrictions`)}
/>
</SelectTrigger>
export const DocumentGlobalAuthActionSelect = ({
value,
defaultValue,
onValueChange,
disabled,
placeholder,
}: DocumentGlobalAuthActionSelectProps) => {
const { _ } = useLingui();
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>
<Trans>No restrictions</Trans>
</SelectItem>
// Convert auth types to MultiSelect options
const authOptions: Option[] = [
{
value: '-1',
label: _(msg`No restrictions`),
},
...Object.values(DocumentActionAuth)
.filter((auth) => auth !== DocumentAuth.ACCOUNT)
.map((authType) => ({
value: authType,
label: DOCUMENT_AUTH_TYPES[authType].value,
})),
];
{Object.values(DocumentActionAuth)
.filter((auth) => auth !== DocumentAuth.ACCOUNT)
.map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
);
},
);
// Convert string array to Option array for MultiSelect
const selectedOptions =
(value
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
// Convert default value to Option array
const defaultOptions =
(defaultValue
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
const handleChange = (options: Option[]) => {
const values = options.map((option) => option.value);
onValueChange?.(values);
};
return (
<MultiSelect
value={selectedOptions}
defaultOptions={defaultOptions}
options={authOptions}
onChange={handleChange}
disabled={disabled}
placeholder={placeholder || _(msg`Select authentication methods`)}
className="bg-background text-muted-foreground"
hideClearAllButton={false}
data-testid="documentActionSelectValue"
/>
);
};
DocumentGlobalAuthActionSelect.displayName = 'DocumentGlobalAuthActionSelect';
@ -64,20 +85,19 @@ export const DocumentGlobalAuthActionTooltip = () => (
</h2>
<p>
<Trans>The authentication required for recipients to sign the signature field.</Trans>
<Trans>
The authentication methods required for recipients to sign the signature field.
</Trans>
</p>
<p>
<Trans>
This can be overriden by setting the authentication requirements directly on each
recipient in the next step.
These can be overriden by setting the authentication requirements directly on each
recipient in the next step. Multiple methods can be selected.
</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
{/* <li>
<strong>Require account</strong> - The recipient must be signed in
</li> */}
<li>
<Trans>
<strong>Require passkey</strong> - The recipient must have an account and passkey
@ -90,6 +110,14 @@ export const DocumentGlobalAuthActionTooltip = () => (
their settings
</Trans>
</li>
<li>
<Trans>
<strong>Require password</strong> - The recipient must have an account and password
configured via their settings, the password will be verified during signing
</Trans>
</li>
<li>
<Trans>
<strong>No restrictions</strong> - No authentication required

View File

@ -0,0 +1,181 @@
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 { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
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';
const getRecipientDisplayText = (recipient: { name: string; email: string }) => {
if (recipient.name && !isTemplateRecipientEmailPlaceholder(recipient.email)) {
return `${recipient.name} (${recipient.email})`;
}
if (recipient.name && isTemplateRecipientEmailPlaceholder(recipient.email)) {
return recipient.name;
}
return recipient.email;
};
export type DocumentReadOnlyFieldsProps = {
fields: DocumentField[];
documentMeta?: Pick<DocumentMeta | TemplateMeta, 'dateFormat'>;
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">
{getRecipientDisplayText(field.recipient)}
</p>
<button
className="absolute right-0 top-0 my-1 p-2 focus:outline-none focus-visible:ring-0"
onClick={() => handleHideField(field.secondaryId)}
title="Hide field"
>
<EyeOffIcon className="h-3 w-3" />
</button>
</PopoverHover>
</div>
)}
<FieldContent field={field} documentMeta={documentMeta} />
</FieldRootContainer>
),
)}
</ElementVisible>
);
};

View File

@ -6,7 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
export const DocumentSignatureSettingsTooltip = () => {
return (
<Tooltip>
<TooltipTrigger>
<TooltipTrigger type="button">
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>

View File

@ -38,6 +38,14 @@ interface FieldToolTipProps extends VariantProps<typeof tooltipVariants> {
export function FieldToolTip({ children, color, className = '', field }: FieldToolTipProps) {
const coords = useFieldPageCoords(field);
const onTooltipContentClick = () => {
const $fieldEl = document.querySelector<HTMLButtonElement>(`#field-${field.id} > button`);
if ($fieldEl) {
$fieldEl.click();
}
};
return createPortal(
<div
className={cn('pointer-events-none absolute')}
@ -52,7 +60,11 @@ export function FieldToolTip({ children, color, className = '', field }: FieldTo
<Tooltip delayDuration={0} open={!field.inserted || !field.fieldMeta}>
<TooltipTrigger className="absolute inset-0 w-full"></TooltipTrigger>
<TooltipContent className={tooltipVariants({ color, className })} sideOffset={2}>
<TooltipContent
className={tooltipVariants({ color, className })}
sideOffset={2}
onClick={onTooltipContentClick}
>
{children}
<TooltipArrow />
</TooltipContent>

View File

@ -1,71 +1,20 @@
import React, { useEffect, useMemo, useState } from 'react';
import type { Field } from '@prisma/client';
import { type Field, FieldType } from '@prisma/client';
import { createPortal } from 'react-dom';
import { useElementBounds } from '@documenso/lib/client-only/hooks/use-element-bounds';
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import type { RecipientColorStyles } from '../../lib/recipient-colors';
import { cn } from '../../lib/utils';
import { Card, CardContent } from '../../primitives/card';
export type FieldRootContainerProps = {
field: Field;
children: React.ReactNode;
};
export type FieldContainerPortalProps = {
field: Field;
className?: string;
children: React.ReactNode;
cardClassName?: string;
};
const getCardClassNames = (
field: Field,
parsedField: TFieldMetaSchema | null,
isValidating: boolean,
checkBoxOrRadio: boolean,
cardClassName?: string,
) => {
const baseClasses =
'field--FieldRootContainer field-card-container relative z-20 h-full w-full transition-all';
const insertedClasses =
'bg-primary/20 border-primary ring-primary/20 ring-offset-primary/20 ring-2 ring-offset-2 dark:shadow-none';
const nonRequiredClasses =
'border-yellow-300 shadow-none ring-2 ring-yellow-100 ring-offset-2 ring-offset-yellow-100 dark:border-2';
const validatingClasses = 'border-orange-300 ring-1 ring-orange-300';
const requiredClasses =
'border-red-500 shadow-none ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 hover:text-red-500';
const requiredCheckboxRadioClasses = 'border-dashed border-red-500';
if (checkBoxOrRadio) {
return cn(
{
[insertedClasses]: field.inserted,
'ring-offset-yellow-200 border-dashed border-yellow-300 ring-2 ring-yellow-200 ring-offset-2 dark:shadow-none':
!field.inserted && !parsedField?.required,
'shadow-none': !field.inserted,
[validatingClasses]: !field.inserted && isValidating,
[requiredCheckboxRadioClasses]: !field.inserted && parsedField?.required,
},
cardClassName,
);
}
return cn(
baseClasses,
{
[insertedClasses]: field.inserted,
[nonRequiredClasses]: !field.inserted && !parsedField?.required,
'shadow-none': !field.inserted && checkBoxOrRadio,
[validatingClasses]: !field.inserted && isValidating,
[requiredClasses]: !field.inserted && parsedField?.required && !checkBoxOrRadio,
},
cardClassName,
);
};
export function FieldContainerPortal({
@ -73,31 +22,57 @@ export function FieldContainerPortal({
children,
className = '',
}: FieldContainerPortalProps) {
const alternativePortalRoot = document.getElementById('document-field-portal-root');
const coords = useFieldPageCoords(field);
const $pageBounds = useElementBounds(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
const maxWidth = $pageBounds?.width ? $pageBounds.width - coords.x : undefined;
const isCheckboxOrRadioField = field.type === 'CHECKBOX' || field.type === 'RADIO';
const isFieldSigned = field.inserted;
const style = {
top: `${coords.y}px`,
left: `${coords.x}px`,
// height: `${coords.height}px`,
// width: `${coords.width}px`,
...(!isCheckboxOrRadioField && {
height: `${coords.height}px`,
width: `${coords.width}px`,
}),
};
const style = useMemo(() => {
const portalBounds = alternativePortalRoot?.getBoundingClientRect();
const bounds = {
top: `${coords.y}px`,
left: `${coords.x}px`,
...(!isCheckboxOrRadioField
? {
height: `${coords.height}px`,
width: `${coords.width}px`,
}
: {
maxWidth: `${maxWidth}px`,
}),
};
if (portalBounds) {
bounds.top = `${coords.y - portalBounds.top}px`;
bounds.left = `${coords.x - portalBounds.left}px`;
}
return bounds;
}, [coords, maxWidth, isCheckboxOrRadioField]);
return createPortal(
<div className={cn('absolute', className)} style={style}>
{children}
</div>,
document.body,
alternativePortalRoot ?? document.body,
);
}
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 ref = React.useRef<HTMLDivElement>(null);
@ -121,33 +96,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 (
<FieldContainerPortal field={field}>
<Card
<div
id={`field-${field.id}`}
ref={ref}
data-field-type={field.type}
data-inserted={field.inserted ? 'true' : 'false'}
className={cardClassNames}
className={cn(
'field--FieldRootContainer field-card-container 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}
</CardContent>
</Card>
{children}
</div>
</FieldContainerPortal>
);
}

View File

@ -3,97 +3,124 @@ import React from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { RecipientActionAuth } from '@documenso/lib/types/document-auth';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export type RecipientActionAuthSelectProps = SelectProps;
export interface RecipientActionAuthSelectProps {
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
disabled?: boolean;
placeholder?: string;
}
export const RecipientActionAuthSelect = (props: RecipientActionAuthSelectProps) => {
export const RecipientActionAuthSelect = ({
value,
defaultValue,
onValueChange,
disabled,
placeholder,
}: RecipientActionAuthSelectProps) => {
const { _ } = useLingui();
// Convert auth types to MultiSelect options
const authOptions: Option[] = [
{
value: '-1',
label: _(msg`Inherit authentication method`),
},
...Object.values(RecipientActionAuth)
.filter((auth) => auth !== RecipientActionAuth.ACCOUNT)
.map((authType) => ({
value: authType,
label: DOCUMENT_AUTH_TYPES[authType].value,
})),
];
// Convert string array to Option array for MultiSelect
const selectedOptions =
(value
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
// Convert default value to Option array
const defaultOptions =
(defaultValue
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
const handleChange = (options: Option[]) => {
const values = options.map((option) => option.value);
onValueChange?.(values);
};
return (
<Select {...props}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue placeholder={_(msg`Inherit authentication method`)} />
<div className="relative">
<MultiSelect
value={selectedOptions}
defaultOptions={defaultOptions}
options={authOptions}
onChange={handleChange}
disabled={disabled}
placeholder={placeholder || _(msg`Select authentication methods`)}
className="bg-background text-muted-foreground"
maxSelected={4} // Allow selecting up to 4 auth methods
hideClearAllButton={false}
/>
<Tooltip>
<TooltipTrigger className="-mr-1 ml-auto">
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<Tooltip>
<TooltipTrigger className="absolute right-2 top-1/2 -translate-y-1/2">
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md p-4">
<h2>
<strong>
<Trans>Recipient action authentication</Trans>
</strong>
</h2>
<TooltipContent className="text-foreground max-w-md p-4">
<h2>
<strong>
<Trans>Recipient action authentication</Trans>
</strong>
</h2>
<p>
<Trans>The authentication required for recipients to sign fields</Trans>
</p>
<p>
<Trans>The authentication methods required for recipients to sign fields</Trans>
</p>
<p className="mt-2">
<Trans>This will override any global settings.</Trans>
</p>
<p className="mt-2">
<Trans>
These will override any global settings. Multiple methods can be selected.
</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<Trans>
<strong>Inherit authentication method</strong> - Use the global action signing
authentication method configured in the "General Settings" step
</Trans>
</li>
{/* <li>
<strong>Require account</strong> - The recipient must be
signed in
</li> */}
<li>
<Trans>
<strong>Require passkey</strong> - The recipient must have an account and passkey
configured via their settings
</Trans>
</li>
<li>
<Trans>
<strong>Require 2FA</strong> - The recipient must have an account and 2FA enabled
via their settings
</Trans>
</li>
<li>
<Trans>
<strong>None</strong> - No authentication required
</Trans>
</li>
</ul>
</TooltipContent>
</Tooltip>
</SelectTrigger>
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value="-1">
<Trans>Inherit authentication method</Trans>
</SelectItem>
{Object.values(RecipientActionAuth)
.filter((auth) => auth !== RecipientActionAuth.ACCOUNT)
.map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<Trans>
<strong>Inherit authentication method</strong> - Use the global action signing
authentication method configured in the "General Settings" step
</Trans>
</li>
<li>
<Trans>
<strong>Require passkey</strong> - The recipient must have an account and passkey
configured via their settings
</Trans>
</li>
<li>
<Trans>
<strong>Require 2FA</strong> - The recipient must have an account and 2FA enabled
via their settings
</Trans>
</li>
<li>
<Trans>
<strong>None</strong> - No authentication required
</Trans>
</li>
</ul>
</TooltipContent>
</Tooltip>
</div>
);
};

View File

@ -24,7 +24,7 @@ export const SigningCard = ({
signingCelebrationImage,
}: SigningCardProps) => {
return (
<div className={cn('relative w-full max-w-xs md:max-w-sm', className)}>
<div className={cn('relative w-full max-w-sm md:max-w-md', className)}>
<SigningCardContent name={name} signature={signature} />
{signingCelebrationImage && (
@ -48,7 +48,7 @@ export const SigningCard3D = ({
const [trackMouse, setTrackMouse] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout>();
const timeoutRef = useRef<number | undefined>();
const cardX = useMotionValue(0);
const cardY = useMotionValue(0);
@ -103,7 +103,7 @@ export const SigningCard3D = ({
clearTimeout(timeoutRef.current);
// Revert the card back to the center position after the mouse stops moving.
timeoutRef.current = setTimeout(() => {
timeoutRef.current = window.setTimeout(() => {
void animate(cardX, 0, { duration: 2, ease: 'backInOut' });
void animate(cardY, 0, { duration: 2, ease: 'backInOut' });
@ -120,12 +120,15 @@ export const SigningCard3D = ({
return () => {
window.removeEventListener('mousemove', onMouseMove);
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
};
}, [onMouseMove]);
return (
<div
className={cn('relative w-full max-w-xs md:max-w-sm', className)}
className={cn('relative w-full max-w-sm md:max-w-md', className)}
style={{ perspective: 800 }}
>
<motion.div

View File

@ -0,0 +1,104 @@
// !: 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 = {
readOnly: {
base: 'ring-neutral-400',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: '',
comboxBoxTrigger:
'ring-2 ring-recipient-green 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: '',
},
green: {
base: 'ring-recipient-green hover:bg-recipient-green/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-green',
comboxBoxTrigger:
'ring-2 ring-recipient-green hover:bg-recipient-green/15 active:bg-recipient-green/15 shadow-[0_0_0_5px_hsl(var(--recipient-green)/10%),0_0_0_2px_hsl(var(--recipient-green)/60%),0_0_0_0.5px_hsl(var(--recipient-green))]',
comboxBoxItem: 'hover:bg-recipient-green/15 active:bg-recipient-green/15',
},
blue: {
base: 'ring-recipient-blue hover:bg-recipient-blue/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-blue',
comboxBoxTrigger:
'ring-2 ring-recipient-blue hover:bg-recipient-blue/15 active:bg-recipient-blue/15 shadow-[0_0_0_5px_hsl(var(--recipient-blue)/10%),0_0_0_2px_hsl(var(--recipient-blue)/60%),0_0_0_0.5px_hsl(var(--recipient-blue))]',
comboxBoxItem: 'ring-recipient-blue hover:bg-recipient-blue/15 active:bg-recipient-blue/15',
},
purple: {
base: 'ring-recipient-purple hover:bg-recipient-purple/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-purple',
comboxBoxTrigger:
'ring-2 ring-recipient-purple hover:bg-recipient-purple/15 active:bg-recipient-purple/15 shadow-[0_0_0_5px_hsl(var(--recipient-purple)/10%),0_0_0_2px_hsl(var(--recipient-purple)/60%),0_0_0_0.5px_hsl(var(--recipient-purple))]',
comboxBoxItem: 'hover:bg-recipient-purple/15 active:bg-recipient-purple/15',
},
orange: {
base: 'ring-recipient-orange hover:bg-recipient-orange/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-orange',
comboxBoxTrigger:
'ring-2 ring-recipient-orange hover:bg-recipient-orange/15 active:bg-recipient-orange/15 shadow-[0_0_0_5px_hsl(var(--recipient-orange)/10%),0_0_0_2px_hsl(var(--recipient-orange)/60%),0_0_0_0.5px_hsl(var(--recipient-orange))]',
comboxBoxItem: 'hover:bg-recipient-orange/15 active:bg-recipient-orange/15',
},
yellow: {
base: 'ring-recipient-yellow hover:bg-recipient-yellow/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-yellow',
comboxBoxTrigger:
'ring-2 ring-recipient-yellow hover:bg-recipient-yellow/15 active:bg-recipient-yellow/15 shadow-[0_0_0_5px_hsl(var(--recipient-yellow)/10%),0_0_0_2px_hsl(var(--recipient-yellow)/60%),0_0_0_0.5px_hsl(var(--recipient-yellow))]',
comboxBoxItem: 'hover:bg-recipient-yellow/15 active:bg-recipient-yellow/15',
},
pink: {
base: 'ring-recipient-pink hover:bg-recipient-pink/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-pink',
comboxBoxTrigger:
'ring-2 ring-recipient-pink hover:bg-recipient-pink/15 active:bg-recipient-pink/15 shadow-[0_0_0_5px_hsl(var(--recipient-pink)/10%),0_0_0_2px_hsl(var(--recipient-pink)/60%),0_0_0_0.5px_hsl(var(--recipient-pink',
comboxBoxItem: 'hover:bg-recipient-pink/15 active:bg-recipient-pink/15',
},
} satisfies Record<string, RecipientColorStyles>;
export type CombinedStylesKey = keyof typeof RECIPIENT_COLOR_STYLES;
export const AVAILABLE_RECIPIENT_COLORS = [
'green',
'blue',
'purple',
'orange',
'yellow',
'pink',
] satisfies CombinedStylesKey[];
export const useRecipientColors = (index: number) => {
const key = AVAILABLE_RECIPIENT_COLORS[index % AVAILABLE_RECIPIENT_COLORS.length];
return RECIPIENT_COLOR_STYLES[key];
};
export const getRecipientColorStyles = (index: number) => {
// Disabling the rule since the hook doesn't do anything special and can
// be used universally.
// eslint-disable-next-line react-hooks/rules-of-hooks
return useRecipientColors(index);
};

View File

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

View File

@ -0,0 +1,15 @@
import React, { useEffect } from 'react';
export const useDebounce = <T>(value: T, delay?: number): T => {
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
};

View File

@ -39,7 +39,7 @@ const Alert = React.forwardRef<
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant, padding }), className)}
className={cn('space-y-2', alertVariants({ variant, padding }), className)}
{...props}
/>
));

View File

@ -15,8 +15,10 @@ type ComboboxProps = {
options: string[];
value: string | null;
onChange: (_value: string | null) => void;
triggerPlaceholder?: string;
placeholder?: string;
disabled?: boolean;
testId?: string;
};
const Combobox = ({
@ -25,7 +27,9 @@ const Combobox = ({
value,
onChange,
disabled = false,
triggerPlaceholder,
placeholder,
testId,
}: ComboboxProps) => {
const { _ } = useLingui();
@ -47,8 +51,9 @@ const Combobox = ({
aria-expanded={open}
className={cn('my-2 w-full justify-between', className)}
disabled={disabled}
data-testid={testId}
>
{value ? value : placeholderValue}
{value ? value : triggerPlaceholder || placeholderValue}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>

View File

@ -26,6 +26,7 @@ export interface DataTableProps<TData, TValue> {
totalPages?: number;
onPaginationChange?: (_page: number, _perPage: number) => void;
onClearFilters?: () => void;
emptyState?: React.ReactNode;
hasFilters?: boolean;
children?: DataTableChildren<TData>;
skeleton?: {
@ -52,6 +53,7 @@ export function DataTable<TData, TValue>({
onClearFilters,
onPaginationChange,
children,
emptyState,
}: DataTableProps<TData, TValue>) {
const pagination = useMemo<PaginationState>(() => {
if (currentPage !== undefined && perPage !== undefined) {
@ -142,17 +144,21 @@ export function DataTable<TData, TValue>({
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-32 text-center">
<p>
<Trans>No results found</Trans>
</p>
{emptyState ?? (
<>
<p>
<Trans>No results found</Trans>
</p>
{hasFilters && onClearFilters !== undefined && (
<button
onClick={() => onClearFilters()}
className="text-foreground mt-1 text-sm"
>
<Trans>Clear filters</Trans>
</button>
{hasFilters && onClearFilters !== undefined && (
<button
onClick={() => onClearFilters()}
className="text-foreground mt-1 text-sm"
>
<Trans>Clear filters</Trans>
</button>
)}
</>
)}
</TableCell>
</TableRow>

View File

@ -65,7 +65,11 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
'bg-background animate-in data-[state=open]:fade-in-90 sm:zoom-in-90 data-[state=open]:slide-in-from-bottom-10 data-[state=open]:sm:slide-in-from-bottom-0 fixed z-50 grid w-full gap-4 rounded-b-lg border p-6 shadow-lg sm:max-w-lg sm:rounded-lg',
'bg-background animate-in data-[state=open]:fade-in-90 sm:zoom-in-90 data-[state=open]:slide-in-from-bottom-10 data-[state=open]:sm:slide-in-from-bottom-0 fixed z-50 grid w-full gap-4 border p-6 shadow-lg sm:max-w-lg sm:rounded-lg',
{
'rounded-b-xl': position === 'start',
'rounded-t-xl': position === 'end',
},
className,
)}
{...props}

View File

@ -7,6 +7,7 @@ import { AlertTriangle, Plus } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { Link } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
@ -44,6 +45,8 @@ export const DocumentDropzone = ({
}: DocumentDropzoneProps) => {
const { _ } = useLingui();
const organisation = useCurrentOrganisation();
const { getRootProps, getInputProps } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
@ -158,7 +161,7 @@ export const DocumentDropzone = ({
{disabled && IS_BILLING_ENABLED() && (
<Button className="hover:bg-warning/80 bg-warning mt-4 w-32" asChild>
<Link to="/settings/billing">
<Link to={`/o/${organisation.url}/settings/billing`}>
<Trans>Upgrade</Trans>
</Link>
</Button>

View File

@ -3,9 +3,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Prisma } 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 {
CalendarDays,
CheckSquare,
@ -38,7 +37,7 @@ import {
} from '@documenso/lib/utils/recipients';
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 { Alert, AlertDescription } from '../alert';
import { Card, CardContent } from '../card';
@ -86,7 +85,7 @@ export type AddFieldsFormProps = {
onSubmit: (_data: TAddFieldsFormSchema) => void;
canGoBack?: boolean;
isDocumentPdfLoaded: boolean;
teamId?: number;
teamId: number;
};
export const AddFieldsFormPartial = ({
@ -167,7 +166,6 @@ export const AddFieldsFormPartial = ({
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const [lastActiveField, setLastActiveField] = useState<TAddFieldsFormSchema['fields'][0] | null>(
null,
);
@ -175,9 +173,10 @@ export const AddFieldsFormPartial = ({
null,
);
const selectedSignerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id);
const selectedSignerStyles = useSignerColors(
const selectedSignerStyles = useRecipientColors(
selectedSignerIndex === -1 ? 0 : selectedSignerIndex,
);
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const filterFieldsWithEmptyValues = (fields: typeof localFields, fieldType: string) =>
@ -400,35 +399,60 @@ export const AddFieldsFormPartial = ({
);
const onFieldCopy = useCallback(
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => {
const { duplicate = false } = options ?? {};
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
const { duplicate = false, duplicateAll = false } = options ?? {};
if (lastActiveField) {
event?.preventDefault();
if (!duplicate) {
setFieldClipboard(lastActiveField);
if (duplicate) {
const newField: TAddFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3,
};
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
append(newField);
return;
}
if (duplicateAll) {
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
pages.forEach((_, index) => {
const pageNumber = index + 1;
if (pageNumber === lastActiveField.pageNumber) {
return;
}
const newField: TAddFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
pageNumber,
};
append(newField);
});
return;
}
const newField: TAddFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3,
};
setFieldClipboard(lastActiveField);
append(newField);
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
});
}
},
[append, lastActiveField, selectedSigner?.email, toast],
[append, lastActiveField, selectedSigner?.email, selectedSigner?.id, toast],
);
const onFieldPaste = useCallback(
@ -440,6 +464,7 @@ export const AddFieldsFormPartial = ({
append({
...copiedField,
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
pageX: copiedField.pageX + 3,
@ -579,7 +604,6 @@ export const AddFieldsFormPartial = ({
onAdvancedSettings={handleAdvancedSettings}
isDocumentPdfLoaded={isDocumentPdfLoaded}
onSave={handleSavedFieldSettings}
teamId={teamId}
/>
) : (
<>
@ -593,13 +617,12 @@ export const AddFieldsFormPartial = ({
{selectedField && (
<div
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]',
selectedSignerStyles.default.base,
'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?.base,
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
'dark:text-black/60': isFieldWithinBounds,
},
// selectedField === FieldType.SIGNATURE && fontCaveat.className,
)}
style={{
top: coords.y,
@ -638,15 +661,17 @@ export const AddFieldsFormPartial = ({
passive={isFieldWithinBounds && !!selectedField}
onFocus={() => setLastActiveField(field)}
onBlur={() => setLastActiveField(null)}
onMouseEnter={() => setLastActiveField(field)}
onMouseLeave={() => setLastActiveField(null)}
onResize={(options) => onFieldResize(options, index)}
onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)}
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
onAdvancedSettings={() => {
setCurrentField(field);
handleAdvancedSettings();
}}
hideRecipients={hideRecipients}
hasErrors={!!hasFieldError}
active={activeFieldId === field.formId}
onFieldActivate={() => setActiveFieldId(field.formId)}
@ -677,7 +702,6 @@ export const AddFieldsFormPartial = ({
<Card
className={cn(
'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">
@ -702,7 +726,6 @@ export const AddFieldsFormPartial = ({
<Card
className={cn(
'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">
@ -728,7 +751,6 @@ export const AddFieldsFormPartial = ({
<Card
className={cn(
'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">
@ -754,7 +776,6 @@ export const AddFieldsFormPartial = ({
<Card
className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
<CardContent className="p-4">
@ -780,7 +801,6 @@ export const AddFieldsFormPartial = ({
<Card
className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
<CardContent className="p-4">
@ -806,7 +826,6 @@ export const AddFieldsFormPartial = ({
<Card
className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
<CardContent className="p-4">
@ -832,7 +851,6 @@ export const AddFieldsFormPartial = ({
<Card
className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
<CardContent className="p-4">
@ -858,7 +876,6 @@ export const AddFieldsFormPartial = ({
<Card
className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
<CardContent className="p-4">
@ -884,7 +901,6 @@ export const AddFieldsFormPartial = ({
<Card
className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
<CardContent className="p-4">
@ -911,7 +927,6 @@ export const AddFieldsFormPartial = ({
<Card
className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
<CardContent className="p-4">

View File

@ -15,6 +15,7 @@ import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
@ -30,6 +31,10 @@ import {
DocumentGlobalAuthActionSelect,
DocumentGlobalAuthActionTooltip,
} from '@documenso/ui/components/document/document-global-auth-action-select';
import {
DocumentReadOnlyFields,
mapFieldsWithRecipients,
} from '@documenso/ui/components/document/document-read-only-fields';
import {
DocumentVisibilitySelect,
DocumentVisibilityTooltip,
@ -65,14 +70,12 @@ import {
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { ShowFieldItem } from './show-field-item';
import type { DocumentFlowStep } from './types';
export type AddSettingsFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
fields: Field[];
isDocumentEnterprise: boolean;
isDocumentPdfLoaded: boolean;
document: TDocument;
currentTeamMemberRole?: TeamMemberRole;
@ -83,7 +86,6 @@ export const AddSettingsFormPartial = ({
documentFlow,
recipients,
fields,
isDocumentEnterprise,
isDocumentPdfLoaded,
document,
currentTeamMemberRole,
@ -91,6 +93,8 @@ export const AddSettingsFormPartial = ({
}: AddSettingsFormProps) => {
const { t } = useLingui();
const organisation = useCurrentOrganisation();
const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
@ -101,8 +105,8 @@ export const AddSettingsFormPartial = ({
title: document.title,
externalId: document.externalId || '',
visibility: document.visibility || '',
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined,
globalActionAuth: documentAuthOption?.globalActionAuth || undefined,
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
globalActionAuth: documentAuthOption?.globalActionAuth || [],
meta: {
timezone:
@ -135,6 +139,12 @@ export const AddSettingsFormPartial = ({
)
.otherwise(() => false);
const onFormSubmit = form.handleSubmit(onSubmit);
const onGoNextClick = () => {
void onFormSubmit().catch(console.error);
};
// We almost always want to set the timezone to the user's local timezone to avoid confusion
// when the document is signed.
useEffect(() => {
@ -161,10 +171,13 @@ export const AddSettingsFormPartial = ({
/>
<DocumentFlowFormContainerContent>
{isDocumentPdfLoaded &&
fields.map((field, index) => (
<ShowFieldItem key={index} field={field} recipients={recipients} />
))}
{isDocumentPdfLoaded && (
<DocumentReadOnlyFields
showRecipientColors={true}
recipientIds={recipients.map((recipient) => recipient.id)}
fields={mapFieldsWithRecipients(fields, recipients)}
/>
)}
<Form {...form}>
<fieldset
@ -215,7 +228,11 @@ export const AddSettingsFormPartial = ({
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<Select
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
@ -245,7 +262,11 @@ export const AddSettingsFormPartial = ({
</FormLabel>
<FormControl>
<DocumentGlobalAuthAccessSelect {...field} onValueChange={field.onChange} />
<DocumentGlobalAuthAccessSelect
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@ -275,7 +296,7 @@ export const AddSettingsFormPartial = ({
/>
)}
{isDocumentEnterprise && (
{organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name="globalActionAuth"
@ -287,7 +308,11 @@ export const AddSettingsFormPartial = ({
</FormLabel>
<FormControl>
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} />
<DocumentGlobalAuthActionSelect
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@ -432,7 +457,7 @@ export const AddSettingsFormPartial = ({
<FormControl>
<Select
{...field}
value={field.value}
onValueChange={field.onChange}
disabled={documentHasBeenSent}
>
@ -468,7 +493,7 @@ export const AddSettingsFormPartial = ({
<Combobox
className="bg-background"
options={TIME_ZONES}
{...field}
value={field.value}
onChange={(value) => value && field.onChange(value)}
disabled={documentHasBeenSent}
/>
@ -523,7 +548,7 @@ export const AddSettingsFormPartial = ({
disabled={form.formState.isSubmitting}
canGoBack={stepIndex !== 0}
onGoBackClick={previousStep}
onGoNextClick={form.handleSubmit(onSubmit)}
onGoNextClick={onGoNextClick}
/>
</DocumentFlowFormContainerFooter>
</>

View File

@ -16,17 +16,6 @@ import {
ZDocumentMetaTimezoneSchema,
} from '@documenso/trpc/server/document-router/schema';
export const ZMapNegativeOneToUndefinedSchema = z
.string()
.optional()
.transform((val) => {
if (val === '-1') {
return undefined;
}
return val;
});
export const ZAddSettingsFormSchema = z.object({
title: z
.string()
@ -34,12 +23,12 @@ export const ZAddSettingsFormSchema = z.object({
.min(1, { message: msg`Title cannot be empty`.id }),
externalId: z.string().optional(),
visibility: z.nativeEnum(DocumentVisibility).optional(),
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentAccessAuthTypesSchema.optional(),
),
globalActionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentActionAuthTypesSchema.optional(),
),
globalAccessAuth: z
.array(z.union([ZDocumentAccessAuthTypesSchema, z.literal('-1')]))
.transform((val) => (val.length === 1 && val[0] === '-1' ? [] : val))
.optional()
.default([]),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema),
meta: z.object({
timezone: ZDocumentMetaTimezoneSchema.optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: ZDocumentMetaDateFormatSchema.optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),

View File

@ -14,6 +14,7 @@ import { useFieldArray, useForm } from 'react-hook-form';
import { prop, sortBy } from 'remeda';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
@ -23,6 +24,10 @@ import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/re
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import {
DocumentReadOnlyFields,
mapFieldsWithRecipients,
} from '../../components/document/document-read-only-fields';
import { Button } from '../button';
import { Checkbox } from '../checkbox';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
@ -40,7 +45,6 @@ import {
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { ShowFieldItem } from './show-field-item';
import { SigningOrderConfirmation } from './signing-order-confirmation';
import type { DocumentFlowStep } from './types';
@ -50,7 +54,6 @@ export type AddSignersFormProps = {
fields: Field[];
signingOrder?: DocumentSigningOrder | null;
allowDictateNextSigner?: boolean;
isDocumentEnterprise: boolean;
onSubmit: (_data: TAddSignersFormSchema) => void;
isDocumentPdfLoaded: boolean;
};
@ -61,7 +64,6 @@ export const AddSignersFormPartial = ({
fields,
signingOrder,
allowDictateNextSigner,
isDocumentEnterprise,
onSubmit,
isDocumentPdfLoaded,
}: AddSignersFormProps) => {
@ -75,6 +77,8 @@ export const AddSignersFormPartial = ({
const { currentStep, totalSteps, previousStep } = useStep();
const organisation = useCurrentOrganisation();
const defaultRecipients = [
{
formId: initialId,
@ -82,7 +86,7 @@ export const AddSignersFormPartial = ({
email: '',
role: RecipientRole.SIGNER,
signingOrder: 1,
actionAuth: undefined,
actionAuth: [],
},
];
@ -116,10 +120,14 @@ export const AddSignersFormPartial = ({
const recipientHasAuthOptions = recipients.find((recipient) => {
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
return recipientAuthOptions?.accessAuth || recipientAuthOptions?.actionAuth;
return (
recipientAuthOptions.accessAuth.length > 0 || recipientAuthOptions.actionAuth.length > 0
);
});
const formHasActionAuth = form.getValues('signers').find((signer) => signer.actionAuth);
const formHasActionAuth = form
.getValues('signers')
.find((signer) => signer.actionAuth.length > 0);
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
}, [recipients, form]);
@ -187,7 +195,7 @@ export const AddSignersFormPartial = ({
name: '',
email: '',
role: RecipientRole.SIGNER,
actionAuth: undefined,
actionAuth: [],
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
});
};
@ -223,7 +231,7 @@ export const AddSignersFormPartial = ({
name: user?.name ?? '',
email: user?.email ?? '',
role: RecipientRole.SIGNER,
actionAuth: undefined,
actionAuth: [],
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
});
}
@ -368,10 +376,13 @@ export const AddSignersFormPartial = ({
description={documentFlow.description}
/>
<DocumentFlowFormContainerContent>
{isDocumentPdfLoaded &&
fields.map((field, index) => (
<ShowFieldItem key={index} field={field} recipients={recipients} />
))}
{isDocumentPdfLoaded && (
<DocumentReadOnlyFields
showRecipientColors={true}
recipientIds={recipients.map((recipient) => recipient.id)}
fields={mapFieldsWithRecipients(fields, recipients)}
/>
)}
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}>
@ -623,36 +634,37 @@ export const AddSignersFormPartial = ({
)}
/>
{showAdvancedSettings && isDocumentEnterprise && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('col-span-8', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.actionAuth,
'col-span-10': isSigningOrderSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.nativeId)
}
/>
</FormControl>
{showAdvancedSettings &&
organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('col-span-8', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.actionAuth,
'col-span-10': isSigningOrderSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.nativeId)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormMessage />
</FormItem>
)}
/>
)}
<div className="col-span-2 flex gap-x-2">
<FormField
@ -750,7 +762,7 @@ export const AddSignersFormPartial = ({
</Button>
</div>
{!alwaysShowAdvancedSettings && isDocumentEnterprise && (
{!alwaysShowAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
<div className="mt-4 flex flex-row items-center">
<Checkbox
id="showAdvancedRecipientSettings"

View File

@ -4,8 +4,6 @@ import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { ZMapNegativeOneToUndefinedSchema } from './add-settings.types';
export const ZAddSignersFormSchema = z
.object({
signers: z.array(
@ -19,9 +17,7 @@ export const ZAddSignersFormSchema = z
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZRecipientActionAuthTypesSchema.optional(),
),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}),
),
signingOrder: z.nativeEnum(DocumentSigningOrder),

View File

@ -5,23 +5,44 @@ import { Trans } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client';
import { DocumentDistributionMethod, DocumentStatus, RecipientRole } from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TDocument } from '@documenso/lib/types/document';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { CopyTextButton } from '../../components/common/copy-text-button';
import { DocumentEmailCheckboxes } from '../../components/document/document-email-checkboxes';
import {
DocumentReadOnlyFields,
mapFieldsWithRecipients,
} from '../../components/document/document-read-only-fields';
import { AvatarWithText } from '../avatar';
import { FormErrorMessage } from '../form/form-error-message';
import { Input } from '../input';
import { Label } from '../label';
import { useStep } from '../stepper';
import { Textarea } from '../textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import { toast } from '../use-toast';
import { type TAddSubjectFormSchema, ZAddSubjectFormSchema } from './add-subject.types';
import {
@ -31,7 +52,6 @@ import {
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { ShowFieldItem } from './show-field-item';
import type { DocumentFlowStep } from './types';
export type AddSubjectFormProps = {
@ -53,15 +73,14 @@ export const AddSubjectFormPartial = ({
}: AddSubjectFormProps) => {
const { _ } = useLingui();
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors, isSubmitting },
} = useForm<TAddSubjectFormSchema>({
const organisation = useCurrentOrganisation();
const form = useForm<TAddSubjectFormSchema>({
defaultValues: {
meta: {
emailId: document.documentMeta?.emailId ?? null,
emailReplyTo: document.documentMeta?.emailReplyTo || undefined,
// emailReplyName: document.documentMeta?.emailReplyName || undefined,
subject: document.documentMeta?.subject ?? '',
message: document.documentMeta?.message ?? '',
distributionMethod:
@ -72,6 +91,21 @@ export const AddSubjectFormPartial = ({
resolver: zodResolver(ZAddSubjectFormSchema),
});
const {
handleSubmit,
setValue,
watch,
formState: { isSubmitting },
} = form;
const { data: emailData, isLoading: isLoadingEmails } =
trpc.enterprise.organisation.email.find.useQuery({
organisationId: organisation.id,
perPage: 100,
});
const emails = emailData?.data || [];
const GoNextLabel = {
[DocumentDistributionMethod.EMAIL]: {
[DocumentStatus.DRAFT]: msg`Send`,
@ -103,10 +137,13 @@ export const AddSubjectFormPartial = ({
/>
<DocumentFlowFormContainerContent>
<div className="flex flex-col">
{isDocumentPdfLoaded &&
fields.map((field, index) => (
<ShowFieldItem key={index} field={field} recipients={recipients} />
))}
{isDocumentPdfLoaded && (
<DocumentReadOnlyFields
showRecipientColors={true}
recipientIds={recipients.map((recipient) => recipient.id)}
fields={mapFieldsWithRecipients(fields, recipients)}
/>
)}
<Tabs
onValueChange={(value) =>
@ -133,54 +170,141 @@ export const AddSubjectFormPartial = ({
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
exit={{ opacity: 0, transition: { duration: 0.15 } }}
className="flex flex-col gap-y-4 rounded-lg border p-4"
>
<div>
<Label htmlFor="subject">
<Trans>
Subject <span className="text-muted-foreground">(Optional)</span>
</Trans>
</Label>
<Form {...form}>
<fieldset
className="flex flex-col gap-y-4 rounded-lg border p-4"
disabled={form.formState.isSubmitting}
>
{organisation.organisationClaim.flags.emailDomains && (
<FormField
control={form.control}
name="meta.emailId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email Sender</Trans>
</FormLabel>
<Input
id="subject"
className="bg-background mt-2"
disabled={isSubmitting}
{...register('meta.subject')}
/>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value}
onValueChange={(value) =>
field.onChange(value === '-1' ? null : value)
}
>
<SelectTrigger loading={isLoadingEmails} className="bg-background">
<SelectValue />
</SelectTrigger>
<FormErrorMessage className="mt-2" error={errors.meta?.subject} />
</div>
<SelectContent>
{emails.map((email) => (
<SelectItem key={email.id} value={email.id}>
{email.email}
</SelectItem>
))}
<div>
<Label htmlFor="message">
<Trans>
Message <span className="text-muted-foreground">(Optional)</span>
</Trans>
</Label>
<SelectItem value={'-1'}>Documenso</SelectItem>
</SelectContent>
</Select>
</FormControl>
<Textarea
id="message"
className="bg-background mt-2 h-32 resize-none"
disabled={isSubmitting}
{...register('meta.message')}
/>
<FormMessage />
</FormItem>
)}
/>
)}
<FormErrorMessage
className="mt-2"
error={
typeof errors.meta?.message !== 'string' ? errors.meta?.message : undefined
}
/>
</div>
<FormField
control={form.control}
name="meta.emailReplyTo"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Reply To Email</Trans>{' '}
<span className="text-muted-foreground">(Optional)</span>
</FormLabel>
<DocumentSendEmailMessageHelper />
<FormControl>
<Input {...field} />
</FormControl>
<DocumentEmailCheckboxes
className="mt-2"
value={emailSettings}
onChange={(value) => setValue('meta.emailSettings', value)}
/>
<FormMessage />
</FormItem>
)}
/>
{/* <FormField
control={form.control}
name="meta.emailReplyName"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Reply To Name</Trans>{' '}
<span className="text-muted-foreground">(Optional)</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/> */}
<FormField
control={form.control}
name="meta.subject"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Subject</Trans>{' '}
<span className="text-muted-foreground">(Optional)</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.message"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Message</Trans>{' '}
<span className="text-muted-foreground">(Optional)</span>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground p-4">
<DocumentSendEmailMessageHelper />
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Textarea className="bg-background mt-2 h-16 resize-none" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DocumentEmailCheckboxes
className="mt-2"
value={emailSettings}
onChange={(value) => setValue('meta.emailSettings', value)}
/>
</fieldset>
</Form>
</motion.div>
)}
@ -200,8 +324,8 @@ export const AddSubjectFormPartial = ({
<p className="mt-2">
<Trans>
We will generate signing links for with you, which you can send to the
recipients through your method of choice.
We will generate signing links for you, which you can send to the recipients
through your method of choice.
</Trans>
</p>
</div>

View File

@ -5,6 +5,12 @@ import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-emai
export const ZAddSubjectFormSchema = z.object({
meta: z.object({
emailId: z.string().nullable(),
emailReplyTo: z.preprocess(
(val) => (val === '' ? undefined : val),
z.string().email().optional(),
),
// emailReplyName: z.string().optional(),
subject: z.string(),
message: z.string(),
distributionMethod: z

View File

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

View File

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

View File

@ -0,0 +1,208 @@
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?: Pick<DocumentMeta | TemplateMeta, 'dateFormat'>;
};
/**
* Renders the content inside field containers prior to sealing.
*/
export const FieldContent = ({ field, documentMeta }: FieldIconProps) => {
const { _ } = useLingui();
const { type, fieldMeta } = field;
// Render checkbox layout for checkbox fields, even if no values exist yet
if (field.type === FieldType.CHECKBOX && field.fieldMeta?.type === 'checkbox') {
let checkedValues: string[] = [];
try {
checkedValues = fromCheckboxValue(field.customText ?? '');
} catch (err) {
// Do nothing.
console.error(err);
}
// If no values exist yet, show a placeholder checkbox
if (!field.fieldMeta.values || field.fieldMeta.values.length === 0) {
return (
<div
className={cn(
'flex gap-1 py-0.5',
field.fieldMeta.direction === 'horizontal' ? 'flex-row flex-wrap' : 'flex-col gap-y-1',
)}
>
<div className="flex items-center">
<Checkbox className="h-3 w-3" disabled />
<Label className="text-foreground ml-1.5 text-xs font-normal opacity-50">
Checkbox option
</Label>
</div>
</div>
);
}
return (
<div
className={cn(
'flex gap-1 py-0.5',
field.fieldMeta.direction === 'horizontal' ? 'flex-row flex-wrap' : 'flex-col gap-y-1',
)}
>
{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"
/>
);
}
const labelToDisplay = fieldMeta?.label || _(FRIENDLY_FIELD_TYPE[type]) || '';
let textToDisplay: string | undefined;
const isSignatureField =
field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE;
if (field.type === FieldType.TEXT && field.fieldMeta?.type === 'text' && field.fieldMeta?.text) {
textToDisplay = field.fieldMeta.text;
}
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="flex h-full w-full items-center overflow-hidden">
<p
className={cn(
'text-foreground w-full whitespace-pre-wrap text-left text-[clamp(0.07rem,25cqw,0.825rem)] duration-200',
{
'!text-center': textAlign === 'center' || !textToDisplay,
'!text-right': textAlign === 'right',
'font-signature text-[clamp(0.07rem,25cqw,1.125rem)]': isSignatureField,
},
)}
>
{textToDisplay || labelToDisplay}
</p>
</div>
);
};

View File

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

View File

@ -41,7 +41,6 @@ import { RadioFieldAdvancedSettings } from './field-items-advanced-settings/radi
import { TextFieldAdvancedSettings } from './field-items-advanced-settings/text-field';
export type FieldAdvancedSettingsProps = {
teamId?: number;
title: MessageDescriptor;
description: MessageDescriptor;
field: FieldFormType;
@ -130,6 +129,7 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => {
validationLength: 0,
required: false,
readOnly: false,
direction: 'vertical',
};
case FieldType.DROPDOWN:
return {
@ -229,11 +229,19 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
return (
<div ref={ref} className="flex h-full flex-col">
<DocumentFlowFormContainerHeader title={title} description={description} />
<DocumentFlowFormContainerContent>
{isDocumentPdfLoaded &&
fields.map((field, index) => (
fields.map((localField, index) => (
<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>
))}
@ -303,6 +311,7 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
/>
))
.otherwise(() => null)}
{errors.length > 0 && (
<div className="mt-4">
<ul>
@ -315,6 +324,7 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
</div>
)}
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter className="mt-auto">
<DocumentFlowFormContainerActions
goNextLabel={msg`Save`}

View File

@ -1,26 +1,28 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { FieldType } from '@prisma/client';
import { CopyPlus, Settings2, Trash } from 'lucide-react';
import { CopyPlus, Settings2, SquareStack, Trash } from 'lucide-react';
import { createPortal } from 'react-dom';
import { Rnd } from 'react-rnd';
import { match } from 'ts-pattern';
import { useSearchParams } from 'react-router';
import { useElementBounds } from '@documenso/lib/client-only/hooks/use-element-bounds';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import type { TFieldMetaSchema } 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 { CheckboxField } from './advanced-fields/checkbox';
import { RadioField } from './advanced-fields/radio';
import { FieldIcon } from './field-icon';
import { FieldContent } from './field-content';
import type { TDocumentFlowFormSchema } from './types';
type Field = TDocumentFlowFormSchema['fields'][0];
export type FieldItemProps = {
field: Field;
fieldClassName?: string;
passive?: boolean;
disabled?: boolean;
minHeight?: number;
@ -31,18 +33,24 @@ export type FieldItemProps = {
onMove?: (_node: HTMLElement) => void;
onRemove?: () => void;
onDuplicate?: () => void;
onDuplicateAllPages?: () => void;
onAdvancedSettings?: () => void;
onFocus?: () => void;
onBlur?: () => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
recipientIndex?: number;
hideRecipients?: boolean;
hasErrors?: boolean;
active?: boolean;
onFieldActivate?: () => void;
onFieldDeactivate?: () => void;
};
/**
* The item when editing fields??
*/
export const FieldItem = ({
fieldClassName,
field,
passive,
disabled,
@ -54,16 +62,19 @@ export const FieldItem = ({
onMove,
onRemove,
onDuplicate,
onDuplicateAllPages,
onAdvancedSettings,
onFocus,
onBlur,
onAdvancedSettings,
recipientIndex = 0,
hideRecipients = false,
hasErrors,
active,
onFieldActivate,
onFieldDeactivate,
}: FieldItemProps) => {
const { _ } = useLingui();
const [searchParams] = useSearchParams();
const [coords, setCoords] = useState({
pageX: 0,
pageY: 0,
@ -71,9 +82,15 @@ export const FieldItem = ({
pageWidth: defaultWidth || 0,
});
const [settingsActive, setSettingsActive] = useState(false);
const $el = useRef(null);
const $el = useRef<HTMLDivElement>(null);
const signerStyles = useSignerColors(recipientIndex);
const $pageBounds = useElementBounds(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
);
const signerStyles = useRecipientColors(recipientIndex);
const isDevMode = searchParams.get('devmode') === 'true';
const advancedField = [
'NUMBER',
@ -209,7 +226,7 @@ export const FieldItem = ({
return createPortal(
<Rnd
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 cursor-not-allowed opacity-75': disabled,
'z-50': active && !disabled,
@ -221,12 +238,15 @@ export const FieldItem = ({
default={{
x: coords.pageX,
y: coords.pageY,
height: fixedSize ? '' : coords.pageHeight,
width: fixedSize ? '' : coords.pageWidth,
height: fixedSize ? 'auto' : coords.pageHeight,
width: fixedSize ? 'auto' : coords.pageWidth,
}}
maxWidth={fixedSize && $pageBounds?.width ? $pageBounds.width - coords.pageX : undefined}
bounds={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`}
onDragStart={() => onFieldActivate?.()}
onResizeStart={() => onFieldActivate?.()}
onMouseEnter={() => onFocus?.()}
onMouseLeave={() => onBlur?.()}
enableResizing={!fixedSize}
resizeHandleStyles={{
bottom: { bottom: -8, cursor: 'ns-resize' },
@ -260,11 +280,12 @@ export const FieldItem = ({
<div
className={cn(
'relative flex h-full w-full items-center justify-center bg-white',
!hasErrors && signerStyles.default.base,
!hasErrors && signerStyles.default.fieldItem,
'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.base,
!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,
},
!fixedSize && '[container-type:size]',
@ -279,37 +300,38 @@ export const FieldItem = ({
ref={$el}
data-field-id={field.nativeId}
>
{match(field.type)
.with('CHECKBOX', () => <CheckboxField field={field} />)
.with('RADIO', () => <RadioField field={field} />)
.otherwise(() => (
<FieldIcon fieldMeta={field.fieldMeta} type={field.type} />
))}
<FieldContent field={field} />
{!hideRecipients && (
<div className="absolute -right-5 top-0 z-20 hidden h-full w-5 items-center justify-center group-hover:flex">
<div
className={cn(
'flex h-5 w-5 flex-col items-center justify-center rounded-r-md text-[0.5rem] font-bold text-white',
signerStyles.default.fieldItemInitials,
{
'!opacity-50': disabled || passive,
},
)}
>
{(field.signerEmail?.charAt(0)?.toUpperCase() ?? '') +
(field.signerEmail?.charAt(1)?.toUpperCase() ?? '')}
</div>
{/* 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={cn(
'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.fieldItemInitials,
{
'!opacity-50': disabled || passive,
},
)}
>
{(field.signerEmail?.charAt(0)?.toUpperCase() ?? '') +
(field.signerEmail?.charAt(1)?.toUpperCase() ?? '')}
</div>
</div>
{isDevMode && (
<div className="text-muted-foreground absolute -top-6 left-0 right-0 text-center text-[10px]">
{`x: ${field.pageX.toFixed(2)}, y: ${field.pageY.toFixed(2)}`}
</div>
)}
</div>
{!disabled && settingsActive && (
<div className="z-[60] mt-1 flex 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="absolute z-[60] mt-1 flex w-full items-center justify-center">
<div className="group flex items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5">
{advancedField && (
<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"
title={_(msg`Advanced settings`)}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onAdvancedSettings}
onTouchEnd={onAdvancedSettings}
>
@ -318,7 +340,8 @@ export const FieldItem = ({
)}
<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"
title={_(msg`Duplicate`)}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onDuplicate}
onTouchEnd={onDuplicate}
>
@ -326,7 +349,17 @@ 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"
title={_(msg`Duplicate on all pages`)}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onDuplicateAllPages}
onTouchEnd={onDuplicateAllPages}
>
<SquareStack className="h-3 w-3" />
</button>
<button
title={_(msg`Remove`)}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onRemove}
onTouchEnd={onRemove}
>

View File

@ -44,6 +44,9 @@ export const CheckboxFieldAdvancedSettings = ({
const [required, setRequired] = useState(fieldState.required ?? false);
const [validationLength, setValidationLength] = useState(fieldState.validationLength ?? 0);
const [validationRule, setValidationRule] = useState(fieldState.validationRule ?? '');
const [direction, setDirection] = useState<'vertical' | 'horizontal'>(
fieldState.direction ?? 'vertical',
);
const handleToggleChange = (field: keyof CheckboxFieldMeta, value: string | boolean) => {
const readOnly = field === 'readOnly' ? Boolean(value) : Boolean(fieldState.readOnly);
@ -52,11 +55,14 @@ export const CheckboxFieldAdvancedSettings = ({
field === 'validationRule' ? String(value) : String(fieldState.validationRule);
const validationLength =
field === 'validationLength' ? Number(value) : Number(fieldState.validationLength);
const currentDirection =
field === 'direction' && String(value) === 'horizontal' ? 'horizontal' : 'vertical';
setReadOnly(readOnly);
setRequired(required);
setValidationRule(validationRule);
setValidationLength(validationLength);
setDirection(currentDirection);
const errors = validateCheckboxField(
values.map((item) => item.value),
@ -65,6 +71,7 @@ export const CheckboxFieldAdvancedSettings = ({
required,
validationRule,
validationLength,
direction: currentDirection,
type: 'checkbox',
},
);
@ -86,6 +93,7 @@ export const CheckboxFieldAdvancedSettings = ({
required,
validationRule,
validationLength,
direction: direction,
type: 'checkbox',
},
);
@ -137,6 +145,29 @@ export const CheckboxFieldAdvancedSettings = ({
onChange={(e) => handleFieldChange('label', e.target.value)}
/>
</div>
<div className="mb-2">
<Label>
<Trans>Direction</Trans>
</Label>
<Select
value={fieldState.direction ?? 'vertical'}
onValueChange={(val) => handleToggleChange('direction', val)}
>
<SelectTrigger className="text-muted-foreground bg-background mt-2 w-full">
<SelectValue placeholder={_(msg`Select direction`)} />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="vertical">
<Trans>Vertical</Trans>
</SelectItem>
<SelectItem value="horizontal">
<Trans>Horizontal</Trans>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-row items-center gap-x-4">
<div className="flex w-2/3 flex-col">
<Label>

View File

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

View File

@ -0,0 +1,105 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Upload } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { Link } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { Button } from './button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip';
export type DocumentDropzoneProps = {
className?: string;
disabled?: boolean;
loading?: boolean;
disabledMessage?: MessageDescriptor;
onDrop?: (_file: File) => void | Promise<void>;
onDropRejected?: () => void | Promise<void>;
type?: 'document' | 'template';
[key: string]: unknown;
};
export const DocumentDropzone = ({
className,
loading,
onDrop,
onDropRejected,
disabled,
disabledMessage = msg`You cannot upload documents at this time.`,
type = 'document',
...props
}: DocumentDropzoneProps) => {
const { _ } = useLingui();
const { organisations } = useSession();
const organisation = useCurrentOrganisation();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const { getRootProps, getInputProps } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
},
multiple: false,
disabled,
onDrop: ([acceptedFile]) => {
if (acceptedFile && onDrop) {
void onDrop(acceptedFile);
}
},
onDropRejected: () => {
if (onDropRejected) {
void onDropRejected();
}
},
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
});
const heading = {
document: msg`Upload Document`,
template: msg`Upload Template Document`,
};
if (disabled && IS_BILLING_ENABLED()) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button className="hover:bg-warning/80 bg-warning" asChild>
<Link
to={
isPersonalLayoutMode
? `/settings/billing`
: `/o/${organisation.url}/settings/billing`
}
>
<Trans>Upgrade</Trans>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p className="text-sm">{_(disabledMessage)}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return (
<Button loading={loading} aria-disabled={disabled} {...getRootProps()} {...props}>
<div className="flex items-center gap-2">
<input data-testid="document-upload-input" {...getInputProps()} />
{!loading && <Upload className="h-4 w-4" />}
{disabled ? _(disabledMessage) : _(heading[type])}
</div>
</Button>
);
};

View File

@ -12,7 +12,12 @@ export const ElementVisible = ({ target, children }: ElementVisibleProps) => {
const observer = new MutationObserver((_mutations) => {
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, {

View File

@ -26,6 +26,7 @@ type MultiSelectComboboxProps<T = OptionValue> = {
enableClearAllButton?: boolean;
enableSearch?: boolean;
className?: string;
contentClassName?: string;
loading?: boolean;
inputPlaceholder?: MessageDescriptor;
onChange: (_values: T[]) => void;
@ -46,6 +47,7 @@ export function MultiSelectCombobox<T = OptionValue>({
enableClearAllButton,
enableSearch = true,
className,
contentClassName,
inputPlaceholder,
loading,
onChange,
@ -149,7 +151,7 @@ export function MultiSelectCombobox<T = OptionValue>({
)}
</div>
<PopoverContent className="w-[200px] p-0">
<PopoverContent className={cn('z-[50000000] w-full p-0', contentClassName)}>
<Command>
{enableSearch && <CommandInput placeholder={inputPlaceholder && _(inputPlaceholder)} />}
<CommandEmpty>

View File

@ -0,0 +1,587 @@
import * as React from 'react';
import { useEffect } from 'react';
import { Command as CommandPrimitive, useCommandState } from 'cmdk';
import { XIcon } from 'lucide-react';
import { useDebounce } from '../lib/use-debounce';
import { cn } from '../lib/utils';
import { Command, CommandGroup, CommandItem, CommandList } from './command';
export interface Option {
value: string;
label: string;
disable?: boolean;
/** fixed option that can't be removed. */
fixed?: boolean;
/** Group the options by providing key. */
[key: string]: string | boolean | undefined;
}
interface GroupOption {
[key: string]: Option[];
}
interface MultiSelectProps {
value?: Option[];
defaultOptions?: Option[];
/** manually controlled options */
options?: Option[];
placeholder?: string;
/** Loading component. */
loadingIndicator?: React.ReactNode;
/** Empty component. */
emptyIndicator?: React.ReactNode;
/** Debounce time for async search. Only work with `onSearch`. */
delay?: number;
/**
* Only work with `onSearch` prop. Trigger search when `onFocus`.
* For example, when user click on the input, it will trigger the search to get initial options.
**/
triggerSearchOnFocus?: boolean;
/** async search */
onSearch?: (value: string) => Promise<Option[]>;
/**
* sync search. This search will not showing loadingIndicator.
* The rest props are the same as async search.
* i.e.: creatable, groupBy, delay.
**/
onSearchSync?: (value: string) => Option[];
onChange?: (options: Option[]) => void;
/** Limit the maximum number of selected options. */
maxSelected?: number;
/** When the number of selected options exceeds the limit, the onMaxSelected will be called. */
onMaxSelected?: (maxLimit: number) => void;
/** Hide the placeholder when there are options selected. */
hidePlaceholderWhenSelected?: boolean;
disabled?: boolean;
/** Group the options base on provided key. */
groupBy?: string;
className?: string;
badgeClassName?: string;
/**
* First item selected is a default behavior by cmdk. That is why the default is true.
* This is a workaround solution by add a dummy item.
*
* @reference: https://github.com/pacocoursey/cmdk/issues/171
*/
selectFirstItem?: boolean;
/** Allow user to create option when there is no option matched. */
creatable?: boolean;
/** Props of `Command` */
commandProps?: React.ComponentPropsWithoutRef<typeof Command>;
/** Props of `CommandInput` */
inputProps?: Omit<
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
'value' | 'placeholder' | 'disabled'
>;
/** hide the clear all button. */
hideClearAllButton?: boolean;
/** test id for the select value. */
'data-testid'?: string;
}
export interface MultiSelectRef {
selectedValue: Option[];
input: HTMLInputElement;
focus: () => void;
reset: () => void;
}
function transToGroupOption(options: Option[], groupBy?: string) {
if (options.length === 0) {
return {};
}
if (!groupBy) {
return {
'': options,
};
}
const groupOption: GroupOption = {};
options.forEach((option) => {
const key = (option[groupBy] as string) || '';
if (!groupOption[key]) {
groupOption[key] = [];
}
groupOption[key].push(option);
});
return groupOption;
}
function removePickedOption(groupOption: GroupOption, picked: Option[]) {
const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption;
for (const [key, value] of Object.entries(cloneOption)) {
cloneOption[key] = value.filter((val) => !picked.find((p) => p.value === val.value));
}
return cloneOption;
}
function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
for (const [, value] of Object.entries(groupOption)) {
if (value.some((option) => targetOption.find((p) => p.value === option.value))) {
return true;
}
}
return false;
}
const CommandEmpty = ({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) => {
const render = useCommandState((state) => state.filtered.count === 0);
if (!render) return null;
return (
<div
className={cn('px-2 py-4 text-center text-sm', className)}
cmdk-empty=""
role="presentation"
{...props}
/>
);
};
CommandEmpty.displayName = 'CommandEmpty';
const MultiSelect = ({
value,
onChange,
placeholder,
defaultOptions: arrayDefaultOptions = [],
options: arrayOptions,
delay,
onSearch,
onSearchSync,
loadingIndicator,
emptyIndicator,
maxSelected = Number.MAX_SAFE_INTEGER,
onMaxSelected,
hidePlaceholderWhenSelected,
disabled,
groupBy,
className,
badgeClassName,
selectFirstItem = true,
creatable = false,
triggerSearchOnFocus = false,
commandProps,
inputProps,
hideClearAllButton = false,
'data-testid': dataTestId,
}: MultiSelectProps) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
const [onScrollbar, setOnScrollbar] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
const dropdownRef = React.useRef<HTMLDivElement>(null); // Added this
const [selected, setSelected] = React.useState<Option[]>(value || []);
const [options, setOptions] = React.useState<GroupOption>(
transToGroupOption(arrayDefaultOptions, groupBy),
);
const [inputValue, setInputValue] = React.useState('');
const debouncedSearchTerm = useDebounce(inputValue, delay || 500);
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
setOpen(false);
inputRef.current.blur();
}
};
const handleUnselect = React.useCallback(
(option: Option) => {
const newOptions = selected.filter((s) => s.value !== option.value);
setSelected(newOptions);
onChange?.(newOptions);
},
[onChange, selected],
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current;
if (input) {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (input.value === '' && selected.length > 0) {
const lastSelectOption = selected[selected.length - 1];
// If last item is fixed, we should not remove it.
if (!lastSelectOption.fixed) {
handleUnselect(selected[selected.length - 1]);
}
}
}
// This is not a default behavior of the <input /> field
if (e.key === 'Escape') {
input.blur();
}
}
},
[handleUnselect, selected],
);
useEffect(() => {
if (open) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('touchend', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchend', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchend', handleClickOutside);
};
}, [open]);
useEffect(() => {
if (value) {
setSelected(value);
}
}, [value]);
useEffect(() => {
/** If `onSearch` is provided, do not trigger options updated. */
if (!arrayOptions || onSearch) {
return;
}
const newOption = transToGroupOption(arrayOptions || [], groupBy);
if (JSON.stringify(newOption) !== JSON.stringify(options)) {
setOptions(newOption);
}
}, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]);
useEffect(() => {
/** sync search */
const doSearchSync = () => {
const res = onSearchSync?.(debouncedSearchTerm);
setOptions(transToGroupOption(res || [], groupBy));
};
// eslint-disable-next-line @typescript-eslint/require-await
const exec = async () => {
if (!onSearchSync || !open) return;
if (triggerSearchOnFocus) {
doSearchSync();
}
if (debouncedSearchTerm) {
doSearchSync();
}
};
void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
useEffect(() => {
/** async search */
const doSearch = async () => {
setIsLoading(true);
const res = await onSearch?.(debouncedSearchTerm);
setOptions(transToGroupOption(res || [], groupBy));
setIsLoading(false);
};
const exec = async () => {
if (!onSearch || !open) return;
if (triggerSearchOnFocus) {
await doSearch();
}
if (debouncedSearchTerm) {
await doSearch();
}
};
void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
const CreatableItem = () => {
if (!creatable) return undefined;
if (
isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
selected.find((s) => s.value === inputValue)
) {
return undefined;
}
const Item = (
<CommandItem
value={inputValue}
className="cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={(value: string) => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue('');
const newOptions = [...selected, { value, label: value }];
setSelected(newOptions);
onChange?.(newOptions);
}}
>
{`Create "${inputValue}"`}
</CommandItem>
);
// For normal creatable
if (!onSearch && inputValue.length > 0) {
return Item;
}
// For async search creatable. avoid showing creatable item before loading at first.
if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
return Item;
}
return undefined;
};
const EmptyItem = React.useCallback(() => {
if (!emptyIndicator) return undefined;
// For async search that showing emptyIndicator
if (onSearch && !creatable && Object.keys(options).length === 0) {
return (
<CommandItem value="-" disabled>
{emptyIndicator}
</CommandItem>
);
}
return <CommandEmpty>{emptyIndicator}</CommandEmpty>;
}, [creatable, emptyIndicator, onSearch, options]);
const selectables = React.useMemo<GroupOption>(
() => removePickedOption(options, selected),
[options, selected],
);
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
const commandFilter = React.useCallback(() => {
if (commandProps?.filter) {
return commandProps.filter;
}
if (creatable) {
return (value: string, search: string) => {
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
};
}
// Using default filter in `cmdk`. We don&lsquo;t have to provide it.
return undefined;
}, [creatable, commandProps?.filter]);
return (
<Command
ref={dropdownRef}
{...commandProps}
onKeyDown={(e) => {
handleKeyDown(e);
commandProps?.onKeyDown?.(e);
}}
className={cn('h-auto overflow-visible bg-transparent', commandProps?.className)}
shouldFilter={
commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch
} // When onSearch is provided, we don&lsquo;t want to filter the options. You can still override it.
filter={commandFilter()}
data-testid={dataTestId}
>
<div
className={cn(
'border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 relative min-h-[38px] rounded-md border text-sm outline-none transition-[color,box-shadow] focus-within:ring-[3px]',
{
'p-1': selected.length !== 0,
'cursor-text': !disabled && selected.length !== 0,
},
!hideClearAllButton && 'pe-9',
className,
)}
onClick={() => {
if (disabled) return;
inputRef?.current?.focus();
}}
>
<div className="flex flex-wrap gap-1">
{selected.map((option) => {
return (
<div
key={option.value}
className={cn(
'animate-fadeIn bg-background text-secondary-foreground hover:bg-background data-fixed:pe-2 relative inline-flex h-7 cursor-default items-center rounded-md border pe-7 pl-2 ps-2 text-xs font-medium transition-all disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
badgeClassName,
)}
data-fixed={option.fixed}
data-disabled={disabled || undefined}
>
{option.label}
<button
className="text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute -inset-y-px -end-px flex size-7 items-center justify-center rounded-e-md border border-transparent p-0 outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleUnselect(option);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(option)}
aria-label="Remove"
>
<XIcon size={14} aria-hidden="true" />
</button>
</div>
);
})}
{/* Avoid having the "Search" Icon */}
<CommandPrimitive.Input
{...inputProps}
ref={inputRef}
value={inputValue}
disabled={disabled}
onValueChange={(value) => {
setInputValue(value);
inputProps?.onValueChange?.(value);
}}
onBlur={(event) => {
if (!onScrollbar) {
setOpen(false);
}
inputProps?.onBlur?.(event);
}}
onFocus={(event) => {
setOpen(true);
if (triggerSearchOnFocus) {
void onSearch?.(debouncedSearchTerm);
}
inputProps?.onFocus?.(event);
}}
placeholder={hidePlaceholderWhenSelected && selected.length !== 0 ? '' : placeholder}
className={cn(
'placeholder:text-muted-foreground/70 flex-1 bg-transparent outline-none disabled:cursor-not-allowed',
{
'w-full': hidePlaceholderWhenSelected,
'px-3 py-2': selected.length === 0,
'ml-1': selected.length !== 0,
},
inputProps?.className,
)}
/>
<button
type="button"
onClick={() => {
setSelected(selected.filter((s) => s.fixed));
onChange?.(selected.filter((s) => s.fixed));
}}
className={cn(
'text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute end-0 top-0 flex size-9 items-center justify-center rounded-md border border-transparent outline-none transition-[color,box-shadow] focus-visible:ring-[3px]',
(hideClearAllButton ||
disabled ||
selected.length < 1 ||
selected.filter((s) => s.fixed).length === selected.length) &&
'hidden',
)}
aria-label="Clear all"
>
<XIcon size={16} aria-hidden="true" />
</button>
</div>
</div>
<div className="relative">
<div
className={cn(
'border-input absolute top-2 z-10 w-full overflow-hidden rounded-md border',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
!open && 'hidden',
)}
data-state={open ? 'open' : 'closed'}
>
{open && (
<CommandList
className="bg-popover text-popover-foreground shadow-lg outline-none"
onMouseLeave={() => {
setOnScrollbar(false);
}}
onMouseEnter={() => {
setOnScrollbar(true);
}}
onMouseUp={() => {
inputRef?.current?.focus();
}}
>
{isLoading ? (
<>{loadingIndicator}</>
) : (
<>
{EmptyItem()}
{CreatableItem()}
{!selectFirstItem && <CommandItem value="-" className="hidden" />}
{Object.entries(selectables).map(([key, dropdowns]) => (
<CommandGroup key={key} heading={key} className="h-full overflow-auto">
<>
{dropdowns.map((option) => {
return (
<CommandItem
key={option.value}
value={option.value}
disabled={option.disable}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={() => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue('');
const newOptions = [...selected, option];
setSelected(newOptions);
onChange?.(newOptions);
}}
className={cn(
'cursor-pointer',
option.disable &&
'pointer-events-none cursor-not-allowed opacity-50',
)}
>
{option.label}
</CommandItem>
);
})}
</>
</CommandGroup>
))}
</>
)}
</CommandList>
)}
</div>
</div>
</Command>
);
};
MultiSelect.displayName = 'MultiSelect';
export { MultiSelect };

View File

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

View File

@ -3,13 +3,13 @@ import { useCallback, useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import { RecipientRole, SendStatus } from '@prisma/client';
import { RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { Check, ChevronsUpDown, Info } from 'lucide-react';
import { sortBy } from 'remeda';
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 { Button } from './button';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from './command';
@ -78,12 +78,12 @@ export const RecipientSelector = ({
role="combobox"
className={cn(
'bg-background text-muted-foreground hover:text-foreground justify-between font-normal',
getSignerColorStyles(
getRecipientColorStyles(
Math.max(
recipients.findIndex((r) => r.id === selectedRecipient?.id),
0,
),
).default.base,
).base,
className,
)}
>
@ -131,12 +131,12 @@ export const RecipientSelector = ({
key={recipient.id}
className={cn(
'px-2 last:mb-1 [&:not(:first-child)]:mt-1',
getSignerColorStyles(
getRecipientColorStyles(
Math.max(
recipients.findIndex((r) => r.id === recipient.id),
0,
),
).default.comboxBoxItem,
).comboxBoxItem,
{
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
},
@ -145,6 +145,7 @@ export const RecipientSelector = ({
onSelectedRecipientChange(recipient);
setShowRecipientsSelector(false);
}}
disabled={recipient.signingStatus !== SigningStatus.NOT_SIGNED}
>
<span
className={cn('text-foreground/70 truncate', {

View File

@ -1,7 +1,10 @@
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown } from 'lucide-react';
import { AnimatePresence } from 'framer-motion';
import { Check, ChevronDown, Loader } from 'lucide-react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { cn } from '../lib/utils';
@ -13,20 +16,33 @@ const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
loading?: boolean;
}
>(({ className, children, loading, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'border-input ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
disabled={loading || props.disabled}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
<AnimatePresence>
{loading ? (
<div className="flex w-full items-center justify-center">
<Loader className="h-5 w-5 animate-spin text-gray-500 dark:text-gray-100" />
</div>
) : (
<AnimateGenericFadeInOut className="flex w-full justify-between">
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</AnimateGenericFadeInOut>
)}
</AnimatePresence>
</SelectPrimitive.Trigger>
));

View File

@ -0,0 +1,55 @@
import * as React from 'react';
import { cva } from 'class-variance-authority';
import { Loader } from 'lucide-react';
import { cn } from '../lib/utils';
const spinnerVariants = cva('text-muted-foreground animate-spin', {
variants: {
size: {
default: 'h-6 w-6',
sm: 'h-4 w-4',
lg: 'h-8 w-8',
},
},
defaultVariants: {
size: 'default',
},
});
type SpinnerSize = 'default' | 'sm' | 'lg';
export interface SpinnerProps extends Omit<React.ComponentPropsWithoutRef<typeof Loader>, 'size'> {
size?: SpinnerSize;
}
const Spinner = React.forwardRef<SVGSVGElement, SpinnerProps>(
({ className, size = 'default', ...props }, ref) => {
return <Loader ref={ref} className={cn(spinnerVariants({ size }), className)} {...props} />;
},
);
Spinner.displayName = 'Spinner';
export interface SpinnerBoxProps extends React.HTMLAttributes<HTMLDivElement> {
spinnerProps?: SpinnerProps;
}
const SpinnerBox = React.forwardRef<HTMLDivElement, SpinnerBoxProps>(
({ className, spinnerProps, ...props }, ref) => {
return (
<div
ref={ref}
className={cn('flex items-center justify-center rounded-lg', className)}
{...props}
>
<Spinner {...spinnerProps} />
</div>
);
},
);
SpinnerBox.displayName = 'SpinnerBox';
export { Spinner, SpinnerBox, spinnerVariants };

View File

@ -24,11 +24,13 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import {
type TFieldMetaSchema as FieldMeta,
ZFieldMetaSchema,
} from '@documenso/lib/types/field-meta';
import { nanoid } from '@documenso/lib/universal/id';
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -53,7 +55,7 @@ import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/type
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
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 { FieldAdvancedSettings } from '../document-flow/field-item-advanced-settings';
import { Form } from '../form/form';
@ -68,16 +70,14 @@ const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
export type AddTemplateFieldsFormProps = {
documentFlow: DocumentFlowStep;
hideRecipients?: boolean;
recipients: Recipient[];
fields: Field[];
onSubmit: (_data: TAddTemplateFieldsFormSchema) => void;
teamId?: number;
teamId: number;
};
export const AddTemplateFieldsFormPartial = ({
documentFlow,
hideRecipients = false,
recipients,
fields,
onSubmit,
@ -136,49 +136,69 @@ export const AddTemplateFieldsFormPartial = ({
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const selectedSignerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id);
const selectedSignerStyles = useSignerColors(
const selectedSignerStyles = useRecipientColors(
selectedSignerIndex === -1 ? 0 : selectedSignerIndex,
);
const onFieldCopy = useCallback(
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => {
const { duplicate = false } = options ?? {};
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
const { duplicate = false, duplicateAll = false } = options ?? {};
if (lastActiveField) {
event?.preventDefault();
if (!duplicate) {
setFieldClipboard(lastActiveField);
if (duplicate) {
const newField: TAddTemplateFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
signerId: selectedSigner?.id ?? lastActiveField.signerId,
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3,
};
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
append(newField);
return;
}
if (duplicateAll) {
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
pages.forEach((_, index) => {
const pageNumber = index + 1;
if (pageNumber === lastActiveField.pageNumber) {
return;
}
const newField: TAddTemplateFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
signerId: selectedSigner?.id ?? lastActiveField.signerId,
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
pageNumber,
};
append(newField);
});
return;
}
const newField: TAddTemplateFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
signerId: selectedSigner?.id ?? lastActiveField.signerId,
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3,
};
setFieldClipboard(lastActiveField);
append(newField);
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
});
}
},
[
append,
lastActiveField,
selectedSigner?.email,
selectedSigner?.id,
selectedSigner?.token,
toast,
],
[append, lastActiveField, selectedSigner?.email, selectedSigner?.id, toast],
);
const onFieldPaste = useCallback(
@ -191,6 +211,7 @@ export const AddTemplateFieldsFormPartial = ({
append({
...copiedField,
formId: nanoid(12),
nativeId: undefined,
signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
signerId: selectedSigner?.id ?? copiedField.signerId,
signerToken: selectedSigner?.token ?? copiedField.signerToken,
@ -305,7 +326,7 @@ export const AddTemplateFieldsFormPartial = ({
pageX -= fieldPageWidth / 2;
pageY -= fieldPageHeight / 2;
append({
const field = {
formId: nanoid(12),
type: selectedField,
pageNumber,
@ -317,7 +338,13 @@ export const AddTemplateFieldsFormPartial = ({
signerId: selectedSigner.id,
signerToken: selectedSigner.token ?? '',
fieldMeta: undefined,
});
};
append(field);
if (ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING.includes(selectedField)) {
setCurrentField(field);
setShowAdvancedSettings(true);
}
setIsFieldWithinBounds(false);
setSelectedField(null);
@ -492,7 +519,6 @@ export const AddTemplateFieldsFormPartial = ({
fields={localFields}
onAdvancedSettings={handleAdvancedSettings}
onSave={handleSavedFieldSettings}
teamId={teamId}
/>
) : (
<>
@ -505,8 +531,8 @@ export const AddTemplateFieldsFormPartial = ({
{selectedField && (
<div
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]',
selectedSignerStyles.default.base,
'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?.base,
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
'dark:text-black/60': isFieldWithinBounds,
@ -545,11 +571,11 @@ export const AddTemplateFieldsFormPartial = ({
onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)}
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
onAdvancedSettings={() => {
setCurrentField(field);
handleAdvancedSettings();
}}
hideRecipients={hideRecipients}
active={activeFieldId === field.formId}
onFieldActivate={() => setActiveFieldId(field.formId)}
onFieldDeactivate={() => setActiveFieldId(null)}
@ -557,99 +583,111 @@ export const AddTemplateFieldsFormPartial = ({
);
})}
{!hideRecipients && (
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
className={cn(
'bg-background text-muted-foreground hover:text-foreground mb-12 mt-2 justify-between font-normal',
selectedSignerStyles.default.base,
)}
>
{selectedSigner?.email && (
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
className={cn(
'bg-background text-muted-foreground hover:text-foreground mb-12 mt-2 justify-between font-normal',
selectedSignerStyles?.comboxBoxTrigger,
)}
>
{selectedSigner?.email &&
!isTemplateRecipientEmailPlaceholder(selectedSigner.email) && (
<span className="flex-1 truncate text-left">
{selectedSigner?.name} ({selectedSigner?.email})
</span>
)}
{!selectedSigner?.email && (
<span className="gradie flex-1 truncate text-left">
{selectedSigner?.email}
</span>
{selectedSigner?.email &&
isTemplateRecipientEmailPlaceholder(selectedSigner.email) && (
<span className="flex-1 truncate text-left">{selectedSigner?.name}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
{!selectedSigner?.email && (
<span className="gradie flex-1 truncate text-left">
No recipient selected
</span>
)}
<PopoverContent className="p-0" align="start">
<Command value={selectedSigner?.email}>
<CommandInput />
<ChevronsUpDown className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
<CommandEmpty>
<span className="text-muted-foreground inline-block px-4">
<Trans>No recipient matching this description was found.</Trans>
</span>
</CommandEmpty>
<PopoverContent className="p-0" align="start">
<Command value={selectedSigner?.email}>
<CommandInput />
{recipientsByRoleToDisplay.map(([role, roleRecipients], roleIndex) => (
<CommandGroup key={roleIndex}>
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
{_(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
<CommandEmpty>
<span className="text-muted-foreground inline-block px-4">
<Trans>No recipient matching this description was found.</Trans>
</span>
</CommandEmpty>
{/* Note: This is duplicated in `add-fields.tsx` */}
{recipientsByRoleToDisplay.map(([role, roleRecipients], roleIndex) => (
<CommandGroup key={roleIndex}>
<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>
)}
{roleRecipients.length === 0 && (
<div
key={`${role}-empty`}
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
{roleRecipients.map((recipient) => (
<CommandItem
key={recipient.id}
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>
</div>
)}
{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 && (
{recipient.name &&
!isTemplateRecipientEmailPlaceholder(recipient.email) && (
<span title={`${recipient.name} (${recipient.email})`}>
{recipient.name} ({recipient.email})
</span>
)}
{!recipient.name && (
{recipient.name &&
isTemplateRecipientEmailPlaceholder(recipient.email) && (
<span title={recipient.name}>{recipient.name}</span>
)}
{!recipient.name &&
!isTemplateRecipientEmailPlaceholder(recipient.email) && (
<span title={recipient.email}>{recipient.email}</span>
)}
</span>
</CommandItem>
))}
</CommandGroup>
))}
</Command>
</PopoverContent>
</Popover>
)}
</span>
</CommandItem>
))}
</CommandGroup>
))}
</Command>
</PopoverContent>
</Popover>
<Form {...form}>
<div className="-mx-2 flex-1 overflow-y-auto px-2">

View File

@ -12,7 +12,9 @@ import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircle, Link2Icon, Plus, Trash } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import { generateRecipientPlaceholder } from '@documenso/lib/utils/templates';
@ -25,6 +27,10 @@ import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-messa
import { Input } from '@documenso/ui/primitives/input';
import { toast } from '@documenso/ui/primitives/use-toast';
import {
DocumentReadOnlyFields,
mapFieldsWithRecipients,
} from '../../components/document/document-read-only-fields';
import { Checkbox } from '../checkbox';
import {
DocumentFlowFormContainerActions,
@ -33,7 +39,6 @@ import {
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from '../document-flow/document-flow-root';
import { ShowFieldItem } from '../document-flow/show-field-item';
import { SigningOrderConfirmation } from '../document-flow/signing-order-confirmation';
import type { DocumentFlowStep } from '../document-flow/types';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
@ -49,14 +54,12 @@ export type AddTemplatePlaceholderRecipientsFormProps = {
signingOrder?: DocumentSigningOrder | null;
allowDictateNextSigner?: boolean;
templateDirectLink?: TemplateDirectLink | null;
isEnterprise: boolean;
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
isDocumentPdfLoaded: boolean;
};
export const AddTemplatePlaceholderRecipientsFormPartial = ({
documentFlow,
isEnterprise,
recipients,
templateDirectLink,
fields,
@ -71,6 +74,8 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
const { _ } = useLingui();
const { user } = useSession();
const organisation = useCurrentOrganisation();
const [placeholderRecipientCount, setPlaceholderRecipientCount] = useState(() =>
recipients.length > 1 ? recipients.length + 1 : 2,
);
@ -83,7 +88,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
{
formId: initialId,
role: RecipientRole.SIGNER,
actionAuth: undefined,
actionAuth: [],
...generateRecipientPlaceholder(1),
signingOrder: 1,
},
@ -133,10 +138,14 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
const recipientHasAuthOptions = recipients.find((recipient) => {
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
return recipientAuthOptions?.accessAuth || recipientAuthOptions?.actionAuth;
return (
recipientAuthOptions.accessAuth.length > 0 || recipientAuthOptions.actionAuth.length > 0
);
});
const formHasActionAuth = form.getValues('signers').find((signer) => signer.actionAuth);
const formHasActionAuth = form
.getValues('signers')
.find((signer) => signer.actionAuth.length > 0);
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
}, [recipients, form]);
@ -176,6 +185,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
email: user.email ?? '',
role: RecipientRole.SIGNER,
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
actionAuth: [],
});
};
@ -185,6 +195,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
role: RecipientRole.SIGNER,
...generateRecipientPlaceholder(placeholderRecipientCount),
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
actionAuth: [],
});
setPlaceholderRecipientCount((count) => count + 1);
@ -237,62 +248,6 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
[form, watchedSigners, toast],
);
const triggerDragAndDrop = useCallback(
(fromIndex: number, toIndex: number) => {
if (!$sensorApi.current) {
return;
}
const draggableId = signers[fromIndex].id;
const preDrag = $sensorApi.current.tryGetLock(draggableId);
if (!preDrag) {
return;
}
const drag = preDrag.snapLift();
setTimeout(() => {
// Move directly to the target index
if (fromIndex < toIndex) {
for (let i = fromIndex; i < toIndex; i++) {
drag.moveDown();
}
} else {
for (let i = fromIndex; i > toIndex; i--) {
drag.moveUp();
}
}
setTimeout(() => {
drag.drop();
}, 500);
}, 0);
},
[signers],
);
const updateSigningOrders = useCallback(
(newIndex: number, oldIndex: number) => {
const updatedSigners = form.getValues('signers').map((signer, index) => {
if (index === oldIndex) {
return { ...signer, signingOrder: newIndex + 1 };
} else if (index >= newIndex && index < oldIndex) {
return { ...signer, signingOrder: (signer.signingOrder ?? index + 1) + 1 };
} else if (index <= newIndex && index > oldIndex) {
return { ...signer, signingOrder: Math.max(1, (signer.signingOrder ?? index + 1) - 1) };
}
return signer;
});
updatedSigners.forEach((signer, index) => {
form.setValue(`signers.${index}.signingOrder`, signer.signingOrder);
});
},
[form],
);
const handleSigningOrderChange = useCallback(
(index: number, newOrderString: string) => {
const trimmedOrderString = newOrderString.trim();
@ -391,10 +346,13 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
description={documentFlow.description}
/>
<DocumentFlowFormContainerContent>
{isDocumentPdfLoaded &&
fields.map((field, index) => (
<ShowFieldItem key={index} field={field} recipients={recipients} />
))}
{isDocumentPdfLoaded && (
<DocumentReadOnlyFields
showRecipientColors={true}
recipientIds={recipients.map((recipient) => recipient.id)}
fields={mapFieldsWithRecipients(fields, recipients)}
/>
)}
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}>
@ -500,6 +458,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
ref={provided.innerRef}
className="flex w-full flex-col gap-y-2"
>
{/* todo */}
{signers.map((signer, index) => (
<Draggable
key={`${signer.id}-${signer.signingOrder}`}
@ -578,7 +537,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel required>
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
)}
@ -588,6 +547,11 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
type="email"
placeholder={_(msg`Email`)}
{...field}
value={
isTemplateRecipientEmailPlaceholder(field.value)
? ''
: field.value
}
disabled={
field.disabled ||
isSubmitting ||
@ -636,29 +600,30 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
)}
/>
{showAdvancedSettings && isEnterprise && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('col-span-8', {
'col-span-10': isSigningOrderSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={isSubmitting}
/>
</FormControl>
{showAdvancedSettings &&
organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('col-span-8', {
'col-span-10': isSigningOrderSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormMessage />
</FormItem>
)}
/>
)}
<div className="col-span-2 flex gap-x-2">
<FormField
@ -760,7 +725,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
</Button>
</div>
{!alwaysShowAdvancedSettings && isEnterprise && (
{!alwaysShowAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
<div className="mt-4 flex flex-row items-center">
<Checkbox
id="showAdvancedRecipientSettings"

View File

@ -1,10 +1,9 @@
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { z } from 'zod';
import { TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX } from '@documenso/lib/constants/template';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
export const ZAddTemplatePlacholderRecipientsFormSchema = z
.object({
signers: z.array(
@ -12,12 +11,10 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z
formId: z.string().min(1),
nativeId: z.number().optional(),
email: z.string().min(1).email(),
name: z.string(),
name: z.string().min(1, { message: 'Name is required' }),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZRecipientActionAuthTypesSchema.optional(),
),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}),
),
signingOrder: z.nativeEnum(DocumentSigningOrder),
@ -25,12 +22,25 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z
})
.refine(
(schema) => {
const emails = schema.signers.map((signer) => signer.email.toLowerCase());
const nonPlaceholderEmails = schema.signers
.map((signer) => signer.email.toLowerCase())
.filter((email) => !TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX.test(email));
return new Set(emails).size === emails.length;
return new Set(nonPlaceholderEmails).size === nonPlaceholderEmails.length;
},
// Dirty hack to handle errors when .root is populated for an array type
{ message: 'Signers must have unique emails', path: ['signers__root'] },
)
.refine(
/*
Since placeholder emails are empty, we need to check that the names are unique.
If we don't do this, the app will add duplicate signers and merge them in the next step, where you add fields.
*/
(schema) => {
const names = schema.signers.map((signer) => signer.name.trim());
return new Set(names).size === names.length;
},
{ message: 'Signers must have unique names', path: ['signers__root'] },
);
export type TAddTemplatePlacholderRecipientsFormSchema = z.infer<

View File

@ -9,6 +9,7 @@ import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import {
DOCUMENT_DISTRIBUTION_METHODS,
@ -20,6 +21,7 @@ import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-emai
import type { TTemplate } from '@documenso/lib/types/template';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TDocumentMetaDateFormat } from '@documenso/trpc/server/document-router/schema';
import {
DocumentGlobalAuthAccessSelect,
@ -50,6 +52,10 @@ import {
} from '@documenso/ui/primitives/form/form';
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 { Combobox } from '../combobox';
import {
@ -59,7 +65,6 @@ import {
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from '../document-flow/document-flow-root';
import { ShowFieldItem } from '../document-flow/show-field-item';
import type { DocumentFlowStep } from '../document-flow/types';
import { Input } from '../input';
import { MultiSelectCombobox } from '../multi-select-combobox';
@ -74,7 +79,6 @@ export type AddTemplateSettingsFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
fields: Field[];
isEnterprise: boolean;
isDocumentPdfLoaded: boolean;
template: TTemplate;
currentTeamMemberRole?: TeamMemberRole;
@ -85,7 +89,6 @@ export const AddTemplateSettingsFormPartial = ({
documentFlow,
recipients,
fields,
isEnterprise,
isDocumentPdfLoaded,
template,
currentTeamMemberRole,
@ -93,6 +96,8 @@ export const AddTemplateSettingsFormPartial = ({
}: AddTemplateSettingsFormProps) => {
const { t, i18n } = useLingui();
const organisation = useCurrentOrganisation();
const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: template.authOptions,
});
@ -103,8 +108,8 @@ export const AddTemplateSettingsFormPartial = ({
title: template.title,
externalId: template.externalId || undefined,
visibility: template.visibility || '',
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined,
globalActionAuth: documentAuthOption?.globalActionAuth || undefined,
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
globalActionAuth: documentAuthOption?.globalActionAuth || [],
meta: {
subject: template.templateMeta?.subject ?? '',
message: template.templateMeta?.message ?? '',
@ -116,6 +121,8 @@ export const AddTemplateSettingsFormPartial = ({
template.templateMeta?.distributionMethod || DocumentDistributionMethod.EMAIL,
redirectUrl: template.templateMeta?.redirectUrl ?? '',
language: template.templateMeta?.language ?? 'en',
emailId: template.templateMeta?.emailId ?? null,
emailReplyTo: template.templateMeta?.emailReplyTo ?? undefined,
emailSettings: ZDocumentEmailSettingsSchema.parse(template?.templateMeta?.emailSettings),
signatureTypes: extractTeamSignatureSettings(template?.templateMeta),
},
@ -127,6 +134,14 @@ export const AddTemplateSettingsFormPartial = ({
const distributionMethod = form.watch('meta.distributionMethod');
const emailSettings = form.watch('meta.emailSettings');
const { data: emailData, isLoading: isLoadingEmails } =
trpc.enterprise.organisation.email.find.useQuery({
organisationId: organisation.id,
perPage: 100,
});
const emails = emailData?.data || [];
const canUpdateVisibility = match(currentTeamMemberRole)
.with(TeamMemberRole.ADMIN, () => true)
.with(
@ -153,10 +168,13 @@ export const AddTemplateSettingsFormPartial = ({
/>
<DocumentFlowFormContainerContent>
{isDocumentPdfLoaded &&
fields.map((field, index) => (
<ShowFieldItem key={index} field={field} recipients={recipients} />
))}
{isDocumentPdfLoaded && (
<DocumentReadOnlyFields
showRecipientColors={true}
recipientIds={recipients.map((recipient) => recipient.id)}
fields={mapFieldsWithRecipients(fields, recipients)}
/>
)}
<Form {...form}>
<fieldset
@ -231,7 +249,11 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel>
<FormControl>
<DocumentGlobalAuthAccessSelect {...field} onValueChange={field.onChange} />
<DocumentGlobalAuthAccessSelect
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@ -360,7 +382,7 @@ export const AddTemplateSettingsFormPartial = ({
)}
/>
{isEnterprise && (
{organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name="globalActionAuth"
@ -372,7 +394,11 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel>
<FormControl>
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} />
<DocumentGlobalAuthActionSelect
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@ -388,6 +414,68 @@ export const AddTemplateSettingsFormPartial = ({
<AccordionContent className="text-muted-foreground -mx-1 px-1 pt-4 text-sm leading-relaxed [&>div]:pb-0">
<div className="flex flex-col space-y-6">
{organisation.organisationClaim.flags.emailDomains && (
<FormField
control={form.control}
name="meta.emailId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email Sender</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value}
onValueChange={(value) =>
field.onChange(value === '-1' ? null : value)
}
>
<SelectTrigger
loading={isLoadingEmails}
className="bg-background"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{emails.map((email) => (
<SelectItem key={email.id} value={email.id}>
{email.email}
</SelectItem>
))}
<SelectItem value={'-1'}>Documenso</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="meta.emailReplyTo"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Reply To Email</Trans>{' '}
<span className="text-muted-foreground">(Optional)</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.subject"
@ -413,14 +501,21 @@ export const AddTemplateSettingsFormPartial = ({
name="meta.message"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Message <span className="text-muted-foreground">(Optional)</span>
</Trans>
<FormLabel className="flex flex-row items-center">
<Trans>Message</Trans>{' '}
<span className="text-muted-foreground">(Optional)</span>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground p-4">
<DocumentSendEmailMessageHelper />
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Textarea className="bg-background h-32 resize-none" {...field} />
<Textarea className="bg-background h-16 resize-none" {...field} />
</FormControl>
<FormMessage />
@ -428,8 +523,6 @@ export const AddTemplateSettingsFormPartial = ({
)}
/>
<DocumentSendEmailMessageHelper />
<DocumentEmailCheckboxes
value={emailSettings}
onChange={(value) => form.setValue('meta.emailSettings', value)}

View File

@ -18,18 +18,16 @@ import {
ZDocumentMetaTimezoneSchema,
} from '@documenso/trpc/server/document-router/schema';
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
export const ZAddTemplateSettingsFormSchema = z.object({
title: z.string().trim().min(1, { message: "Title can't be empty" }),
externalId: z.string().optional(),
visibility: z.nativeEnum(DocumentVisibility).optional(),
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentAccessAuthTypesSchema.optional(),
),
globalActionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentActionAuthTypesSchema.optional(),
),
globalAccessAuth: z
.array(z.union([ZDocumentAccessAuthTypesSchema, z.literal('-1')]))
.transform((val) => (val.length === 1 && val[0] === '-1' ? [] : val))
.optional()
.default([]),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]),
meta: z.object({
subject: z.string(),
message: z.string(),
@ -50,6 +48,11 @@ export const ZAddTemplateSettingsFormSchema = z.object({
.union([z.string(), z.enum(SUPPORTED_LANGUAGE_CODES)])
.optional()
.default('en'),
emailId: z.string().nullable(),
emailReplyTo: z.preprocess(
(val) => (val === '' ? undefined : val),
z.string().email().optional(),
),
emailSettings: ZDocumentEmailSettingsSchema,
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
message: msg`At least one signature type must be enabled`.id,

View File

@ -3,7 +3,8 @@
@tailwind utilities;
@layer base {
:root {
:root,
.dark-mode-disabled {
--background: 0 0% 100%;
--foreground: 222.2 47.4% 11.2%;
@ -48,12 +49,12 @@
--gold: 47.9 95.8% 53.1%;
--signer-green: 100 48% 55%;
--signer-blue: 212 56% 50%;
--signer-purple: 266 100% 64%;
--signer-orange: 36 92% 54%;
--signer-yellow: 51 100% 43%;
--signer-pink: 313 65% 57%;
--recipient-green: 100 48% 55%;
--recipient-blue: 212 56% 50%;
--recipient-purple: 266 100% 64%;
--recipient-orange: 36 92% 54%;
--recipient-yellow: 51 100% 43%;
--recipient-pink: 313 65% 57%;
/* Base - Neutral */
--new-neutral-50: 0, 0%, 96%;
@ -190,7 +191,9 @@
body {
@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 {
mask: 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:
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;
-webkit-mask-composite: xor;
}
@ -223,7 +230,7 @@
scrollbar-gutter: stable;
}
.custom-scrollbar::-webkit-scrollbar-track {
/* .custom-scrollbar::-webkit-scrollbar-track {
border-radius: 10px;
}
@ -235,7 +242,7 @@
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgb(100 116 139 / 0.5);
}
} */
/* Custom Swagger Dark Theme */
.swagger-dark-theme .swagger-ui {