mirror of
https://github.com/documenso/documenso.git
synced 2025-11-14 08:42:12 +10:00
feat: add direct templates links (#1165)
## Description Direct templates links is a feature that provides template owners the ability to allow users to create documents based of their templates. ## General outline This works by allowing the template owner to configure a "direct recipient" in the template. When a user opens the direct link to the template, it will create a flow where they sign the fields configured by the template owner for the direct recipient. After these fields are signed the following will occur: - A document will be created where the owner is the template owner - The direct recipient fields will be signed - The document will be sent to any other recipients configured in the template - If there are none the document will be immediately completed ## Notes There's a custom prisma migration to migrate all documents to have 'DOCUMENT' as the source, then sets the column to required. --------- Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
This commit is contained in:
@ -34,7 +34,7 @@ export function DataTablePagination<TData>({
|
||||
const visibleRows = table.getFilteredRowModel().rows.length;
|
||||
|
||||
return (
|
||||
<span>
|
||||
<span data-testid="data-table-count">
|
||||
Showing {visibleRows} result{visibleRows > 1 && 's'}.
|
||||
</span>
|
||||
);
|
||||
|
||||
@ -74,7 +74,10 @@ const DialogContent = React.forwardRef<
|
||||
>
|
||||
{children}
|
||||
{!hideClose && (
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<DialogPrimitive.Close
|
||||
data-testid="btn-dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
|
||||
@ -12,7 +12,6 @@ import { match } from 'ts-pattern';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import type { DocumentData } from '@documenso/prisma/client';
|
||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
import { PasswordDialog } from './document-password-dialog';
|
||||
@ -46,7 +45,6 @@ const PDFLoader = () => (
|
||||
export type PDFViewerProps = {
|
||||
className?: string;
|
||||
documentData: DocumentData;
|
||||
document?: DocumentWithData;
|
||||
password?: string | null;
|
||||
onPasswordSubmit?: (password: string) => void | Promise<void>;
|
||||
onDocumentLoad?: (_doc: LoadedPDFDocument) => void;
|
||||
|
||||
@ -1,15 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import React, { useId, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useId, useMemo, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Plus, Trash } from 'lucide-react';
|
||||
import { Link2Icon, Plus, Trash } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { generateRecipientPlaceholder } from '@documenso/lib/utils/templates';
|
||||
import type { TemplateDirectLink } from '@documenso/prisma/client';
|
||||
import { type Field, type Recipient, RecipientRole } from '@documenso/prisma/client';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
|
||||
@ -30,6 +32,7 @@ import { ShowFieldItem } from '../document-flow/show-field-item';
|
||||
import type { DocumentFlowStep } from '../document-flow/types';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
|
||||
import { useStep } from '../stepper';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
|
||||
import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
|
||||
import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
|
||||
|
||||
@ -37,6 +40,7 @@ export type AddTemplatePlaceholderRecipientsFormProps = {
|
||||
documentFlow: DocumentFlowStep;
|
||||
recipients: Recipient[];
|
||||
fields: Field[];
|
||||
templateDirectLink: TemplateDirectLink | null;
|
||||
isEnterprise: boolean;
|
||||
isDocumentPdfLoaded: boolean;
|
||||
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
|
||||
@ -46,6 +50,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
documentFlow,
|
||||
isEnterprise,
|
||||
recipients,
|
||||
templateDirectLink,
|
||||
fields,
|
||||
isDocumentPdfLoaded,
|
||||
onSubmit,
|
||||
@ -61,32 +66,43 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
|
||||
const { currentStep, totalSteps, previousStep } = useStep();
|
||||
|
||||
const generateDefaultFormSigners = () => {
|
||||
if (recipients.length === 0) {
|
||||
return [
|
||||
{
|
||||
formId: initialId,
|
||||
role: RecipientRole.SIGNER,
|
||||
actionAuth: undefined,
|
||||
...generateRecipientPlaceholder(1),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return recipients.map((recipient) => ({
|
||||
nativeId: recipient.id,
|
||||
formId: String(recipient.id),
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
const form = useForm<TAddTemplatePlacholderRecipientsFormSchema>({
|
||||
resolver: zodResolver(ZAddTemplatePlacholderRecipientsFormSchema),
|
||||
defaultValues: {
|
||||
signers:
|
||||
recipients.length > 0
|
||||
? recipients.map((recipient) => ({
|
||||
nativeId: recipient.id,
|
||||
formId: String(recipient.id),
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
actionAuth:
|
||||
ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
formId: initialId,
|
||||
name: `Recipient 1`,
|
||||
email: `recipient.1@documenso.com`,
|
||||
role: RecipientRole.SIGNER,
|
||||
actionAuth: undefined,
|
||||
},
|
||||
],
|
||||
signers: generateDefaultFormSigners(),
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
signers: generateDefaultFormSigners(),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [recipients]);
|
||||
|
||||
// Always show advanced settings if any recipient has auth options.
|
||||
const alwaysShowAdvancedSettings = useMemo(() => {
|
||||
const recipientHasAuthOptions = recipients.find((recipient) => {
|
||||
@ -130,11 +146,8 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
const onAddPlaceholderRecipient = () => {
|
||||
appendSigner({
|
||||
formId: nanoid(12),
|
||||
// Update TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX if this is ever changed.
|
||||
name: `Recipient ${placeholderRecipientCount}`,
|
||||
// Update TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX if this is ever changed.
|
||||
email: `recipient.${placeholderRecipientCount}@documenso.com`,
|
||||
role: RecipientRole.SIGNER,
|
||||
...generateRecipientPlaceholder(placeholderRecipientCount),
|
||||
});
|
||||
|
||||
setPlaceholderRecipientCount((count) => count + 1);
|
||||
@ -144,6 +157,15 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
removeSigner(index);
|
||||
};
|
||||
|
||||
const isSignerDirectRecipient = (
|
||||
signer: TAddTemplatePlacholderRecipientsFormSchema['signers'][number],
|
||||
): boolean => {
|
||||
return (
|
||||
templateDirectLink !== null &&
|
||||
signer.nativeId === templateDirectLink?.directTemplateRecipientId
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentFlowFormContainerContent>
|
||||
@ -183,7 +205,12 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
{...field}
|
||||
disabled={isSubmitting || signers[index].email === user?.email}
|
||||
disabled={
|
||||
field.disabled ||
|
||||
isSubmitting ||
|
||||
signers[index].email === user?.email ||
|
||||
isSignerDirectRecipient(signer)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@ -208,7 +235,12 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
<Input
|
||||
placeholder="Name"
|
||||
{...field}
|
||||
disabled={isSubmitting || signers[index].email === user?.email}
|
||||
disabled={
|
||||
field.disabled ||
|
||||
isSubmitting ||
|
||||
signers[index].email === user?.email ||
|
||||
isSignerDirectRecipient(signer)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@ -246,6 +278,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
{...field}
|
||||
onValueChange={field.onChange}
|
||||
disabled={isSubmitting}
|
||||
hideCCRecipients={isSignerDirectRecipient(signer)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@ -254,14 +287,32 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isSubmitting || signers.length === 1}
|
||||
onClick={() => onRemoveSigner(index)}
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
{isSignerDirectRecipient(signer) ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80">
|
||||
<Link2Icon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
||||
<h3 className="text-foreground text-lg font-semibold">
|
||||
Direct link receiver
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
This field cannot be modified or deleted. When you share this template's
|
||||
direct link or add it to your public profile, anyone who accesses it can
|
||||
input their name and email, and fill in the fields assigned to them.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isSubmitting || signers.length === 1}
|
||||
onClick={() => onRemoveSigner(index)}
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</motion.fieldset>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user