feat: add template meta for templates

Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
This commit is contained in:
Adithya Krishna
2024-04-18 17:47:50 +05:30
parent 9715dbfeaa
commit cce0cdfbe2
12 changed files with 569 additions and 6 deletions

View File

@ -1,9 +1,15 @@
/* eslint-disable unused-imports/no-unused-imports */
/* eslint-disable @typescript-eslint/consistent-type-imports */
/* eslint-disable unused-imports/no-unused-vars */
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client'; import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@ -19,8 +25,14 @@ import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types'; import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients'; import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients';
import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types'; import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types';
import { AddTemplateSettingsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-settings';
import { TTemplateSettingsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-settings.types';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
/* eslint-disable unused-imports/no-unused-imports */
/* eslint-disable @typescript-eslint/consistent-type-imports */
/* eslint-disable unused-imports/no-unused-vars */
export type EditTemplateFormProps = { export type EditTemplateFormProps = {
className?: string; className?: string;
user: User; user: User;
@ -31,8 +43,8 @@ export type EditTemplateFormProps = {
templateRootPath: string; templateRootPath: string;
}; };
type EditTemplateStep = 'signers' | 'fields'; type EditTemplateStep = 'settings' | 'signers' | 'fields';
const EditTemplateSteps: EditTemplateStep[] = ['signers', 'fields']; const EditTemplateSteps: EditTemplateStep[] = ['settings', 'signers', 'fields'];
export const EditTemplateForm = ({ export const EditTemplateForm = ({
className, className,
@ -49,15 +61,20 @@ export const EditTemplateForm = ({
const [step, setStep] = useState<EditTemplateStep>('signers'); const [step, setStep] = useState<EditTemplateStep>('signers');
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = { const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
settings: {
title: 'General',
description: 'Configure general settings for the document.',
stepIndex: 1,
},
signers: { signers: {
title: 'Add Placeholders', title: 'Add Placeholders',
description: 'Add all relevant placeholders for each recipient.', description: 'Add all relevant placeholders for each recipient.',
stepIndex: 1, stepIndex: 2,
}, },
fields: { fields: {
title: 'Add Fields', title: 'Add Fields',
description: 'Add all relevant fields for each recipient.', description: 'Add all relevant fields for each recipient.',
stepIndex: 2, stepIndex: 3,
}, },
}; };
@ -87,6 +104,35 @@ export const EditTemplateForm = ({
} }
}; };
const onAddTemplateSettingsFormSubmit = async (data: TTemplateSettingsFormSchema) => {
// try {
// const { timezone, dateFormat, redirectUrl } = data.meta;
// await setSettingsForDocument({
// documentId: document.id,
// data: {
// title: data.title,
// globalAccessAuth: data.globalAccessAuth ?? null,
// globalActionAuth: data.globalActionAuth ?? null,
// },
// meta: {
// timezone,
// dateFormat,
// redirectUrl,
// },
// });
// // Router refresh is here to clear the router cache for when navigating to /documents.
// router.refresh();
// setStep('signers');
// } catch (err) {
// console.error(err);
// toast({
// title: 'Error',
// description: 'An error occurred while updating the document settings.',
// variant: 'destructive',
// });
// }
};
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => { const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
try { try {
await addTemplateFields({ await addTemplateFields({
@ -135,6 +181,13 @@ export const EditTemplateForm = ({
currentStep={currentDocumentFlow.stepIndex} currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])} setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
> >
<AddTemplateSettingsFormPartial
key={recipients.length}
documentFlow={documentFlow.signers}
recipients={recipients}
fields={fields}
onSubmit={onAddTemplateSettingsFormSubmit}
/>
<AddTemplatePlaceholderRecipientsFormPartial <AddTemplatePlaceholderRecipientsFormPartial
key={recipients.length} key={recipients.length}
documentFlow={documentFlow.signers} documentFlow={documentFlow.signers}

View File

@ -44,7 +44,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
redirect(templateRootPath); redirect(templateRootPath);
} }
const { templateDocumentData } = template; const { templateDocumentData, templateDocumentMeta } = template;
const [templateRecipients, templateFields] = await Promise.all([ const [templateRecipients, templateFields] = await Promise.all([
getRecipientsForTemplate({ getRecipientsForTemplate({
@ -79,6 +79,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
recipients={templateRecipients} recipients={templateRecipients}
fields={templateFields} fields={templateFields}
documentData={templateDocumentData} documentData={templateDocumentData}
documemntMeta={templateDocumentMeta}
templateRootPath={templateRootPath} templateRootPath={templateRootPath}
/> />
</div> </div>

View File

@ -80,7 +80,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
text: render(template, { plainText: true }), text: render(template, { plainText: true }),
attachments: [ attachments: [
{ {
filename: document.title, filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
content: Buffer.from(completedDocument), content: Buffer.from(completedDocument),
}, },
], ],

View File

@ -0,0 +1,50 @@
'use server';
import { prisma } from '@documenso/prisma';
export type CreateTemplateDocumentMetaOptions = {
templateId: number;
subject?: string;
message?: string;
timezone?: string;
password?: string;
dateFormat?: string;
redirectUrl?: string;
};
export const upsertTemplateDocumentMeta = async ({
subject,
message,
timezone,
dateFormat,
templateId,
password,
redirectUrl,
}: CreateTemplateDocumentMetaOptions) => {
return await prisma.$transaction(async (tx) => {
const upsertedTemplateDocumentMeta = await tx.templateDocumentMeta.upsert({
where: {
templateId,
},
update: {
subject,
message,
timezone,
password,
dateFormat,
redirectUrl,
},
create: {
templateId,
subject,
message,
timezone,
password,
dateFormat,
redirectUrl,
},
});
return upsertedTemplateDocumentMeta;
});
};

View File

@ -42,6 +42,7 @@ export const createDocumentFromTemplate = async ({
Recipient: true, Recipient: true,
Field: true, Field: true,
templateDocumentData: true, templateDocumentData: true,
templateDocumentMeta: true,
}, },
}); });
@ -57,12 +58,24 @@ export const createDocumentFromTemplate = async ({
}, },
}); });
// const templateDocumentMeta = await prisma.documentMeta.create({
// data: {
// subject: template.templateDocumentMeta.subject,
// message: template.templateDocumentMeta.message,
// timezone: template.templateDocumentMeta.timezone,
// password: template.templateDocumentMeta.password,
// dateFormat: template.templateDocumentMeta.dateFormat,
// redirectUrl: template.templateDocumentMeta.redirectUrl,
// },
// });
const document = await prisma.document.create({ const document = await prisma.document.create({
data: { data: {
userId, userId,
teamId: template.teamId, teamId: template.teamId,
title: template.title, title: template.title,
documentDataId: documentData.id, documentDataId: documentData.id,
// documentMeta: templateDocumentMeta,
Recipient: { Recipient: {
create: template.Recipient.map((recipient) => ({ create: template.Recipient.map((recipient) => ({
email: recipient.email, email: recipient.email,

View File

@ -38,6 +38,7 @@ export const duplicateTemplate = async ({
Recipient: true, Recipient: true,
Field: true, Field: true,
templateDocumentData: true, templateDocumentData: true,
templateDocumentMeta: true,
}, },
}); });

View File

@ -37,6 +37,7 @@ export const findTemplates = async ({
where: whereFilter, where: whereFilter,
include: { include: {
templateDocumentData: true, templateDocumentData: true,
templateDocumentMeta: true,
team: { team: {
select: { select: {
id: true, id: true,

View File

@ -29,6 +29,7 @@ export const getTemplateById = async ({ id, userId }: GetTemplateByIdOptions) =>
where: whereFilter, where: whereFilter,
include: { include: {
templateDocumentData: true, templateDocumentData: true,
templateDocumentMeta: true,
}, },
}); });
}; };

View File

@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "TemplateDocumentMeta" (
"id" TEXT NOT NULL,
"subject" TEXT,
"message" TEXT,
"timezone" TEXT DEFAULT 'Etc/UTC',
"password" TEXT,
"dateFormat" TEXT DEFAULT 'yyyy-MM-dd hh:mm a',
"templateId" INTEGER NOT NULL,
"redirectUrl" TEXT,
CONSTRAINT "TemplateDocumentMeta_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TemplateDocumentMeta_templateId_key" ON "TemplateDocumentMeta"("templateId");
-- AddForeignKey
ALTER TABLE "TemplateDocumentMeta" ADD CONSTRAINT "TemplateDocumentMeta_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -324,6 +324,18 @@ model DocumentMeta {
redirectUrl String? redirectUrl String?
} }
model TemplateDocumentMeta {
id String @id @default(cuid())
subject String?
message String?
timezone String? @default("Etc/UTC") @db.Text
password String?
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
templateId Int @unique
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
redirectUrl String?
}
enum ReadStatus { enum ReadStatus {
NOT_OPENED NOT_OPENED
OPENED OPENED
@ -550,6 +562,7 @@ model Template {
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade) templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
templateDocumentMeta TemplateDocumentMeta?
User User @relation(fields: [userId], references: [id], onDelete: Cascade) User User @relation(fields: [userId], references: [id], onDelete: Cascade)
Recipient Recipient[] Recipient Recipient[]
Field Field[] Field Field[]

View File

@ -0,0 +1,369 @@
'use client';
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
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_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { DocumentAccessAuth, DocumentActionAuth } from '@documenso/lib/types/document-auth';
import { DocumentStatus, type Field, type Recipient, SendStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@documenso/ui/primitives/accordion';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Combobox } from '../combobox';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
import { useStep } from '../stepper';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import type { TTemplateSettingsFormSchema } from './add-template-settings.types';
import { ZTemplateSettingsFormSchema } from './add-template-settings.types';
export type AddSettingsFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
fields: Field[];
isDocumentEnterprise: boolean;
isDocumentPdfLoaded: boolean;
document: DocumentWithData;
onSubmit: (_data: TTemplateSettingsFormSchema) => void;
};
export const AddTemplateSettingsFormPartial = ({
documentFlow,
recipients,
fields,
isDocumentEnterprise,
isDocumentPdfLoaded,
document,
onSubmit,
}: AddSettingsFormProps) => {
const form = useForm<TTemplateSettingsFormSchema>({
resolver: zodResolver(ZTemplateSettingsFormSchema),
defaultValues: {
title: document.title,
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined,
globalActionAuth: documentAuthOption?.globalActionAuth || undefined,
meta: {
timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
dateFormat: document.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
redirectUrl: document.documentMeta?.redirectUrl ?? '',
},
},
});
const { stepIndex, currentStep, totalSteps, previousStep } = useStep();
const documentHasBeenSent = recipients.some(
(recipient) => recipient.sendStatus === SendStatus.SENT,
);
// We almost always want to set the timezone to the user's local timezone to avoid confusion
// when the document is signed.
useEffect(() => {
if (!form.formState.touchedFields.meta?.timezone && !documentHasBeenSent) {
form.setValue('meta.timezone', Intl.DateTimeFormat().resolvedOptions().timeZone);
}
}, [documentHasBeenSent, form, form.setValue, form.formState.touchedFields.meta?.timezone]);
return (
<>
<DocumentFlowFormContainerHeader
title={documentFlow.title}
description={documentFlow.description}
/>
<DocumentFlowFormContainerContent>
{isDocumentPdfLoaded &&
fields.map((field, index) => (
<ShowFieldItem key={index} field={field} recipients={recipients} />
))}
<Form {...form}>
<fieldset
className="flex h-full flex-col space-y-6"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel required>Title</FormLabel>
<FormControl>
<Input
className="bg-background"
{...field}
disabled={document.status !== DocumentStatus.DRAFT || field.disabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="globalAccessAuth"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
Document access
<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>Document access</strong>
</h2>
<p>The authentication required for recipients to view the document.</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 to
view the document
</li>
<li>
<strong>None</strong> - The document can be accessed directly by the URL
sent to the recipient
</li>
</ul>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue data-testid="documentAccessSelectValue" placeholder="None" />
</SelectTrigger>
<SelectContent position="popper">
{Object.values(DocumentAccessAuth).map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>None</SelectItem>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
{isDocumentEnterprise && (
<FormField
control={form.control}
name="globalActionAuth"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
Recipient action authentication
<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>Global recipient action authentication</strong>
</h2>
<p>
The authentication required for recipients to sign the signature field.
</p>
<p>
This can be overriden by setting the authentication requirements
directly on each recipient in the next step.
</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>
<strong>Require passkey</strong> - The recipient must have an account
and passkey configured via their settings
</li>
<li>
<strong>Require 2FA</strong> - The recipient must have an account and
2FA enabled via their settings
</li>
<li>
<strong>None</strong> - No authentication required
</li>
</ul>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue data-testid="documentActionSelectValue" placeholder="None" />
</SelectTrigger>
<SelectContent position="popper">
{Object.values(DocumentActionAuth).map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>None</SelectItem>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
)}
<Accordion type="multiple" className="mt-6">
<AccordionItem value="advanced-options" className="border-none">
<AccordionTrigger className="text-foreground mb-2 rounded border px-3 py-2 text-left hover:bg-neutral-200/30 hover:no-underline">
Advanced Options
</AccordionTrigger>
<AccordionContent className="text-muted-foreground -mx-1 px-1 pt-2 text-sm leading-relaxed">
<div className="flex flex-col space-y-6 ">
<FormField
control={form.control}
name="meta.dateFormat"
render={({ field }) => (
<FormItem>
<FormLabel>Date Format</FormLabel>
<FormControl>
<Select
{...field}
onValueChange={field.onChange}
disabled={documentHasBeenSent}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.timezone"
render={({ field }) => (
<FormItem>
<FormLabel>Time Zone</FormLabel>
<FormControl>
<Combobox
className="bg-background"
options={TIME_ZONES}
{...field}
onChange={(value) => value && field.onChange(value)}
disabled={documentHasBeenSent}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.redirectUrl"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
Redirect URL{' '}
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
Add a URL to redirect the user to once the document is signed
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</fieldset>
</Form>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep
title={documentFlow.title}
step={currentStep}
maxStep={totalSteps}
/>
<DocumentFlowFormContainerActions
loading={form.formState.isSubmitting}
disabled={form.formState.isSubmitting}
canGoBack={stepIndex !== 0}
onGoBackClick={previousStep}
onGoNextClick={form.handleSubmit(onSubmit)}
/>
</DocumentFlowFormContainerFooter>
</>
);
};

View File

@ -0,0 +1,42 @@
import { z } from 'zod';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
export const ZMapNegativeOneToUndefinedSchema = z
.string()
.optional()
.transform((val) => {
if (val === '-1') {
return undefined;
}
return val;
});
export const ZTemplateSettingsFormSchema = z.object({
title: z.string().trim().min(1, { message: "Title can't be empty" }),
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentAccessAuthTypesSchema.optional(),
),
globalActionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentActionAuthTypesSchema.optional(),
),
meta: z.object({
timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
}),
}),
});
export type TTemplateSettingsFormSchema = z.infer<typeof ZTemplateSettingsFormSchema>;