mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
feat: dialog to enter custom recipients for creating document from template
This commit is contained in:
@ -1,30 +1,31 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useTransition } from 'react';
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { AlertTriangle, Loader, Plus } from 'lucide-react';
|
import { AlertTriangle, Loader } from 'lucide-react';
|
||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import type { Template } from '@documenso/prisma/client';
|
import type { Recipient, Template } from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
import { TemplateType } from '~/components/formatter/template-type';
|
import { TemplateType } from '~/components/formatter/template-type';
|
||||||
|
|
||||||
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
||||||
import { DataTableTitle } from './data-table-title';
|
import { DataTableTitle } from './data-table-title';
|
||||||
|
import { UseTemplateDialog } from './use-template-dialog';
|
||||||
|
|
||||||
|
type TemplateWithRecipient = Template & {
|
||||||
|
Recipient: Recipient[];
|
||||||
|
};
|
||||||
|
|
||||||
type TemplatesDataTableProps = {
|
type TemplatesDataTableProps = {
|
||||||
templates: Template[];
|
templates: TemplateWithRecipient[];
|
||||||
perPage: number;
|
perPage: number;
|
||||||
page: number;
|
page: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
@ -47,14 +48,6 @@ export const TemplatesDataTable = ({
|
|||||||
|
|
||||||
const { remaining } = useLimits();
|
const { remaining } = useLimits();
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [loadingStates, setLoadingStates] = useState<{ [key: string]: boolean }>({});
|
|
||||||
|
|
||||||
const { mutateAsync: createDocumentFromTemplate } =
|
|
||||||
trpc.template.createDocumentFromTemplate.useMutation();
|
|
||||||
|
|
||||||
const onPaginationChange = (page: number, perPage: number) => {
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
updateSearchParams({
|
updateSearchParams({
|
||||||
@ -64,28 +57,6 @@ export const TemplatesDataTable = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onUseButtonClick = async (templateId: number) => {
|
|
||||||
try {
|
|
||||||
const { id } = await createDocumentFromTemplate({
|
|
||||||
templateId,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Document created',
|
|
||||||
description: 'Your document has been created from the template successfully.',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push(`${documentRootPath}/${id}`);
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: 'Error',
|
|
||||||
description: 'An error occurred while creating document from template.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{remaining.documents === 0 && (
|
{remaining.documents === 0 && (
|
||||||
@ -121,22 +92,13 @@ export const TemplatesDataTable = ({
|
|||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
accessorKey: 'actions',
|
accessorKey: 'actions',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const isRowLoading = loadingStates[row.original.id];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-x-4">
|
<div className="flex items-center gap-x-4">
|
||||||
<Button
|
<UseTemplateDialog
|
||||||
disabled={isRowLoading || remaining.documents === 0}
|
templateId={row.original.id}
|
||||||
loading={isRowLoading}
|
recipients={row.original.Recipient}
|
||||||
onClick={async () => {
|
documentRootPath={documentRootPath}
|
||||||
setLoadingStates((prev) => ({ ...prev, [row.original.id]: true }));
|
/>
|
||||||
await onUseButtonClick(row.original.id);
|
|
||||||
setLoadingStates((prev) => ({ ...prev, [row.original.id]: false }));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!isRowLoading && <Plus className="-ml-1 mr-2 h-4 w-4" />}
|
|
||||||
Use Template
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<DataTableActionDropdown
|
<DataTableActionDropdown
|
||||||
row={row.original}
|
row={row.original}
|
||||||
|
|||||||
242
apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx
Normal file
242
apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
import * as z from 'zod';
|
||||||
|
|
||||||
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { ROLE_ICONS } from '@documenso/ui/primitives/document-flow/add-signers';
|
||||||
|
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
const ZAddRecipientsForNewDocumentSchema = z.object({
|
||||||
|
recipients: z.array(
|
||||||
|
z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
name: z.string(),
|
||||||
|
role: z.nativeEnum(RecipientRole),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
||||||
|
|
||||||
|
export type UseTemplateDialogProps = {
|
||||||
|
templateId: number;
|
||||||
|
recipients: Recipient[];
|
||||||
|
documentRootPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UseTemplateDialog({
|
||||||
|
recipients,
|
||||||
|
documentRootPath,
|
||||||
|
templateId,
|
||||||
|
}: UseTemplateDialogProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<TAddRecipientsForNewDocumentSchema>({
|
||||||
|
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
|
||||||
|
defaultValues: {
|
||||||
|
recipients:
|
||||||
|
recipients.length > 0
|
||||||
|
? recipients.map((recipient) => ({
|
||||||
|
nativeId: recipient.id,
|
||||||
|
formId: String(recipient.id),
|
||||||
|
name: recipient.name,
|
||||||
|
email: recipient.email,
|
||||||
|
role: recipient.role,
|
||||||
|
}))
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: createDocumentFromTemplate, isLoading: isCreatingDocumentFromTemplate } =
|
||||||
|
trpc.template.createDocumentFromTemplate.useMutation();
|
||||||
|
|
||||||
|
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
||||||
|
try {
|
||||||
|
const { id } = await createDocumentFromTemplate({
|
||||||
|
templateId,
|
||||||
|
recipients: data.recipients,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Document created',
|
||||||
|
description: 'Your document has been created from the template successfully.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push(`${documentRootPath}/${id}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while creating document from template.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCreateDocumentFromTemplate = handleSubmit(onSubmit);
|
||||||
|
|
||||||
|
const { fields: formRecipients } = useFieldArray({
|
||||||
|
control,
|
||||||
|
name: 'recipients',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="cursor-pointer">
|
||||||
|
<Plus className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
Use Template
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Document Recipients</DialogTitle>
|
||||||
|
<DialogDescription>Add the recipients to create the template with.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
{formRecipients.map((recipient, index) => (
|
||||||
|
<div
|
||||||
|
key={recipient.id}
|
||||||
|
data-native-id={recipient.id}
|
||||||
|
className="flex flex-wrap items-end gap-x-4"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor={`recipient-${recipient.id}-email`}>
|
||||||
|
Email
|
||||||
|
<span className="text-destructive ml-1 inline-block font-medium">*</span>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`recipients.${index}.email`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
id={`recipient-${recipient.id}-email`}
|
||||||
|
type="email"
|
||||||
|
className="bg-background mt-2"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor={`recipient-${recipient.id}-name`}>Name</Label>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`recipients.${index}.name`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
id={`recipient-${recipient.id}-name`}
|
||||||
|
type="text"
|
||||||
|
className="bg-background mt-2"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-[60px]">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`recipients.${index}.role`}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<Select value={value} onValueChange={(x) => onChange(x)}>
|
||||||
|
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent className="" align="end">
|
||||||
|
<SelectItem value={RecipientRole.SIGNER}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
|
||||||
|
Signer
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
|
<SelectItem value={RecipientRole.CC}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
|
||||||
|
Receives copy
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
|
<SelectItem value={RecipientRole.APPROVER}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
|
||||||
|
Approver
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
|
<SelectItem value={RecipientRole.VIEWER}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
|
||||||
|
Viewer
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.email} />
|
||||||
|
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.name} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="justify-end">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
loading={isCreatingDocumentFromTemplate}
|
||||||
|
disabled={isCreatingDocumentFromTemplate}
|
||||||
|
onClick={onCreateDocumentFromTemplate}
|
||||||
|
>
|
||||||
|
Create Document
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -38,6 +38,7 @@ export const findTemplates = async ({
|
|||||||
include: {
|
include: {
|
||||||
templateDocumentData: true,
|
templateDocumentData: true,
|
||||||
Field: true,
|
Field: true,
|
||||||
|
Recipient: true,
|
||||||
},
|
},
|
||||||
skip: Math.max(page - 1, 0) * perPage,
|
skip: Math.max(page - 1, 0) * perPage,
|
||||||
orderBy: {
|
orderBy: {
|
||||||
|
|||||||
@ -52,6 +52,7 @@ export const templateRouter = router({
|
|||||||
return await createDocumentFromTemplate({
|
return await createDocumentFromTemplate({
|
||||||
templateId,
|
templateId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
|
recipients: input.recipients,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export const ZCreateTemplateMutationSchema = z.object({
|
export const ZCreateTemplateMutationSchema = z.object({
|
||||||
title: z.string().min(1).trim(),
|
title: z.string().min(1).trim(),
|
||||||
teamId: z.number().optional(),
|
teamId: z.number().optional(),
|
||||||
@ -8,6 +10,15 @@ export const ZCreateTemplateMutationSchema = z.object({
|
|||||||
|
|
||||||
export const ZCreateDocumentFromTemplateMutationSchema = z.object({
|
export const ZCreateDocumentFromTemplateMutationSchema = z.object({
|
||||||
templateId: z.number(),
|
templateId: z.number(),
|
||||||
|
recipients: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
name: z.string(),
|
||||||
|
role: z.nativeEnum(RecipientRole),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZDuplicateTemplateMutationSchema = z.object({
|
export const ZDuplicateTemplateMutationSchema = z.object({
|
||||||
|
|||||||
@ -32,7 +32,7 @@ import {
|
|||||||
import { ShowFieldItem } from './show-field-item';
|
import { ShowFieldItem } from './show-field-item';
|
||||||
import type { DocumentFlowStep } from './types';
|
import type { DocumentFlowStep } from './types';
|
||||||
|
|
||||||
const ROLE_ICONS: Record<RecipientRole, JSX.Element> = {
|
export const ROLE_ICONS: Record<RecipientRole, JSX.Element> = {
|
||||||
SIGNER: <PencilLine className="h-4 w-4" />,
|
SIGNER: <PencilLine className="h-4 w-4" />,
|
||||||
APPROVER: <BadgeCheck className="h-4 w-4" />,
|
APPROVER: <BadgeCheck className="h-4 w-4" />,
|
||||||
CC: <Copy className="h-4 w-4" />,
|
CC: <Copy className="h-4 w-4" />,
|
||||||
|
|||||||
Reference in New Issue
Block a user