feat: add document distribution setting (#1437)

Add a document distribution setting which will allow us to further
configure how recipients currently receive documents.
This commit is contained in:
David Nguyen
2024-11-08 13:32:13 +09:00
committed by GitHub
parent 451723a8ab
commit f6bcf921d5
32 changed files with 911 additions and 160 deletions

View File

@ -0,0 +1,222 @@
import { Trans } from '@lingui/macro';
import { InfoIcon } from 'lucide-react';
import { DocumentEmailEvents } from '@documenso/lib/types/document-email';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { cn } from '../../lib/utils';
import { Checkbox } from '../../primitives/checkbox';
type Value = Record<DocumentEmailEvents, boolean>;
type DocumentEmailCheckboxesProps = {
value: Value;
onChange: (value: Value) => void;
className?: string;
};
export const DocumentEmailCheckboxes = ({
value,
onChange,
className,
}: DocumentEmailCheckboxesProps) => {
return (
<div className={cn('space-y-3', className)}>
<div className="flex flex-row items-center">
<Checkbox
id={DocumentEmailEvents.RecipientSigningRequest}
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={value.recipientSigningRequest}
onCheckedChange={(checked) =>
onChange({ ...value, [DocumentEmailEvents.RecipientSigningRequest]: Boolean(checked) })
}
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
htmlFor={DocumentEmailEvents.RecipientSigningRequest}
>
<Trans>Send recipient signing request email</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<h2>
<strong>
<Trans>Recipient signing request email</Trans>
</strong>
</h2>
<p>
<Trans>
This email is sent to the recipient requesting them to sign the document.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
</div>
<div className="flex flex-row items-center">
<Checkbox
id={DocumentEmailEvents.RecipientRemoved}
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={value.recipientRemoved}
onCheckedChange={(checked) =>
onChange({ ...value, [DocumentEmailEvents.RecipientRemoved]: Boolean(checked) })
}
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
htmlFor={DocumentEmailEvents.RecipientRemoved}
>
<Trans>Send recipient removed email</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<h2>
<strong>
<Trans>Recipient removed email</Trans>
</strong>
</h2>
<p>
<Trans>
This email is sent to the recipient if they are removed from a pending document.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
</div>
<div className="flex flex-row items-center">
<Checkbox
id={DocumentEmailEvents.DocumentPending}
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={value.documentPending}
onCheckedChange={(checked) =>
onChange({ ...value, [DocumentEmailEvents.DocumentPending]: Boolean(checked) })
}
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
htmlFor={DocumentEmailEvents.DocumentPending}
>
<Trans>Send document pending email</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<h2>
<strong>
<Trans>Document pending email</Trans>
</strong>
</h2>
<p>
<Trans>
This email will be sent to the recipient who has just signed the document, if
there are still other recipients who have not signed yet.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
</div>
<div className="flex flex-row items-center">
<Checkbox
id={DocumentEmailEvents.DocumentCompleted}
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={value.documentCompleted}
onCheckedChange={(checked) =>
onChange({ ...value, [DocumentEmailEvents.DocumentCompleted]: Boolean(checked) })
}
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
htmlFor={DocumentEmailEvents.DocumentCompleted}
>
<Trans>Send document completed email</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<h2>
<strong>
<Trans>Document completed email</Trans>
</strong>
</h2>
<p>
<Trans>
This will be sent to all recipients once the document has been fully completed.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
</div>
<div className="flex flex-row items-center">
<Checkbox
id={DocumentEmailEvents.DocumentDeleted}
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={value.documentDeleted}
onCheckedChange={(checked) =>
onChange({ ...value, [DocumentEmailEvents.DocumentDeleted]: Boolean(checked) })
}
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
htmlFor={DocumentEmailEvents.DocumentDeleted}
>
<Trans>Send document deleted email</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<h2>
<strong>
<Trans>Document deleted email</Trans>
</strong>
</h2>
<p>
<Trans>
This will be sent to all recipients if a pending document has been deleted.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
</div>
</div>
);
};

View File

@ -2,18 +2,32 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { AnimatePresence, motion } from 'framer-motion';
import { useForm } from 'react-hook-form';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import type { Field, Recipient } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import {
DocumentDistributionMethod,
DocumentStatus,
RecipientRole,
} from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
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 { 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 { toast } from '../use-toast';
import { type TAddSubjectFormSchema, ZAddSubjectFormSchema } from './add-subject.types';
import {
DocumentFlowFormContainerActions,
@ -42,20 +56,45 @@ export const AddSubjectFormPartial = ({
onSubmit,
isDocumentPdfLoaded,
}: AddSubjectFormProps) => {
const { _ } = useLingui();
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors, isSubmitting },
} = useForm<TAddSubjectFormSchema>({
defaultValues: {
meta: {
subject: document.documentMeta?.subject ?? '',
message: document.documentMeta?.message ?? '',
distributionMethod:
document.documentMeta?.distributionMethod || DocumentDistributionMethod.EMAIL,
emailSettings: ZDocumentEmailSettingsSchema.parse(document?.documentMeta?.emailSettings),
},
},
resolver: zodResolver(ZAddSubjectFormSchema),
});
const GoNextLabel = {
[DocumentDistributionMethod.EMAIL]: {
[DocumentStatus.DRAFT]: msg`Send`,
[DocumentStatus.PENDING]: recipients.some((recipient) => recipient.sendStatus === 'SENT')
? msg`Resend`
: msg`Send`,
[DocumentStatus.COMPLETED]: msg`Update`,
},
[DocumentDistributionMethod.NONE]: {
[DocumentStatus.DRAFT]: msg`Generate Links`,
[DocumentStatus.PENDING]: msg`View Document`,
[DocumentStatus.COMPLETED]: msg`View Document`,
},
};
const distributionMethod = watch('meta.distributionMethod');
const emailSettings = watch('meta.emailSettings');
const onFormSubmit = handleSubmit(onSubmit);
const { currentStep, totalSteps, previousStep } = useStep();
@ -72,46 +111,158 @@ export const AddSubjectFormPartial = ({
<ShowFieldItem key={index} field={field} recipients={recipients} />
))}
<div className="flex flex-col gap-y-4">
<div>
<Label htmlFor="subject">
<Trans>
Subject <span className="text-muted-foreground">(Optional)</span>
</Trans>
</Label>
<Tabs
onValueChange={(value) =>
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
setValue('meta.distributionMethod', value as DocumentDistributionMethod)
}
value={distributionMethod}
className="mb-2"
>
<TabsList className="w-full">
<TabsTrigger className="w-full" value={DocumentDistributionMethod.EMAIL}>
Email
</TabsTrigger>
<TabsTrigger className="w-full" value={DocumentDistributionMethod.NONE}>
None
</TabsTrigger>
</TabsList>
</Tabs>
<Input
id="subject"
className="bg-background mt-2"
disabled={isSubmitting}
{...register('meta.subject')}
/>
<AnimatePresence mode="wait">
{distributionMethod === DocumentDistributionMethod.EMAIL && (
<motion.div
key={'Emails'}
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>
<FormErrorMessage className="mt-2" error={errors.meta?.subject} />
</div>
<Input
id="subject"
className="bg-background mt-2"
disabled={isSubmitting}
{...register('meta.subject')}
/>
<div>
<Label htmlFor="message">
<Trans>
Message <span className="text-muted-foreground">(Optional)</span>
</Trans>
</Label>
<FormErrorMessage className="mt-2" error={errors.meta?.subject} />
</div>
<Textarea
id="message"
className="bg-background mt-2 h-32 resize-none"
disabled={isSubmitting}
{...register('meta.message')}
/>
<div>
<Label htmlFor="message">
<Trans>
Message <span className="text-muted-foreground">(Optional)</span>
</Trans>
</Label>
<FormErrorMessage
className="mt-2"
error={typeof errors.meta?.message !== 'string' ? errors.meta?.message : undefined}
/>
</div>
<Textarea
id="message"
className="bg-background mt-2 h-32 resize-none"
disabled={isSubmitting}
{...register('meta.message')}
/>
<DocumentSendEmailMessageHelper />
</div>
<FormErrorMessage
className="mt-2"
error={
typeof errors.meta?.message !== 'string' ? errors.meta?.message : undefined
}
/>
</div>
<DocumentSendEmailMessageHelper />
<DocumentEmailCheckboxes
className="mt-2"
value={emailSettings}
onChange={(value) => setValue('meta.emailSettings', value)}
/>
</motion.div>
)}
{distributionMethod === DocumentDistributionMethod.NONE && (
<motion.div
key={'Links'}
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
exit={{ opacity: 0, transition: { duration: 0.15 } }}
className="rounded-lg border"
>
{document.status === DocumentStatus.DRAFT ? (
<div className="text-muted-foreground py-16 text-center text-sm">
<p>
<Trans>We won't send anything to notify recipients.</Trans>
</p>
<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.
</Trans>
</p>
</div>
) : (
<ul className="text-muted-foreground divide-y">
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans>
</li>
)}
{recipients.map((recipient) => (
<li
key={recipient.id}
className="flex items-center justify-between px-4 py-3 text-sm"
>
<AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
primaryText={
<p className="text-muted-foreground text-sm">{recipient.email}</p>
}
secondaryText={
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
}
/>
{recipient.role !== RecipientRole.CC && (
<CopyTextButton
value={formatSigningLink(recipient.token)}
onCopySuccess={() => {
toast({
title: _(msg`Copied to clipboard`),
description: _(
msg`The signing link has been copied to your clipboard.`,
),
});
}}
badgeContentUncopied={
<p className="ml-1 text-xs">
<Trans>Copy</Trans>
</p>
}
badgeContentCopied={
<p className="ml-1 text-xs">
<Trans>Copied</Trans>
</p>
}
/>
)}
</li>
))}
</ul>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</DocumentFlowFormContainerContent>
@ -121,7 +272,7 @@ export const AddSubjectFormPartial = ({
<DocumentFlowFormContainerActions
loading={isSubmitting}
disabled={isSubmitting}
goNextLabel={document.status === DocumentStatus.DRAFT ? msg`Send` : msg`Update`}
goNextLabel={GoNextLabel[distributionMethod][document.status]}
onGoBackClick={previousStep}
onGoNextClick={() => void onFormSubmit()}
/>

View File

@ -1,9 +1,18 @@
import { z } from 'zod';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { DocumentDistributionMethod } from '.prisma/client';
export const ZAddSubjectFormSchema = z.object({
meta: z.object({
subject: z.string(),
message: z.string(),
distributionMethod: z
.nativeEnum(DocumentDistributionMethod)
.optional()
.default(DocumentDistributionMethod.EMAIL),
emailSettings: ZDocumentEmailSettingsSchema,
}),
});

View File

@ -4,14 +4,17 @@ import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_DISTRIBUTION_METHODS } from '@documenso/lib/constants/document';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { type Field, type Recipient } from '@documenso/prisma/client';
import { DocumentDistributionMethod, type Field, type Recipient } from '@documenso/prisma/client';
import type { TemplateWithData } from '@documenso/prisma/types/template';
import {
DocumentGlobalAuthAccessSelect,
@ -37,6 +40,7 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { DocumentEmailCheckboxes } from '../../components/document/document-email-checkboxes';
import { Combobox } from '../combobox';
import {
DocumentFlowFormContainerActions,
@ -74,6 +78,8 @@ export const AddTemplateSettingsFormPartial = ({
template,
onSubmit,
}: AddTemplateSettingsFormProps) => {
const { _ } = useLingui();
const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: template.authOptions,
});
@ -90,14 +96,20 @@ export const AddTemplateSettingsFormPartial = ({
message: template.templateMeta?.message ?? '',
timezone: template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
dateFormat: template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
distributionMethod:
template.templateMeta?.distributionMethod || DocumentDistributionMethod.EMAIL,
redirectUrl: template.templateMeta?.redirectUrl ?? '',
language: template.templateMeta?.language ?? 'en',
emailSettings: ZDocumentEmailSettingsSchema.parse(template?.templateMeta?.emailSettings),
},
},
});
const { stepIndex, currentStep, totalSteps, previousStep } = useStep();
const distributionMethod = form.watch('meta.distributionMethod');
const emailSettings = form.watch('meta.emailSettings');
// We almost always want to set the timezone to the user's local timezone to avoid confusion
// when the document is signed.
useEffect(() => {
@ -198,6 +210,77 @@ export const AddTemplateSettingsFormPartial = ({
)}
/>
<FormField
control={form.control}
name="meta.distributionMethod"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Document Distribution Method</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<h2>
<strong>
<Trans>Document Distribution Method</Trans>
</strong>
</h2>
<p>
<Trans>
This is how the document will reach the recipients once the document is
ready for signing.
</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<Trans>
<strong>Email</strong> - The recipient will be emailed the document to
sign, approve, etc.
</Trans>
</li>
<li>
<Trans>
<strong>Links</strong> - We will generate links which you can send to
the recipients manually.
</Trans>
</li>
</ul>
<Trans>
<strong>Note</strong> - If you use Links in combination with direct
templates, you will need to manually send the links to the remaining
recipients.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue data-testid="documentDistributionMethodSelectValue" />
</SelectTrigger>
<SelectContent position="popper">
{Object.values(DOCUMENT_DISTRIBUTION_METHODS).map(
({ value, description }) => (
<SelectItem key={value} value={value}>
{_(description)}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
{isEnterprise && (
<FormField
control={form.control}
@ -217,59 +300,66 @@ export const AddTemplateSettingsFormPartial = ({
/>
)}
<Accordion type="multiple">
<AccordionItem value="email-options" className="border-none">
<AccordionTrigger className="text-foreground rounded border px-3 py-2 text-left hover:bg-neutral-200/30 hover:no-underline">
<Trans>Email Options</Trans>
</AccordionTrigger>
{distributionMethod === DocumentDistributionMethod.EMAIL && (
<Accordion type="multiple">
<AccordionItem value="email-options" className="border-none">
<AccordionTrigger className="text-foreground rounded border px-3 py-2 text-left hover:bg-neutral-200/30 hover:no-underline">
<Trans>Email Options</Trans>
</AccordionTrigger>
<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">
<FormField
control={form.control}
name="meta.subject"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Subject <span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<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">
<FormField
control={form.control}
name="meta.subject"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Subject <span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.message"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Message <span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormField
control={form.control}
name="meta.message"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Message <span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Textarea className="bg-background h-32 resize-none" {...field} />
</FormControl>
<FormControl>
<Textarea className="bg-background h-32 resize-none" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormMessage />
</FormItem>
)}
/>
<DocumentSendEmailMessageHelper />
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<DocumentSendEmailMessageHelper />
<DocumentEmailCheckboxes
value={emailSettings}
onChange={(value) => form.setValue('meta.emailSettings', value)}
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
<Accordion type="multiple">
<AccordionItem value="advanced-options" className="border-none">

View File

@ -7,9 +7,11 @@ import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
import { DocumentDistributionMethod } from '.prisma/client';
export const ZAddTemplateSettingsFormSchema = z.object({
title: z.string().trim().min(1, { message: "Title can't be empty" }),
@ -25,6 +27,10 @@ export const ZAddTemplateSettingsFormSchema = z.object({
message: z.string(),
timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),
distributionMethod: z
.nativeEnum(DocumentDistributionMethod)
.optional()
.default(DocumentDistributionMethod.EMAIL),
redirectUrl: z
.string()
.optional()
@ -36,6 +42,7 @@ export const ZAddTemplateSettingsFormSchema = z.object({
.union([z.string(), z.enum(SUPPORTED_LANGUAGE_CODES)])
.optional()
.default('en'),
emailSettings: ZDocumentEmailSettingsSchema,
}),
});