mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Merge branch 'main' into feat/public-api
This commit is contained in:
@ -96,7 +96,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<Link href="/documents" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
Documents
|
||||
</Link>
|
||||
|
||||
@ -1,30 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import { useTransition } from 'react';
|
||||
|
||||
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 { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import type { Template } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { Recipient, Template } from '@documenso/prisma/client';
|
||||
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 { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
import { TemplateType } from '~/components/formatter/template-type';
|
||||
|
||||
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
||||
import { DataTableTitle } from './data-table-title';
|
||||
import { UseTemplateDialog } from './use-template-dialog';
|
||||
|
||||
type TemplateWithRecipient = Template & {
|
||||
Recipient: Recipient[];
|
||||
};
|
||||
|
||||
type TemplatesDataTableProps = {
|
||||
templates: Template[];
|
||||
templates: TemplateWithRecipient[];
|
||||
perPage: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
@ -47,14 +48,6 @@ export const TemplatesDataTable = ({
|
||||
|
||||
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) => {
|
||||
startTransition(() => {
|
||||
updateSearchParams({
|
||||
@ -64,29 +57,6 @@ export const TemplatesDataTable = ({
|
||||
});
|
||||
};
|
||||
|
||||
const onUseButtonClick = async (templateId: number) => {
|
||||
try {
|
||||
const { id } = await createDocumentFromTemplate({
|
||||
templateId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Document created',
|
||||
description: 'Your document has been created from the template successfully.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.push(`${documentRootPath}/${id}/edit`);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while creating document from template.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{remaining.documents === 0 && (
|
||||
@ -122,22 +92,13 @@ export const TemplatesDataTable = ({
|
||||
header: 'Actions',
|
||||
accessorKey: 'actions',
|
||||
cell: ({ row }) => {
|
||||
const isRowLoading = loadingStates[row.original.id];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Button
|
||||
disabled={isRowLoading || remaining.documents === 0}
|
||||
loading={isRowLoading}
|
||||
onClick={async () => {
|
||||
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>
|
||||
<UseTemplateDialog
|
||||
templateId={row.original.id}
|
||||
recipients={row.original.Recipient}
|
||||
documentRootPath={documentRootPath}
|
||||
/>
|
||||
|
||||
<DataTableActionDropdown
|
||||
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 { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons';
|
||||
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-lg">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -107,6 +107,8 @@ test('[TEMPLATES]: delete template', async ({ page }) => {
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await expect(page.getByText('Template deleted').first()).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
await unseedTeam(team.url);
|
||||
@ -187,15 +189,18 @@ test('[TEMPLATES]: use template', async ({ page }) => {
|
||||
|
||||
// Use personal template.
|
||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||
await page.getByRole('button', { name: 'Create Document' }).click();
|
||||
await page.waitForURL(/documents/);
|
||||
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
|
||||
await page.waitForURL('/documents');
|
||||
await expect(page.getByRole('main')).toContainText('Showing 1 result');
|
||||
|
||||
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Use team template.
|
||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||
await page.getByRole('button', { name: 'Create Document' }).click();
|
||||
await page.waitForURL(/\/t\/.+\/documents/);
|
||||
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
|
||||
await page.waitForURL(`/t/${team.url}/documents`);
|
||||
|
||||
@ -38,6 +38,7 @@ export const findTemplates = async ({
|
||||
include: {
|
||||
templateDocumentData: true,
|
||||
Field: true,
|
||||
Recipient: true,
|
||||
},
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
orderBy: {
|
||||
|
||||
@ -2,7 +2,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { prisma } from '..';
|
||||
import { DocumentDataType } from '../client';
|
||||
import { DocumentDataType, ReadStatus, RecipientRole, SendStatus, SigningStatus } from '../client';
|
||||
|
||||
const examplePdf = fs
|
||||
.readFileSync(path.join(__dirname, '../../../assets/example.pdf'))
|
||||
@ -28,9 +28,36 @@ export const seedTemplate = async (options: SeedTemplateOptions) => {
|
||||
return await prisma.template.create({
|
||||
data: {
|
||||
title,
|
||||
templateDocumentDataId: documentData.id,
|
||||
userId: userId,
|
||||
teamId,
|
||||
templateDocumentData: {
|
||||
connect: {
|
||||
id: documentData.id,
|
||||
},
|
||||
},
|
||||
User: {
|
||||
connect: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
Recipient: {
|
||||
create: {
|
||||
email: 'recipient.1@documenso.com',
|
||||
name: 'Recipient 1',
|
||||
token: Math.random().toString().slice(2, 7),
|
||||
sendStatus: SendStatus.NOT_SENT,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
readStatus: ReadStatus.NOT_OPENED,
|
||||
role: RecipientRole.SIGNER,
|
||||
},
|
||||
},
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
connect: {
|
||||
id: teamId,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -53,6 +53,7 @@ export const templateRouter = router({
|
||||
templateId,
|
||||
teamId,
|
||||
userId: ctx.user.id,
|
||||
recipients: input.recipients,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
|
||||
export const ZCreateTemplateMutationSchema = z.object({
|
||||
title: z.string().min(1).trim(),
|
||||
teamId: z.number().optional(),
|
||||
@ -9,6 +11,15 @@ export const ZCreateTemplateMutationSchema = z.object({
|
||||
export const ZCreateDocumentFromTemplateMutationSchema = z.object({
|
||||
templateId: z.number(),
|
||||
teamId: z.number().optional(),
|
||||
recipients: z
|
||||
.array(
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const ZDuplicateTemplateMutationSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user