mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
feat: templates (#537)
Adds the basically ability to create and use templates for repetitive document types
This commit is contained in:
@ -86,6 +86,7 @@ export const SinglePlayerClient = () => {
|
|||||||
data.fields.map((field, i) => ({
|
data.fields.map((field, i) => ({
|
||||||
id: i,
|
id: i,
|
||||||
documentId: -1,
|
documentId: -1,
|
||||||
|
templateId: null,
|
||||||
recipientId: -1,
|
recipientId: -1,
|
||||||
type: field.type,
|
type: field.type,
|
||||||
page: field.pageNumber,
|
page: field.pageNumber,
|
||||||
@ -148,6 +149,7 @@ export const SinglePlayerClient = () => {
|
|||||||
const placeholderRecipient: Recipient = {
|
const placeholderRecipient: Recipient = {
|
||||||
id: -1,
|
id: -1,
|
||||||
documentId: -1,
|
documentId: -1,
|
||||||
|
templateId: null,
|
||||||
email: '',
|
email: '',
|
||||||
name: '',
|
name: '',
|
||||||
token: '',
|
token: '',
|
||||||
|
|||||||
@ -6,8 +6,9 @@ import Link from 'next/link';
|
|||||||
|
|
||||||
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { DocumentStatus, Signature } from '@documenso/prisma/client';
|
import type { Signature } from '@documenso/prisma/client';
|
||||||
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
|
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||||
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
|
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
|
||||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
export const STEP = {
|
export const STEP = {
|
||||||
EMAIL: 'EMAIL',
|
EMAIL: 'EMAIL',
|
||||||
NAME: 'NAME',
|
NAME: 'NAME',
|
||||||
SIGN: "SIGN"
|
SIGN: 'SIGN',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -99,6 +99,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
};
|
};
|
||||||
|
|
||||||
const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
|
const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
|
|||||||
@ -41,6 +41,7 @@ export const DuplicateDocumentDialog = ({
|
|||||||
trpcReact.document.duplicateDocument.useMutation({
|
trpcReact.document.duplicateDocument.useMutation({
|
||||||
onSuccess: (newId) => {
|
onSuccess: (newId) => {
|
||||||
router.push(`/documents/${newId}`);
|
router.push(`/documents/${newId}`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Document Duplicated',
|
title: 'Document Duplicated',
|
||||||
description: 'Your document has been successfully duplicated.',
|
description: 'Your document has been successfully duplicated.',
|
||||||
|
|||||||
156
apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx
Normal file
156
apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
import {
|
||||||
|
DocumentFlowFormContainer,
|
||||||
|
DocumentFlowFormContainerHeader,
|
||||||
|
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||||
|
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
|
||||||
|
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 type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type EditTemplateFormProps = {
|
||||||
|
className?: string;
|
||||||
|
user: User;
|
||||||
|
template: Template;
|
||||||
|
recipients: Recipient[];
|
||||||
|
fields: Field[];
|
||||||
|
documentData: DocumentData;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditTemplateStep = 'signers' | 'fields';
|
||||||
|
const EditTemplateSteps: EditTemplateStep[] = ['signers', 'fields'];
|
||||||
|
|
||||||
|
export const EditTemplateForm = ({
|
||||||
|
className,
|
||||||
|
template,
|
||||||
|
recipients,
|
||||||
|
fields,
|
||||||
|
user: _user,
|
||||||
|
documentData,
|
||||||
|
}: EditTemplateFormProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [step, setStep] = useState<EditTemplateStep>('signers');
|
||||||
|
|
||||||
|
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
|
||||||
|
signers: {
|
||||||
|
title: 'Add Placeholders',
|
||||||
|
description: 'Add all relevant placeholders for each recipient.',
|
||||||
|
stepIndex: 1,
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
title: 'Add Fields',
|
||||||
|
description: 'Add all relevant fields for each recipient.',
|
||||||
|
stepIndex: 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentDocumentFlow = documentFlow[step];
|
||||||
|
|
||||||
|
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation();
|
||||||
|
const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation();
|
||||||
|
|
||||||
|
const onAddTemplatePlaceholderFormSubmit = async (
|
||||||
|
data: TAddTemplatePlacholderRecipientsFormSchema,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await addTemplateSigners({
|
||||||
|
templateId: template.id,
|
||||||
|
signers: data.signers,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
|
||||||
|
setStep('fields');
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while adding signers.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
|
||||||
|
try {
|
||||||
|
await addTemplateFields({
|
||||||
|
templateId: template.id,
|
||||||
|
fields: data.fields,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Template saved',
|
||||||
|
description: 'Your templates has been saved successfully.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push('/templates');
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while adding signers.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
||||||
|
<Card
|
||||||
|
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||||
|
gradient
|
||||||
|
>
|
||||||
|
<CardContent className="p-2">
|
||||||
|
<LazyPDFViewer key={documentData.id} documentData={documentData} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
|
<DocumentFlowFormContainer
|
||||||
|
className="lg:h-[calc(100vh-6rem)]"
|
||||||
|
onSubmit={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<DocumentFlowFormContainerHeader
|
||||||
|
title={currentDocumentFlow.title}
|
||||||
|
description={currentDocumentFlow.description}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stepper
|
||||||
|
currentStep={currentDocumentFlow.stepIndex}
|
||||||
|
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
|
||||||
|
>
|
||||||
|
<AddTemplatePlaceholderRecipientsFormPartial
|
||||||
|
key={recipients.length}
|
||||||
|
documentFlow={documentFlow.signers}
|
||||||
|
recipients={recipients}
|
||||||
|
fields={fields}
|
||||||
|
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddTemplateFieldsFormPartial
|
||||||
|
key={fields.length}
|
||||||
|
documentFlow={documentFlow.fields}
|
||||||
|
recipients={recipients}
|
||||||
|
fields={fields}
|
||||||
|
onSubmit={onAddFieldsFormSubmit}
|
||||||
|
/>
|
||||||
|
</Stepper>
|
||||||
|
</DocumentFlowFormContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
81
apps/web/src/app/(dashboard)/templates/[id]/page.tsx
Normal file
81
apps/web/src/app/(dashboard)/templates/[id]/page.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
|
||||||
|
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
|
||||||
|
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||||
|
|
||||||
|
import { TemplateType } from '~/components/formatter/template-type';
|
||||||
|
|
||||||
|
import { EditTemplateForm } from './edit-template';
|
||||||
|
|
||||||
|
export type TemplatePageProps = {
|
||||||
|
params: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function TemplatePage({ params }: TemplatePageProps) {
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
const templateId = Number(id);
|
||||||
|
|
||||||
|
if (!templateId || Number.isNaN(templateId)) {
|
||||||
|
redirect('/documents');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const template = await getTemplateById({
|
||||||
|
id: templateId,
|
||||||
|
userId: user.id,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!template || !template.templateDocumentData) {
|
||||||
|
redirect('/documents');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { templateDocumentData } = template;
|
||||||
|
|
||||||
|
const [templateRecipients, templateFields] = await Promise.all([
|
||||||
|
getRecipientsForTemplate({
|
||||||
|
templateId,
|
||||||
|
userId: user.id,
|
||||||
|
}),
|
||||||
|
getFieldsForTemplate({
|
||||||
|
templateId,
|
||||||
|
userId: user.id,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||||
|
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||||
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
|
Templates
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||||
|
{template.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
|
<TemplateType inheritColor type={template.type} className="text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditTemplateForm
|
||||||
|
className="mt-8"
|
||||||
|
template={template}
|
||||||
|
user={user}
|
||||||
|
recipients={templateRecipients}
|
||||||
|
fields={templateFields}
|
||||||
|
documentData={templateDocumentData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Copy, Edit, MoreHorizontal, Trash2 } from 'lucide-react';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
|
import type { Template } from '@documenso/prisma/client';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
|
||||||
|
import { DeleteTemplateDialog } from './delete-template-dialog';
|
||||||
|
import { DuplicateTemplateDialog } from './duplicate-template-dialog';
|
||||||
|
|
||||||
|
export type DataTableActionDropdownProps = {
|
||||||
|
row: Template;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = row.userId === session.user.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
|
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<DropdownMenuItem disabled={!isOwner} asChild>
|
||||||
|
<Link href={`/templates/${row.id}`}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{/* <DropdownMenuItem disabled={!isOwner} onClick={async () => onDuplicateButtonClick(row.id)}> */}
|
||||||
|
<DropdownMenuItem disabled={!isOwner} onClick={() => setDuplicateDialogOpen(true)}>
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
Duplicate
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem disabled={!isOwner} onClick={() => setDeleteDialogOpen(true)}>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
|
||||||
|
<DuplicateTemplateDialog
|
||||||
|
id={row.id}
|
||||||
|
open={isDuplicateDialogOpen}
|
||||||
|
onOpenChange={setDuplicateDialogOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteTemplateDialog
|
||||||
|
id={row.id}
|
||||||
|
open={isDeleteDialogOpen}
|
||||||
|
onOpenChange={setDeleteDialogOpen}
|
||||||
|
/>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
138
apps/web/src/app/(dashboard)/templates/data-table-templates.tsx
Normal file
138
apps/web/src/app/(dashboard)/templates/data-table-templates.tsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Loader, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
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 { 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';
|
||||||
|
|
||||||
|
type TemplatesDataTableProps = {
|
||||||
|
templates: Template[];
|
||||||
|
perPage: number;
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TemplatesDataTable = ({
|
||||||
|
templates,
|
||||||
|
perPage,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
}: TemplatesDataTableProps) => {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
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({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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(`/documents/${id}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while creating document from template.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'Created',
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Title',
|
||||||
|
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Type',
|
||||||
|
accessorKey: 'type',
|
||||||
|
cell: ({ row }) => <TemplateType type={row.original.type} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Actions',
|
||||||
|
accessorKey: 'actions',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const isRowLoading = loadingStates[row.original.id];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<Button
|
||||||
|
disabled={isRowLoading}
|
||||||
|
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>
|
||||||
|
<DataTableActionDropdown row={row.original} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={templates}
|
||||||
|
perPage={perPage}
|
||||||
|
currentPage={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
>
|
||||||
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
{isPending && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||||
|
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
26
apps/web/src/app/(dashboard)/templates/data-table-title.tsx
Normal file
26
apps/web/src/app/(dashboard)/templates/data-table-title.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
|
import { Template } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type DataTableTitleProps = {
|
||||||
|
row: Template;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DataTableTitle = ({ row }: DataTableTitleProps) => {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/templates/${row.id}`}
|
||||||
|
className="block max-w-[10rem] cursor-pointer truncate font-medium hover:underline md:max-w-[20rem]"
|
||||||
|
>
|
||||||
|
{row.title}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
type DeleteTemplateDialogProps = {
|
||||||
|
id: number;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (_open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateDialogProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { mutateAsync: deleteTemplate, isLoading } = trpcReact.template.deleteTemplate.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
router.refresh();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Template deleted',
|
||||||
|
description: 'Your template has been successfully deleted.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDeleteTemplate = async () => {
|
||||||
|
try {
|
||||||
|
await deleteTemplate({ id });
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'This template could not be deleted at this time. Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 7500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Do you want to delete this template?</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
Please note that this action is irreversible. Once confirmed, your template will be
|
||||||
|
permanently deleted.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="button" loading={isLoading} onClick={onDeleteTemplate} className="flex-1">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
type DuplicateTemplateDialogProps = {
|
||||||
|
id: number;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (_open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DuplicateTemplateDialog = ({
|
||||||
|
id,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: DuplicateTemplateDialogProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { mutateAsync: duplicateTemplate, isLoading } =
|
||||||
|
trpcReact.template.duplicateTemplate.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
router.refresh();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Template duplicated',
|
||||||
|
description: 'Your template has been duplicated successfully.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDuplicate = async () => {
|
||||||
|
try {
|
||||||
|
await duplicateTemplate({
|
||||||
|
templateId: id,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while duplicating template.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Do you want to duplicate this template?</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="pt-2">Your template will be duplicated.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="button" loading={isLoading} onClick={onDuplicate} className="flex-1">
|
||||||
|
Duplicate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
17
apps/web/src/app/(dashboard)/templates/empty-state.tsx
Normal file
17
apps/web/src/app/(dashboard)/templates/empty-state.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Bird } from 'lucide-react';
|
||||||
|
|
||||||
|
export const EmptyTemplateState = () => {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground/60 flex h-96 flex-col items-center justify-center gap-y-4">
|
||||||
|
<Bird className="h-12 w-12" strokeWidth={1.5} />
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-semibold">We're all empty</h3>
|
||||||
|
|
||||||
|
<p className="mt-2 max-w-[50ch]">
|
||||||
|
You have not yet created any templates. To create a template please upload one.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
228
apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx
Normal file
228
apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { FilePlus, X } from 'lucide-react';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import * as z from 'zod';
|
||||||
|
|
||||||
|
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||||
|
import { base64 } from '@documenso/lib/universal/base64';
|
||||||
|
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
const ZCreateTemplateFormSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TCreateTemplateFormSchema = z.infer<typeof ZCreateTemplateFormSchema>;
|
||||||
|
|
||||||
|
export const NewTemplateDialog = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const form = useForm<TCreateTemplateFormSchema>({
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
},
|
||||||
|
resolver: zodResolver(ZCreateTemplateFormSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: createTemplate, isLoading: isCreatingTemplate } =
|
||||||
|
trpc.template.createTemplate.useMutation();
|
||||||
|
|
||||||
|
const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false);
|
||||||
|
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
|
||||||
|
|
||||||
|
const onFileDrop = async (file: File) => {
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const base64String = base64.encode(new Uint8Array(arrayBuffer));
|
||||||
|
|
||||||
|
setUploadedFile({
|
||||||
|
file,
|
||||||
|
fileBase64: `data:application/pdf;base64,${base64String}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!form.getValues('name')) {
|
||||||
|
form.setValue('name', file.name);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'Please try again later.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (values: TCreateTemplateFormSchema) => {
|
||||||
|
if (!uploadedFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file: File = uploadedFile.file;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { type, data } = await putFile(file);
|
||||||
|
|
||||||
|
const { id: templateDocumentDataId } = await createDocumentData({
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { id } = await createTemplate({
|
||||||
|
title: values.name ? values.name : file.name,
|
||||||
|
templateDocumentDataId,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Template document uploaded',
|
||||||
|
description:
|
||||||
|
'Your document has been uploaded successfully. You will be redirected to the template page.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setShowNewTemplateDialog(false);
|
||||||
|
|
||||||
|
void router.push(`/templates/${id}`);
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'Please try again later.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
if (form.getValues('name') === uploadedFile?.file.name) {
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadedFile(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showNewTemplateDialog) {
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
}, [form, showNewTemplateDialog]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={showNewTemplateDialog} onOpenChange={setShowNewTemplateDialog}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="cursor-pointer" disabled={!session?.user.emailVerified}>
|
||||||
|
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
New Template
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="w-full max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="mb-4">New Template</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name your template</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input id="email" type="text" className="bg-background mt-1.5" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Leave this empty if you would like to use your document's name for the
|
||||||
|
template
|
||||||
|
</span>
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="template">Upload a Document</Label>
|
||||||
|
|
||||||
|
<div className="my-3">
|
||||||
|
{uploadedFile ? (
|
||||||
|
<Card gradient className="h-[40vh]">
|
||||||
|
<CardContent className="flex h-full flex-col items-center justify-center p-2">
|
||||||
|
<button
|
||||||
|
onClick={() => resetForm()}
|
||||||
|
title="Remove Template"
|
||||||
|
className="text-muted-foreground absolute right-2.5 top-2.5 rounded-sm opacity-60 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
<X className="h-6 w-6" />
|
||||||
|
<span className="sr-only">Remove Template</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm">
|
||||||
|
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
||||||
|
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
|
||||||
|
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="group-hover:text-foreground text-muted-foreground mt-4 font-medium">
|
||||||
|
Uploaded Document
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<span className="text-muted-foreground/80 mt-1 text-sm">
|
||||||
|
{uploadedFile.file.name}
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<DocumentDropzone
|
||||||
|
className="mt-1.5 h-[40vh]"
|
||||||
|
onDrop={onFileDrop}
|
||||||
|
type="template"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full justify-end">
|
||||||
|
<Button loading={isCreatingTemplate} type="submit">
|
||||||
|
Create Template
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
52
apps/web/src/app/(dashboard)/templates/page.tsx
Normal file
52
apps/web/src/app/(dashboard)/templates/page.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getTemplates } from '@documenso/lib/server-only/template/get-templates';
|
||||||
|
|
||||||
|
import { TemplatesDataTable } from './data-table-templates';
|
||||||
|
import { EmptyTemplateState } from './empty-state';
|
||||||
|
import { NewTemplateDialog } from './new-template-dialog';
|
||||||
|
|
||||||
|
type TemplatesPageProps = {
|
||||||
|
searchParams?: {
|
||||||
|
page?: number;
|
||||||
|
perPage?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
const page = Number(searchParams.page) || 1;
|
||||||
|
const perPage = Number(searchParams.perPage) || 10;
|
||||||
|
|
||||||
|
const { templates, totalPages } = await getTemplates({
|
||||||
|
userId: user.id,
|
||||||
|
page: page,
|
||||||
|
perPage: perPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<h1 className="mb-5 mt-2 truncate text-2xl font-semibold md:text-3xl">Templates</h1>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<NewTemplateDialog />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
{templates.length > 0 ? (
|
||||||
|
<TemplatesDataTable
|
||||||
|
templates={templates}
|
||||||
|
page={page}
|
||||||
|
perPage={perPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyTemplateState />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,6 +3,9 @@
|
|||||||
import type { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { Search } from 'lucide-react';
|
import { Search } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -10,10 +13,22 @@ import { Button } from '@documenso/ui/primitives/button';
|
|||||||
|
|
||||||
import { CommandMenu } from '../common/command-menu';
|
import { CommandMenu } from '../common/command-menu';
|
||||||
|
|
||||||
|
const navigationLinks = [
|
||||||
|
{
|
||||||
|
href: '/documents',
|
||||||
|
label: 'Documents',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/templates',
|
||||||
|
label: 'Templates',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||||
// const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
|
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
|
||||||
|
|
||||||
@ -26,9 +41,29 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn('ml-8 hidden flex-1 gap-x-6 md:flex md:justify-center', className)}
|
className={cn(
|
||||||
|
'ml-8 hidden flex-1 items-center gap-x-12 md:flex md:justify-between',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
<div className="flex items-baseline gap-x-6">
|
||||||
|
{navigationLinks.map(({ href, label }) => (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground dark:text-muted focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
|
||||||
|
{
|
||||||
|
'text-foreground dark:text-muted-foreground': pathname?.startsWith(href),
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<CommandMenu open={open} onOpenChange={setOpen} />
|
<CommandMenu open={open} onOpenChange={setOpen} />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@ -47,19 +82,6 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* We have no other subpaths rn */}
|
|
||||||
{/* <Link
|
|
||||||
href="/documents"
|
|
||||||
className={cn(
|
|
||||||
'text-muted-foreground focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
|
|
||||||
{
|
|
||||||
'text-foreground': pathname?.startsWith('/documents'),
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Documents
|
|
||||||
</Link> */}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -49,7 +49,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
|||||||
|
|
||||||
<DesktopNav />
|
<DesktopNav />
|
||||||
|
|
||||||
<div className="flex gap-x-4">
|
<div className="flex gap-x-4 md:ml-8">
|
||||||
<ProfileDropdown user={user} />
|
<ProfileDropdown user={user} />
|
||||||
|
|
||||||
{/* <Button variant="outline" size="sm" className="h-10 w-10 p-0.5 md:hidden">
|
{/* <Button variant="outline" size="sm" className="h-10 w-10 p-0.5 md:hidden">
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import Link from 'next/link';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
CreditCard,
|
CreditCard,
|
||||||
|
FileSpreadsheet,
|
||||||
Lock,
|
Lock,
|
||||||
LogOut,
|
LogOut,
|
||||||
User as LucideUser,
|
User as LucideUser,
|
||||||
@ -106,6 +107,13 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/templates" className="cursor-pointer">
|
||||||
|
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
||||||
|
Templates
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
|
|||||||
50
apps/web/src/components/formatter/template-type.tsx
Normal file
50
apps/web/src/components/formatter/template-type.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
import { Globe, Lock } from 'lucide-react';
|
||||||
|
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||||
|
|
||||||
|
import { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
|
type TemplateTypeIcon = {
|
||||||
|
label: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TemplateTypes = (typeof TemplateTypePrisma)[keyof typeof TemplateTypePrisma];
|
||||||
|
|
||||||
|
const TEMPLATE_TYPES: Record<TemplateTypes, TemplateTypeIcon> = {
|
||||||
|
PRIVATE: {
|
||||||
|
label: 'Private',
|
||||||
|
icon: Lock,
|
||||||
|
color: 'text-blue-600 dark:text-blue-300',
|
||||||
|
},
|
||||||
|
PUBLIC: {
|
||||||
|
label: 'Public',
|
||||||
|
icon: Globe,
|
||||||
|
color: 'text-green-500 dark:text-green-300',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TemplateTypeProps = HTMLAttributes<HTMLSpanElement> & {
|
||||||
|
type: TemplateTypes;
|
||||||
|
inheritColor?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TemplateType = ({ className, type, inheritColor, ...props }: TemplateTypeProps) => {
|
||||||
|
const { label, icon: Icon, color } = TEMPLATE_TYPES[type];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn('flex items-center', className)} {...props}>
|
||||||
|
{Icon && (
|
||||||
|
<Icon
|
||||||
|
className={cn('mr-2 inline-block h-4 w-4', {
|
||||||
|
[color]: !inheritColor,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -19,9 +19,11 @@ export const getRecipientsStats = async () => {
|
|||||||
|
|
||||||
results.forEach((result) => {
|
results.forEach((result) => {
|
||||||
const { readStatus, signingStatus, sendStatus, _count } = result;
|
const { readStatus, signingStatus, sendStatus, _count } = result;
|
||||||
|
|
||||||
stats[readStatus] += _count;
|
stats[readStatus] += _count;
|
||||||
stats[signingStatus] += _count;
|
stats[signingStatus] += _count;
|
||||||
stats[sendStatus] += _count;
|
stats[sendStatus] += _count;
|
||||||
|
|
||||||
stats.TOTAL_RECIPIENTS += _count;
|
stats.TOTAL_RECIPIENTS += _count;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen
|
|||||||
|
|
||||||
import type { FindResultSet } from '../../types/find-result-set';
|
import type { FindResultSet } from '../../types/find-result-set';
|
||||||
|
|
||||||
export interface FindDocumentsOptions {
|
export type FindDocumentsOptions = {
|
||||||
userId: number;
|
userId: number;
|
||||||
term?: string;
|
term?: string;
|
||||||
status?: ExtendedDocumentStatus;
|
status?: ExtendedDocumentStatus;
|
||||||
@ -19,7 +19,7 @@ export interface FindDocumentsOptions {
|
|||||||
direction: 'asc' | 'desc';
|
direction: 'asc' | 'desc';
|
||||||
};
|
};
|
||||||
period?: '' | '7d' | '14d' | '30d';
|
period?: '' | '7d' | '14d' | '30d';
|
||||||
}
|
};
|
||||||
|
|
||||||
export const findDocuments = async ({
|
export const findDocuments = async ({
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
22
packages/lib/server-only/field/get-fields-for-template.ts
Normal file
22
packages/lib/server-only/field/get-fields-for-template.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export interface GetFieldsForTemplateOptions {
|
||||||
|
templateId: number;
|
||||||
|
userId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getFieldsForTemplate = async ({ templateId, userId }: GetFieldsForTemplateOptions) => {
|
||||||
|
const fields = await prisma.field.findMany({
|
||||||
|
where: {
|
||||||
|
templateId,
|
||||||
|
Template: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
id: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
};
|
||||||
@ -27,6 +27,10 @@ export const removeSignedFieldWithToken = async ({
|
|||||||
|
|
||||||
const { Document: document, Recipient: recipient } = field;
|
const { Document: document, Recipient: recipient } = field;
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new Error(`Document not found for field ${field.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
throw new Error(`Document ${document.id} has already been completed`);
|
throw new Error(`Document ${document.id} has already been completed`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { FieldType, SendStatus, SigningStatus } from '@documenso/prisma/client';
|
import type { FieldType } from '@documenso/prisma/client';
|
||||||
|
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export interface SetFieldsForDocumentOptions {
|
export interface SetFieldsForDocumentOptions {
|
||||||
userId: number;
|
userId: number;
|
||||||
|
|||||||
118
packages/lib/server-only/field/set-fields-for-template.ts
Normal file
118
packages/lib/server-only/field/set-fields-for-template.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import type { FieldType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type Field = {
|
||||||
|
id?: number | null;
|
||||||
|
type: FieldType;
|
||||||
|
signerEmail: string;
|
||||||
|
signerId?: number;
|
||||||
|
pageNumber: number;
|
||||||
|
pageX: number;
|
||||||
|
pageY: number;
|
||||||
|
pageWidth: number;
|
||||||
|
pageHeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SetFieldsForTemplateOptions = {
|
||||||
|
userId: number;
|
||||||
|
templateId: number;
|
||||||
|
fields: Field[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setFieldsForTemplate = async ({
|
||||||
|
userId,
|
||||||
|
templateId,
|
||||||
|
fields,
|
||||||
|
}: SetFieldsForTemplateOptions) => {
|
||||||
|
const template = await prisma.template.findFirst({
|
||||||
|
where: {
|
||||||
|
id: templateId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
throw new Error('Template not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingFields = await prisma.field.findMany({
|
||||||
|
where: {
|
||||||
|
templateId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Recipient: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removedFields = existingFields.filter(
|
||||||
|
(existingField) =>
|
||||||
|
!fields.find(
|
||||||
|
(field) =>
|
||||||
|
field.id === existingField.id || field.signerEmail === existingField.Recipient?.email,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const linkedFields = fields.map((field) => {
|
||||||
|
const existing = existingFields.find((existingField) => existingField.id === field.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
_persisted: existing,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const persistedFields = await prisma.$transaction(
|
||||||
|
// Disabling as wrapping promises here causes type issues
|
||||||
|
// eslint-disable-next-line @typescript-eslint/promise-function-async
|
||||||
|
linkedFields.map((field) =>
|
||||||
|
prisma.field.upsert({
|
||||||
|
where: {
|
||||||
|
id: field._persisted?.id ?? -1,
|
||||||
|
templateId,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
page: field.pageNumber,
|
||||||
|
positionX: field.pageX,
|
||||||
|
positionY: field.pageY,
|
||||||
|
width: field.pageWidth,
|
||||||
|
height: field.pageHeight,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
type: field.type,
|
||||||
|
page: field.pageNumber,
|
||||||
|
positionX: field.pageX,
|
||||||
|
positionY: field.pageY,
|
||||||
|
width: field.pageWidth,
|
||||||
|
height: field.pageHeight,
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
Template: {
|
||||||
|
connect: {
|
||||||
|
id: templateId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Recipient: {
|
||||||
|
connect: {
|
||||||
|
templateId_email: {
|
||||||
|
templateId,
|
||||||
|
email: field.signerEmail.toLowerCase(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (removedFields.length > 0) {
|
||||||
|
await prisma.field.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: removedFields.map((field) => field.id),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return persistedFields;
|
||||||
|
};
|
||||||
@ -33,6 +33,10 @@ export const signFieldWithToken = async ({
|
|||||||
|
|
||||||
const { Document: document, Recipient: recipient } = field;
|
const { Document: document, Recipient: recipient } = field;
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new Error(`Document not found for field ${field.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
throw new Error(`Document ${document.id} has already been completed`);
|
throw new Error(`Document ${document.id} has already been completed`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,25 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export interface GetRecipientsForTemplateOptions {
|
||||||
|
templateId: number;
|
||||||
|
userId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getRecipientsForTemplate = async ({
|
||||||
|
templateId,
|
||||||
|
userId,
|
||||||
|
}: GetRecipientsForTemplateOptions) => {
|
||||||
|
const recipients = await prisma.recipient.findMany({
|
||||||
|
where: {
|
||||||
|
templateId,
|
||||||
|
Template: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
id: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return recipients;
|
||||||
|
};
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { nanoid } from '../../universal/id';
|
||||||
|
|
||||||
|
export type SetRecipientsForTemplateOptions = {
|
||||||
|
userId: number;
|
||||||
|
templateId: number;
|
||||||
|
recipients: {
|
||||||
|
id?: number;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setRecipientsForTemplate = async ({
|
||||||
|
userId,
|
||||||
|
templateId,
|
||||||
|
recipients,
|
||||||
|
}: SetRecipientsForTemplateOptions) => {
|
||||||
|
const template = await prisma.template.findFirst({
|
||||||
|
where: {
|
||||||
|
id: templateId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
throw new Error('Template not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedRecipients = recipients.map((recipient) => ({
|
||||||
|
...recipient,
|
||||||
|
email: recipient.email.toLowerCase(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const existingRecipients = await prisma.recipient.findMany({
|
||||||
|
where: {
|
||||||
|
templateId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removedRecipients = existingRecipients.filter(
|
||||||
|
(existingRecipient) =>
|
||||||
|
!normalizedRecipients.find(
|
||||||
|
(recipient) =>
|
||||||
|
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const linkedRecipients = normalizedRecipients.map((recipient) => {
|
||||||
|
const existing = existingRecipients.find(
|
||||||
|
(existingRecipient) =>
|
||||||
|
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...recipient,
|
||||||
|
_persisted: existing,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const persistedRecipients = await prisma.$transaction(
|
||||||
|
// Disabling as wrapping promises here causes type issues
|
||||||
|
// eslint-disable-next-line @typescript-eslint/promise-function-async
|
||||||
|
linkedRecipients.map((recipient) =>
|
||||||
|
prisma.recipient.upsert({
|
||||||
|
where: {
|
||||||
|
id: recipient._persisted?.id ?? -1,
|
||||||
|
templateId,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
name: recipient.name,
|
||||||
|
email: recipient.email,
|
||||||
|
templateId,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
name: recipient.name,
|
||||||
|
email: recipient.email,
|
||||||
|
token: nanoid(),
|
||||||
|
templateId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (removedRecipients.length > 0) {
|
||||||
|
await prisma.recipient.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: removedRecipients.map((recipient) => recipient.id),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return persistedRecipients;
|
||||||
|
};
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import type { TCreateDocumentFromTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
|
||||||
|
|
||||||
|
export type CreateDocumentFromTemplateOptions = TCreateDocumentFromTemplateMutationSchema & {
|
||||||
|
userId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createDocumentFromTemplate = async ({
|
||||||
|
templateId,
|
||||||
|
userId,
|
||||||
|
}: CreateDocumentFromTemplateOptions) => {
|
||||||
|
const template = await prisma.template.findUnique({
|
||||||
|
where: { id: templateId, userId },
|
||||||
|
include: {
|
||||||
|
Recipient: true,
|
||||||
|
Field: true,
|
||||||
|
templateDocumentData: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
throw new Error('Template not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentData = await prisma.documentData.create({
|
||||||
|
data: {
|
||||||
|
type: template.templateDocumentData.type,
|
||||||
|
data: template.templateDocumentData.data,
|
||||||
|
initialData: template.templateDocumentData.initialData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const document = await prisma.document.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
title: template.title,
|
||||||
|
documentDataId: documentData.id,
|
||||||
|
Recipient: {
|
||||||
|
create: template.Recipient.map((recipient) => ({
|
||||||
|
email: recipient.email,
|
||||||
|
name: recipient.name,
|
||||||
|
token: nanoid(),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
include: {
|
||||||
|
Recipient: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.field.createMany({
|
||||||
|
data: template.Field.map((field) => {
|
||||||
|
const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId);
|
||||||
|
|
||||||
|
const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: field.type,
|
||||||
|
page: field.page,
|
||||||
|
positionX: field.positionX,
|
||||||
|
positionY: field.positionY,
|
||||||
|
width: field.width,
|
||||||
|
height: field.height,
|
||||||
|
customText: field.customText,
|
||||||
|
inserted: field.inserted,
|
||||||
|
documentId: document.id,
|
||||||
|
recipientId: documentRecipient?.id || null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return document;
|
||||||
|
};
|
||||||
20
packages/lib/server-only/template/create-template.ts
Normal file
20
packages/lib/server-only/template/create-template.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { TCreateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
|
||||||
|
|
||||||
|
export type CreateTemplateOptions = TCreateTemplateMutationSchema & {
|
||||||
|
userId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTemplate = async ({
|
||||||
|
title,
|
||||||
|
userId,
|
||||||
|
templateDocumentDataId,
|
||||||
|
}: CreateTemplateOptions) => {
|
||||||
|
return await prisma.template.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
userId,
|
||||||
|
templateDocumentDataId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
12
packages/lib/server-only/template/delete-template.ts
Normal file
12
packages/lib/server-only/template/delete-template.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type DeleteTemplateOptions = {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteTemplate = async ({ id, userId }: DeleteTemplateOptions) => {
|
||||||
|
return await prisma.template.delete({ where: { id, userId } });
|
||||||
|
};
|
||||||
74
packages/lib/server-only/template/duplicate-template.ts
Normal file
74
packages/lib/server-only/template/duplicate-template.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import type { TDuplicateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
|
||||||
|
|
||||||
|
export type DuplicateTemplateOptions = TDuplicateTemplateMutationSchema & {
|
||||||
|
userId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const duplicateTemplate = async ({ templateId, userId }: DuplicateTemplateOptions) => {
|
||||||
|
const template = await prisma.template.findUnique({
|
||||||
|
where: { id: templateId, userId },
|
||||||
|
include: {
|
||||||
|
Recipient: true,
|
||||||
|
Field: true,
|
||||||
|
templateDocumentData: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
throw new Error('Template not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentData = await prisma.documentData.create({
|
||||||
|
data: {
|
||||||
|
type: template.templateDocumentData.type,
|
||||||
|
data: template.templateDocumentData.data,
|
||||||
|
initialData: template.templateDocumentData.initialData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const duplicatedTemplate = await prisma.template.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
title: template.title + ' (copy)',
|
||||||
|
templateDocumentDataId: documentData.id,
|
||||||
|
Recipient: {
|
||||||
|
create: template.Recipient.map((recipient) => ({
|
||||||
|
email: recipient.email,
|
||||||
|
name: recipient.name,
|
||||||
|
token: nanoid(),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
include: {
|
||||||
|
Recipient: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.field.createMany({
|
||||||
|
data: template.Field.map((field) => {
|
||||||
|
const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId);
|
||||||
|
|
||||||
|
const duplicatedTemplateRecipient = duplicatedTemplate.Recipient.find(
|
||||||
|
(doc) => doc.email === recipient?.email,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: field.type,
|
||||||
|
page: field.page,
|
||||||
|
positionX: field.positionX,
|
||||||
|
positionY: field.positionY,
|
||||||
|
width: field.width,
|
||||||
|
height: field.height,
|
||||||
|
customText: field.customText,
|
||||||
|
inserted: field.inserted,
|
||||||
|
templateId: duplicatedTemplate.id,
|
||||||
|
recipientId: duplicatedTemplateRecipient?.id || null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return duplicatedTemplate;
|
||||||
|
};
|
||||||
18
packages/lib/server-only/template/get-template-by-id.ts
Normal file
18
packages/lib/server-only/template/get-template-by-id.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export interface GetTemplateByIdOptions {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTemplateById = async ({ id, userId }: GetTemplateByIdOptions) => {
|
||||||
|
return await prisma.template.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
templateDocumentData: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
35
packages/lib/server-only/template/get-templates.ts
Normal file
35
packages/lib/server-only/template/get-templates.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type GetTemplatesOptions = {
|
||||||
|
userId: number;
|
||||||
|
page: number;
|
||||||
|
perPage: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTemplates = async ({ userId, page = 1, perPage = 10 }: GetTemplatesOptions) => {
|
||||||
|
const [templates, count] = await Promise.all([
|
||||||
|
prisma.template.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
templateDocumentData: true,
|
||||||
|
Field: true,
|
||||||
|
},
|
||||||
|
skip: Math.max(page - 1, 0) * perPage,
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.template.count({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
templates,
|
||||||
|
totalPages: Math.ceil(count / perPage),
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[templateId,email]` on the table `Recipient` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "TemplateType" AS ENUM ('PUBLIC', 'PRIVATE');
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Field" DROP CONSTRAINT "Field_recipientId_fkey";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Field" ADD COLUMN "templateId" INTEGER,
|
||||||
|
ALTER COLUMN "documentId" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
-- Add CHECK constraint to ensure that only one of the two columns is set
|
||||||
|
ALTER TABLE "Field" ADD CONSTRAINT "Field_templateId_documentId_check" CHECK (
|
||||||
|
("templateId" IS NULL AND "documentId" IS NOT NULL) OR
|
||||||
|
("templateId" IS NOT NULL AND "documentId" IS NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Recipient" ADD COLUMN "templateId" INTEGER,
|
||||||
|
ALTER COLUMN "documentId" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
-- Add CHECK constraint to ensure that only one of the two columns is set
|
||||||
|
ALTER TABLE "Recipient" ADD CONSTRAINT "Recipient_templateId_documentId_check" CHECK (
|
||||||
|
("templateId" IS NULL AND "documentId" IS NOT NULL) OR
|
||||||
|
("templateId" IS NOT NULL AND "documentId" IS NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Template" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"type" "TemplateType" NOT NULL DEFAULT 'PRIVATE',
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"templateDocumentDataId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Template_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Template_templateDocumentDataId_key" ON "Template"("templateDocumentDataId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Field_templateId_idx" ON "Field"("templateId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Recipient_templateId_idx" ON "Recipient"("templateId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Recipient_templateId_email_key" ON "Recipient"("templateId", "email");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Recipient" ADD CONSTRAINT "Recipient_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Field" ADD CONSTRAINT "Field_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Field" ADD CONSTRAINT "Field_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Template" ADD CONSTRAINT "Template_templateDocumentDataId_fkey" FOREIGN KEY ("templateDocumentDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Template" ADD CONSTRAINT "Template_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -41,6 +41,7 @@ model User {
|
|||||||
twoFactorEnabled Boolean @default(false)
|
twoFactorEnabled Boolean @default(false)
|
||||||
twoFactorBackupCodes String?
|
twoFactorBackupCodes String?
|
||||||
VerificationToken VerificationToken[]
|
VerificationToken VerificationToken[]
|
||||||
|
Template Template[]
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
}
|
}
|
||||||
@ -153,6 +154,7 @@ model DocumentData {
|
|||||||
data String
|
data String
|
||||||
initialData String
|
initialData String
|
||||||
Document Document?
|
Document Document?
|
||||||
|
Template Template?
|
||||||
}
|
}
|
||||||
|
|
||||||
model DocumentMeta {
|
model DocumentMeta {
|
||||||
@ -179,22 +181,26 @@ enum SigningStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Recipient {
|
model Recipient {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
documentId Int
|
documentId Int?
|
||||||
email String @db.VarChar(255)
|
templateId Int?
|
||||||
name String @default("") @db.VarChar(255)
|
email String @db.VarChar(255)
|
||||||
|
name String @default("") @db.VarChar(255)
|
||||||
token String
|
token String
|
||||||
expired DateTime?
|
expired DateTime?
|
||||||
signedAt DateTime?
|
signedAt DateTime?
|
||||||
readStatus ReadStatus @default(NOT_OPENED)
|
readStatus ReadStatus @default(NOT_OPENED)
|
||||||
signingStatus SigningStatus @default(NOT_SIGNED)
|
signingStatus SigningStatus @default(NOT_SIGNED)
|
||||||
sendStatus SendStatus @default(NOT_SENT)
|
sendStatus SendStatus @default(NOT_SENT)
|
||||||
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||||
|
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
||||||
Field Field[]
|
Field Field[]
|
||||||
Signature Signature[]
|
Signature Signature[]
|
||||||
|
|
||||||
@@unique([documentId, email])
|
@@unique([documentId, email])
|
||||||
|
@@unique([templateId, email])
|
||||||
@@index([documentId])
|
@@index([documentId])
|
||||||
|
@@index([templateId])
|
||||||
@@index([token])
|
@@index([token])
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -209,7 +215,8 @@ enum FieldType {
|
|||||||
|
|
||||||
model Field {
|
model Field {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
documentId Int
|
documentId Int?
|
||||||
|
templateId Int?
|
||||||
recipientId Int?
|
recipientId Int?
|
||||||
type FieldType
|
type FieldType
|
||||||
page Int
|
page Int
|
||||||
@ -219,11 +226,13 @@ model Field {
|
|||||||
height Decimal @default(-1)
|
height Decimal @default(-1)
|
||||||
customText String
|
customText String
|
||||||
inserted Boolean
|
inserted Boolean
|
||||||
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||||
Recipient Recipient? @relation(fields: [recipientId], references: [id], onDelete: Cascade)
|
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
||||||
|
Recipient Recipient? @relation(fields: [recipientId], references: [id])
|
||||||
Signature Signature?
|
Signature Signature?
|
||||||
|
|
||||||
@@index([documentId])
|
@@index([documentId])
|
||||||
|
@@index([templateId])
|
||||||
@@index([recipientId])
|
@@index([recipientId])
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -253,3 +262,25 @@ model DocumentShareLink {
|
|||||||
|
|
||||||
@@unique([documentId, email])
|
@@unique([documentId, email])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum TemplateType {
|
||||||
|
PUBLIC
|
||||||
|
PRIVATE
|
||||||
|
}
|
||||||
|
|
||||||
|
model Template {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
type TemplateType @default(PRIVATE)
|
||||||
|
title String
|
||||||
|
userId Int
|
||||||
|
templateDocumentDataId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
|
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
|
||||||
|
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
Recipient Recipient[]
|
||||||
|
Field Field[]
|
||||||
|
|
||||||
|
@@unique([templateDocumentDataId])
|
||||||
|
}
|
||||||
|
|||||||
@ -2,11 +2,13 @@ import { TRPCError } from '@trpc/server';
|
|||||||
|
|
||||||
import { removeSignedFieldWithToken } from '@documenso/lib/server-only/field/remove-signed-field-with-token';
|
import { removeSignedFieldWithToken } from '@documenso/lib/server-only/field/remove-signed-field-with-token';
|
||||||
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
||||||
|
import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template';
|
||||||
import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token';
|
import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token';
|
||||||
|
|
||||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||||
import {
|
import {
|
||||||
ZAddFieldsMutationSchema,
|
ZAddFieldsMutationSchema,
|
||||||
|
ZAddTemplateFieldsMutationSchema,
|
||||||
ZRemovedSignedFieldWithTokenMutationSchema,
|
ZRemovedSignedFieldWithTokenMutationSchema,
|
||||||
ZSignFieldWithTokenMutationSchema,
|
ZSignFieldWithTokenMutationSchema,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
@ -42,6 +44,27 @@ export const fieldRouter = router({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
addTemplateFields: authenticatedProcedure
|
||||||
|
.input(ZAddTemplateFieldsMutationSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const { templateId, fields } = input;
|
||||||
|
|
||||||
|
await setFieldsForTemplate({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
templateId,
|
||||||
|
fields: fields.map((field) => ({
|
||||||
|
id: field.nativeId,
|
||||||
|
signerEmail: field.signerEmail,
|
||||||
|
type: field.type,
|
||||||
|
pageNumber: field.pageNumber,
|
||||||
|
pageX: field.pageX,
|
||||||
|
pageY: field.pageY,
|
||||||
|
pageWidth: field.pageWidth,
|
||||||
|
pageHeight: field.pageHeight,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
signFieldWithToken: procedure
|
signFieldWithToken: procedure
|
||||||
.input(ZSignFieldWithTokenMutationSchema)
|
.input(ZSignFieldWithTokenMutationSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
|
|||||||
@ -21,6 +21,25 @@ export const ZAddFieldsMutationSchema = z.object({
|
|||||||
|
|
||||||
export type TAddFieldsMutationSchema = z.infer<typeof ZAddFieldsMutationSchema>;
|
export type TAddFieldsMutationSchema = z.infer<typeof ZAddFieldsMutationSchema>;
|
||||||
|
|
||||||
|
export const ZAddTemplateFieldsMutationSchema = z.object({
|
||||||
|
templateId: z.number(),
|
||||||
|
fields: z.array(
|
||||||
|
z.object({
|
||||||
|
formId: z.string().min(1),
|
||||||
|
nativeId: z.number().optional(),
|
||||||
|
type: z.nativeEnum(FieldType),
|
||||||
|
signerEmail: z.string().min(1),
|
||||||
|
pageNumber: z.number().min(1),
|
||||||
|
pageX: z.number().min(0),
|
||||||
|
pageY: z.number().min(0),
|
||||||
|
pageWidth: z.number().min(0),
|
||||||
|
pageHeight: z.number().min(0),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAddTemplateFieldsMutationSchema = z.infer<typeof ZAddTemplateFieldsMutationSchema>;
|
||||||
|
|
||||||
export const ZSignFieldWithTokenMutationSchema = z.object({
|
export const ZSignFieldWithTokenMutationSchema = z.object({
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
fieldId: z.number(),
|
fieldId: z.number(),
|
||||||
|
|||||||
@ -2,9 +2,14 @@ import { TRPCError } from '@trpc/server';
|
|||||||
|
|
||||||
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
||||||
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||||
|
import { setRecipientsForTemplate } from '@documenso/lib/server-only/recipient/set-recipients-for-template';
|
||||||
|
|
||||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||||
import { ZAddSignersMutationSchema, ZCompleteDocumentWithTokenMutationSchema } from './schema';
|
import {
|
||||||
|
ZAddSignersMutationSchema,
|
||||||
|
ZAddTemplateSignersMutationSchema,
|
||||||
|
ZCompleteDocumentWithTokenMutationSchema,
|
||||||
|
} from './schema';
|
||||||
|
|
||||||
export const recipientRouter = router({
|
export const recipientRouter = router({
|
||||||
addSigners: authenticatedProcedure
|
addSigners: authenticatedProcedure
|
||||||
@ -32,6 +37,31 @@ export const recipientRouter = router({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
addTemplateSigners: authenticatedProcedure
|
||||||
|
.input(ZAddTemplateSignersMutationSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const { templateId, signers } = input;
|
||||||
|
|
||||||
|
return await setRecipientsForTemplate({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
templateId,
|
||||||
|
recipients: signers.map((signer) => ({
|
||||||
|
id: signer.nativeId,
|
||||||
|
email: signer.email,
|
||||||
|
name: signer.name,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We were unable to sign this field. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
completeDocumentWithToken: procedure
|
completeDocumentWithToken: procedure
|
||||||
.input(ZCompleteDocumentWithTokenMutationSchema)
|
.input(ZCompleteDocumentWithTokenMutationSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
|
|||||||
@ -23,6 +23,29 @@ export const ZAddSignersMutationSchema = z
|
|||||||
|
|
||||||
export type TAddSignersMutationSchema = z.infer<typeof ZAddSignersMutationSchema>;
|
export type TAddSignersMutationSchema = z.infer<typeof ZAddSignersMutationSchema>;
|
||||||
|
|
||||||
|
export const ZAddTemplateSignersMutationSchema = z
|
||||||
|
.object({
|
||||||
|
templateId: z.number(),
|
||||||
|
signers: z.array(
|
||||||
|
z.object({
|
||||||
|
nativeId: z.number().optional(),
|
||||||
|
email: z.string().email().min(1),
|
||||||
|
name: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(schema) => {
|
||||||
|
const emails = schema.signers.map((signer) => signer.email.toLowerCase());
|
||||||
|
|
||||||
|
return new Set(emails).size === emails.length;
|
||||||
|
},
|
||||||
|
// Dirty hack to handle errors when .root is populated for an array type
|
||||||
|
{ message: 'Signers must have unique emails', path: ['signers__root'] },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TAddTemplateSignersMutationSchema = z.infer<typeof ZAddTemplateSignersMutationSchema>;
|
||||||
|
|
||||||
export const ZCompleteDocumentWithTokenMutationSchema = z.object({
|
export const ZCompleteDocumentWithTokenMutationSchema = z.object({
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
documentId: z.number(),
|
documentId: z.number(),
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { profileRouter } from './profile-router/router';
|
|||||||
import { recipientRouter } from './recipient-router/router';
|
import { recipientRouter } from './recipient-router/router';
|
||||||
import { shareLinkRouter } from './share-link-router/router';
|
import { shareLinkRouter } from './share-link-router/router';
|
||||||
import { singleplayerRouter } from './singleplayer-router/router';
|
import { singleplayerRouter } from './singleplayer-router/router';
|
||||||
|
import { templateRouter } from './template-router/router';
|
||||||
import { router } from './trpc';
|
import { router } from './trpc';
|
||||||
import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router';
|
import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router';
|
||||||
|
|
||||||
@ -19,6 +20,7 @@ export const appRouter = router({
|
|||||||
shareLink: shareLinkRouter,
|
shareLink: shareLinkRouter,
|
||||||
singleplayer: singleplayerRouter,
|
singleplayer: singleplayerRouter,
|
||||||
twoFactorAuthentication: twoFactorAuthenticationRouter,
|
twoFactorAuthentication: twoFactorAuthenticationRouter,
|
||||||
|
template: templateRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
@ -63,6 +63,7 @@ export const singleplayerRouter = router({
|
|||||||
// Dummy data.
|
// Dummy data.
|
||||||
id: -1,
|
id: -1,
|
||||||
documentId: -1,
|
documentId: -1,
|
||||||
|
templateId: null,
|
||||||
recipientId: -1,
|
recipientId: -1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
94
packages/trpc/server/template-router/router.ts
Normal file
94
packages/trpc/server/template-router/router.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
|
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
|
||||||
|
import { createTemplate } from '@documenso/lib/server-only/template/create-template';
|
||||||
|
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
|
||||||
|
import { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-template';
|
||||||
|
|
||||||
|
import { authenticatedProcedure, router } from '../trpc';
|
||||||
|
import {
|
||||||
|
ZCreateDocumentFromTemplateMutationSchema,
|
||||||
|
ZCreateTemplateMutationSchema,
|
||||||
|
ZDeleteTemplateMutationSchema,
|
||||||
|
ZDuplicateTemplateMutationSchema,
|
||||||
|
} from './schema';
|
||||||
|
|
||||||
|
export const templateRouter = router({
|
||||||
|
createTemplate: authenticatedProcedure
|
||||||
|
.input(ZCreateTemplateMutationSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const { title, templateDocumentDataId } = input;
|
||||||
|
|
||||||
|
return await createTemplate({
|
||||||
|
title,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
templateDocumentDataId,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We were unable to create this template. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
createDocumentFromTemplate: authenticatedProcedure
|
||||||
|
.input(ZCreateDocumentFromTemplateMutationSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const { templateId } = input;
|
||||||
|
|
||||||
|
return await createDocumentFromTemplate({
|
||||||
|
templateId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We were unable to create this document. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
duplicateTemplate: authenticatedProcedure
|
||||||
|
.input(ZDuplicateTemplateMutationSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const { templateId } = input;
|
||||||
|
|
||||||
|
return await duplicateTemplate({
|
||||||
|
templateId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We were unable to duplicate the template. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteTemplate: authenticatedProcedure
|
||||||
|
.input(ZDeleteTemplateMutationSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const { id } = input;
|
||||||
|
|
||||||
|
const userId = ctx.user.id;
|
||||||
|
|
||||||
|
return await deleteTemplate({ id, userId });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We were unable to delete this template. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
26
packages/trpc/server/template-router/schema.ts
Normal file
26
packages/trpc/server/template-router/schema.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZCreateTemplateMutationSchema = z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
templateDocumentDataId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZCreateDocumentFromTemplateMutationSchema = z.object({
|
||||||
|
templateId: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZDuplicateTemplateMutationSchema = z.object({
|
||||||
|
templateId: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZDeleteTemplateMutationSchema = z.object({
|
||||||
|
id: z.number().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TCreateTemplateMutationSchema = z.infer<typeof ZCreateTemplateMutationSchema>;
|
||||||
|
export type TCreateDocumentFromTemplateMutationSchema = z.infer<
|
||||||
|
typeof ZCreateDocumentFromTemplateMutationSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type TDuplicateTemplateMutationSchema = z.infer<typeof ZDuplicateTemplateMutationSchema>;
|
||||||
|
export type TDeleteTemplateMutationSchema = z.infer<typeof ZDeleteTemplateMutationSchema>;
|
||||||
@ -20,7 +20,7 @@ const DialogPortal = ({
|
|||||||
}: DialogPrimitive.DialogPortalProps & { position?: 'start' | 'end' | 'center' }) => (
|
}: DialogPrimitive.DialogPortalProps & { position?: 'start' | 'end' | 'center' }) => (
|
||||||
<DialogPrimitive.Portal {...props}>
|
<DialogPrimitive.Portal {...props}>
|
||||||
<div
|
<div
|
||||||
className={cn('fixed inset-0 z-50 flex justify-center sm:items-center', {
|
className={cn('fixed inset-0 z-[9999] flex justify-center sm:items-center', {
|
||||||
'items-start': position === 'start',
|
'items-start': position === 'start',
|
||||||
'items-end': position === 'end',
|
'items-end': position === 'end',
|
||||||
'items-center': position === 'center',
|
'items-center': position === 'center',
|
||||||
|
|||||||
@ -75,10 +75,20 @@ const DocumentDropzoneCardCenterVariants: Variants = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DocumentDescription = {
|
||||||
|
document: {
|
||||||
|
headline: 'Add a document',
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
headline: 'Upload Template Document',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export type DocumentDropzoneProps = {
|
export type DocumentDropzoneProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onDrop?: (_file: File) => void | Promise<void>;
|
onDrop?: (_file: File) => void | Promise<void>;
|
||||||
|
type?: 'document' | 'template';
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -86,6 +96,7 @@ export const DocumentDropzone = ({
|
|||||||
className,
|
className,
|
||||||
onDrop,
|
onDrop,
|
||||||
disabled,
|
disabled,
|
||||||
|
type = 'document',
|
||||||
...props
|
...props
|
||||||
}: DocumentDropzoneProps) => {
|
}: DocumentDropzoneProps) => {
|
||||||
const { getRootProps, getInputProps } = useDropzone({
|
const { getRootProps, getInputProps } = useDropzone({
|
||||||
@ -157,7 +168,7 @@ export const DocumentDropzone = ({
|
|||||||
<input {...getInputProps()} />
|
<input {...getInputProps()} />
|
||||||
|
|
||||||
<p className="group-hover:text-foreground text-muted-foreground mt-8 font-medium">
|
<p className="group-hover:text-foreground text-muted-foreground mt-8 font-medium">
|
||||||
Add a document
|
{DocumentDescription[type].headline}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground/80 mt-1 text-sm ">Drag & drop your document here.</p>
|
<p className="text-muted-foreground/80 mt-1 text-sm ">Drag & drop your document here.</p>
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export const ZDocumentFlowFormSchema = z.object({
|
|||||||
formId: z.string().min(1),
|
formId: z.string().min(1),
|
||||||
nativeId: z.number().optional(),
|
nativeId: z.number().optional(),
|
||||||
type: z.nativeEnum(FieldType),
|
type: z.nativeEnum(FieldType),
|
||||||
signerEmail: z.string().min(1),
|
signerEmail: z.string().min(1).optional(),
|
||||||
pageNumber: z.number().min(1),
|
pageNumber: z.number().min(1),
|
||||||
pageX: z.number().min(0),
|
pageX: z.number().min(0),
|
||||||
pageY: z.number().min(0),
|
pageY: z.number().min(0),
|
||||||
|
|||||||
539
packages/ui/primitives/template-flow/add-template-fields.tsx
Normal file
539
packages/ui/primitives/template-flow/add-template-fields.tsx
Normal file
@ -0,0 +1,539 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { Caveat } from 'next/font/google';
|
||||||
|
|
||||||
|
import { ChevronsUpDown } from 'lucide-react';
|
||||||
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||||
|
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||||
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
|
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||||
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
} from '@documenso/ui/primitives/command';
|
||||||
|
import {
|
||||||
|
DocumentFlowFormContainerActions,
|
||||||
|
DocumentFlowFormContainerContent,
|
||||||
|
DocumentFlowFormContainerFooter,
|
||||||
|
DocumentFlowFormContainerStep,
|
||||||
|
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
|
import { FieldItem } from '@documenso/ui/primitives/document-flow/field-item';
|
||||||
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
|
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||||
|
|
||||||
|
import { useStep } from '../stepper';
|
||||||
|
// import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
|
import type { TAddTemplateFieldsFormSchema } from './add-template-fields.types';
|
||||||
|
|
||||||
|
const fontCaveat = Caveat({
|
||||||
|
weight: ['500'],
|
||||||
|
subsets: ['latin'],
|
||||||
|
display: 'swap',
|
||||||
|
variable: '--font-caveat',
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEFAULT_HEIGHT_PERCENT = 5;
|
||||||
|
const DEFAULT_WIDTH_PERCENT = 15;
|
||||||
|
|
||||||
|
const MIN_HEIGHT_PX = 60;
|
||||||
|
const MIN_WIDTH_PX = 200;
|
||||||
|
|
||||||
|
export type AddTemplateFieldsFormProps = {
|
||||||
|
documentFlow: DocumentFlowStep;
|
||||||
|
hideRecipients?: boolean;
|
||||||
|
recipients: Recipient[];
|
||||||
|
fields: Field[];
|
||||||
|
onSubmit: (_data: TAddTemplateFieldsFormSchema) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AddTemplateFieldsFormPartial = ({
|
||||||
|
documentFlow,
|
||||||
|
hideRecipients = false,
|
||||||
|
recipients,
|
||||||
|
fields,
|
||||||
|
onSubmit,
|
||||||
|
}: AddTemplateFieldsFormProps) => {
|
||||||
|
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
|
||||||
|
|
||||||
|
const { currentStep, totalSteps, previousStep } = useStep();
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
} = useForm<TAddTemplateFieldsFormSchema>({
|
||||||
|
defaultValues: {
|
||||||
|
fields: fields.map((field) => ({
|
||||||
|
nativeId: field.id,
|
||||||
|
formId: `${field.id}-${field.templateId}`,
|
||||||
|
pageNumber: field.page,
|
||||||
|
type: field.type,
|
||||||
|
pageX: Number(field.positionX),
|
||||||
|
pageY: Number(field.positionY),
|
||||||
|
pageWidth: Number(field.width),
|
||||||
|
pageHeight: Number(field.height),
|
||||||
|
signerId: field.recipientId ?? -1,
|
||||||
|
signerEmail:
|
||||||
|
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
||||||
|
signerToken:
|
||||||
|
recipients.find((recipient) => recipient.id === field.recipientId)?.token ?? '',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onFormSubmit = handleSubmit(onSubmit);
|
||||||
|
|
||||||
|
const {
|
||||||
|
append,
|
||||||
|
remove,
|
||||||
|
update,
|
||||||
|
fields: localFields,
|
||||||
|
} = useFieldArray({
|
||||||
|
control,
|
||||||
|
name: 'fields',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
||||||
|
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
|
||||||
|
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
|
||||||
|
|
||||||
|
const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
|
||||||
|
const [coords, setCoords] = useState({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fieldBounds = useRef({
|
||||||
|
height: 0,
|
||||||
|
width: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onMouseMove = useCallback(
|
||||||
|
(event: MouseEvent) => {
|
||||||
|
setIsFieldWithinBounds(
|
||||||
|
isWithinPageBounds(
|
||||||
|
event,
|
||||||
|
PDF_VIEWER_PAGE_SELECTOR,
|
||||||
|
fieldBounds.current.width,
|
||||||
|
fieldBounds.current.height,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
setCoords({
|
||||||
|
x: event.clientX - fieldBounds.current.width / 2,
|
||||||
|
y: event.clientY - fieldBounds.current.height / 2,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[isWithinPageBounds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onMouseClick = useCallback(
|
||||||
|
(event: MouseEvent) => {
|
||||||
|
if (!selectedField || !selectedSigner) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const $page = getPage(event, PDF_VIEWER_PAGE_SELECTOR);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!$page ||
|
||||||
|
!isWithinPageBounds(
|
||||||
|
event,
|
||||||
|
PDF_VIEWER_PAGE_SELECTOR,
|
||||||
|
fieldBounds.current.width,
|
||||||
|
fieldBounds.current.height,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setSelectedField(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { top, left, height, width } = getBoundingClientRect($page);
|
||||||
|
|
||||||
|
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
|
||||||
|
|
||||||
|
// Calculate x and y as a percentage of the page width and height
|
||||||
|
let pageX = ((event.pageX - left) / width) * 100;
|
||||||
|
let pageY = ((event.pageY - top) / height) * 100;
|
||||||
|
|
||||||
|
// Get the bounds as a percentage of the page width and height
|
||||||
|
const fieldPageWidth = (fieldBounds.current.width / width) * 100;
|
||||||
|
const fieldPageHeight = (fieldBounds.current.height / height) * 100;
|
||||||
|
|
||||||
|
// And center it based on the bounds
|
||||||
|
pageX -= fieldPageWidth / 2;
|
||||||
|
pageY -= fieldPageHeight / 2;
|
||||||
|
|
||||||
|
append({
|
||||||
|
formId: nanoid(12),
|
||||||
|
type: selectedField,
|
||||||
|
pageNumber,
|
||||||
|
pageX,
|
||||||
|
pageY,
|
||||||
|
pageWidth: fieldPageWidth,
|
||||||
|
pageHeight: fieldPageHeight,
|
||||||
|
signerEmail: selectedSigner.email,
|
||||||
|
signerId: selectedSigner.id,
|
||||||
|
signerToken: selectedSigner.token ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsFieldWithinBounds(false);
|
||||||
|
setSelectedField(null);
|
||||||
|
},
|
||||||
|
[append, isWithinPageBounds, selectedField, selectedSigner, getPage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onFieldResize = useCallback(
|
||||||
|
(node: HTMLElement, index: number) => {
|
||||||
|
const field = localFields[index];
|
||||||
|
|
||||||
|
const $page = window.document.querySelector<HTMLElement>(
|
||||||
|
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
x: pageX,
|
||||||
|
y: pageY,
|
||||||
|
width: pageWidth,
|
||||||
|
height: pageHeight,
|
||||||
|
} = getFieldPosition($page, node);
|
||||||
|
|
||||||
|
update(index, {
|
||||||
|
...field,
|
||||||
|
pageX,
|
||||||
|
pageY,
|
||||||
|
pageWidth,
|
||||||
|
pageHeight,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[getFieldPosition, localFields, update],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onFieldMove = useCallback(
|
||||||
|
(node: HTMLElement, index: number) => {
|
||||||
|
const field = localFields[index];
|
||||||
|
|
||||||
|
const $page = window.document.querySelector<HTMLElement>(
|
||||||
|
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { x: pageX, y: pageY } = getFieldPosition($page, node);
|
||||||
|
|
||||||
|
update(index, {
|
||||||
|
...field,
|
||||||
|
pageX,
|
||||||
|
pageY,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[getFieldPosition, localFields, update],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedField) {
|
||||||
|
window.addEventListener('mousemove', onMouseMove);
|
||||||
|
window.addEventListener('mouseup', onMouseClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', onMouseMove);
|
||||||
|
window.removeEventListener('mouseup', onMouseClick);
|
||||||
|
};
|
||||||
|
}, [onMouseClick, onMouseMove, selectedField]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new MutationObserver((_mutations) => {
|
||||||
|
const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||||
|
|
||||||
|
if (!$page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { height, width } = $page.getBoundingClientRect();
|
||||||
|
|
||||||
|
fieldBounds.current = {
|
||||||
|
height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX),
|
||||||
|
width: Math.max(width * (DEFAULT_WIDTH_PERCENT / 100), MIN_WIDTH_PX),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedSigner(recipients[0]);
|
||||||
|
}, [recipients]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DocumentFlowFormContainerContent>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{selectedField && (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
'bg-background pointer-events-none fixed z-50 cursor-pointer transition-opacity',
|
||||||
|
{
|
||||||
|
'border-primary': isFieldWithinBounds,
|
||||||
|
'opacity-50': !isFieldWithinBounds,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
top: coords.y,
|
||||||
|
left: coords.x,
|
||||||
|
height: fieldBounds.current.height,
|
||||||
|
width: fieldBounds.current.width,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent className="text-foreground flex h-full w-full items-center justify-center p-2">
|
||||||
|
{FRIENDLY_FIELD_TYPE[selectedField]}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{localFields.map((field, index) => (
|
||||||
|
<FieldItem
|
||||||
|
key={index}
|
||||||
|
field={field}
|
||||||
|
disabled={selectedSigner?.email !== field.signerEmail}
|
||||||
|
minHeight={fieldBounds.current.height}
|
||||||
|
minWidth={fieldBounds.current.width}
|
||||||
|
passive={isFieldWithinBounds && !!selectedField}
|
||||||
|
onResize={(options) => onFieldResize(options, index)}
|
||||||
|
onMove={(options) => onFieldMove(options, index)}
|
||||||
|
onRemove={() => remove(index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!hideRecipients && (
|
||||||
|
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="bg-background text-muted-foreground mb-12 justify-between font-normal"
|
||||||
|
>
|
||||||
|
{selectedSigner?.email && (
|
||||||
|
<span className="flex-1 truncate text-left">
|
||||||
|
{selectedSigner?.name} ({selectedSigner?.email})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!selectedSigner?.email && (
|
||||||
|
<span className="flex-1 truncate text-left">{selectedSigner?.email}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent className="p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput />
|
||||||
|
<CommandEmpty>
|
||||||
|
<span className="text-muted-foreground inline-block px-4">
|
||||||
|
No recipient matching this description was found.
|
||||||
|
</span>
|
||||||
|
</CommandEmpty>
|
||||||
|
|
||||||
|
<CommandGroup>
|
||||||
|
{recipients.map((recipient, index) => (
|
||||||
|
<CommandItem
|
||||||
|
key={index}
|
||||||
|
className={cn({
|
||||||
|
// 'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
|
||||||
|
})}
|
||||||
|
onSelect={() => {
|
||||||
|
setSelectedSigner(recipient);
|
||||||
|
setShowRecipientsSelector(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* {recipient.sendStatus !== SendStatus.SENT ? (
|
||||||
|
<Check
|
||||||
|
aria-hidden={recipient !== selectedSigner}
|
||||||
|
className={cn('mr-2 h-4 w-4 flex-shrink-0', {
|
||||||
|
'opacity-0': recipient !== selectedSigner,
|
||||||
|
'opacity-100': recipient === selectedSigner,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Info className="mr-2 h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
This document has already been sent to this recipient. You can no
|
||||||
|
longer edit this recipient.
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
{recipient.name && (
|
||||||
|
<span
|
||||||
|
className="truncate"
|
||||||
|
title={`${recipient.name} (${recipient.email})`}
|
||||||
|
>
|
||||||
|
{recipient.name} ({recipient.email})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!recipient.name && (
|
||||||
|
<span className="truncate" title={recipient.email}>
|
||||||
|
{recipient.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="-mx-2 flex-1 overflow-y-auto px-2">
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group h-full w-full"
|
||||||
|
disabled={!selectedSigner}
|
||||||
|
onClick={() => setSelectedField(FieldType.SIGNATURE)}
|
||||||
|
onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
|
||||||
|
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
|
||||||
|
>
|
||||||
|
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground group-data-[selected]:text-foreground w-full truncate text-3xl font-medium',
|
||||||
|
fontCaveat.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedSigner?.name || 'Signature'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-center text-xs">Signature</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group h-full w-full"
|
||||||
|
disabled={!selectedSigner}
|
||||||
|
onClick={() => setSelectedField(FieldType.EMAIL)}
|
||||||
|
onMouseDown={() => setSelectedField(FieldType.EMAIL)}
|
||||||
|
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
|
||||||
|
>
|
||||||
|
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{'Email'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">Email</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group h-full w-full"
|
||||||
|
disabled={!selectedSigner}
|
||||||
|
onClick={() => setSelectedField(FieldType.NAME)}
|
||||||
|
onMouseDown={() => setSelectedField(FieldType.NAME)}
|
||||||
|
data-selected={selectedField === FieldType.NAME ? true : undefined}
|
||||||
|
>
|
||||||
|
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{'Name'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">Name</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group h-full w-full"
|
||||||
|
disabled={!selectedSigner}
|
||||||
|
onClick={() => setSelectedField(FieldType.DATE)}
|
||||||
|
onMouseDown={() => setSelectedField(FieldType.DATE)}
|
||||||
|
data-selected={selectedField === FieldType.DATE ? true : undefined}
|
||||||
|
>
|
||||||
|
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{'Date'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">Date</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentFlowFormContainerContent>
|
||||||
|
|
||||||
|
<DocumentFlowFormContainerFooter>
|
||||||
|
<DocumentFlowFormContainerStep
|
||||||
|
title={documentFlow.title}
|
||||||
|
step={currentStep}
|
||||||
|
maxStep={totalSteps}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DocumentFlowFormContainerActions
|
||||||
|
loading={isSubmitting}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
goNextLabel="Save Template"
|
||||||
|
onGoBackClick={() => {
|
||||||
|
previousStep();
|
||||||
|
remove();
|
||||||
|
}}
|
||||||
|
onGoNextClick={() => void onFormSubmit()}
|
||||||
|
/>
|
||||||
|
</DocumentFlowFormContainerFooter>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export const ZAddTemplateFieldsFormSchema = z.object({
|
||||||
|
fields: z.array(
|
||||||
|
z.object({
|
||||||
|
formId: z.string().min(1),
|
||||||
|
nativeId: z.number().optional(),
|
||||||
|
type: z.nativeEnum(FieldType),
|
||||||
|
signerEmail: z.string().min(1),
|
||||||
|
signerToken: z.string(),
|
||||||
|
signerId: z.number().optional(),
|
||||||
|
pageNumber: z.number().min(1),
|
||||||
|
pageX: z.number().min(0),
|
||||||
|
pageY: z.number().min(0),
|
||||||
|
pageWidth: z.number().min(0),
|
||||||
|
pageHeight: z.number().min(0),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAddTemplateFieldsFormSchema = z.infer<typeof ZAddTemplateFieldsFormSchema>;
|
||||||
@ -0,0 +1,193 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useId, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { Plus, Trash } from 'lucide-react';
|
||||||
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
|
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
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 {
|
||||||
|
DocumentFlowFormContainerActions,
|
||||||
|
DocumentFlowFormContainerContent,
|
||||||
|
DocumentFlowFormContainerFooter,
|
||||||
|
DocumentFlowFormContainerStep,
|
||||||
|
} from '../document-flow/document-flow-root';
|
||||||
|
import type { DocumentFlowStep } from '../document-flow/types';
|
||||||
|
import { useStep } from '../stepper';
|
||||||
|
import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
|
||||||
|
import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
|
||||||
|
|
||||||
|
export type AddTemplatePlaceholderRecipientsFormProps = {
|
||||||
|
documentFlow: DocumentFlowStep;
|
||||||
|
recipients: Recipient[];
|
||||||
|
fields: Field[];
|
||||||
|
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||||
|
documentFlow,
|
||||||
|
recipients,
|
||||||
|
fields: _fields,
|
||||||
|
onSubmit,
|
||||||
|
}: AddTemplatePlaceholderRecipientsFormProps) => {
|
||||||
|
const initialId = useId();
|
||||||
|
const [placeholderRecipientCount, setPlaceholderRecipientCount] = useState(() =>
|
||||||
|
recipients.length > 1 ? recipients.length + 1 : 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { currentStep, totalSteps, previousStep } = useStep();
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = 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,
|
||||||
|
}))
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
formId: initialId,
|
||||||
|
name: `Recipient 1`,
|
||||||
|
email: `recipient.1@documenso.com`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onFormSubmit = handleSubmit(onSubmit);
|
||||||
|
|
||||||
|
const {
|
||||||
|
append: appendSigner,
|
||||||
|
fields: signers,
|
||||||
|
remove: removeSigner,
|
||||||
|
} = useFieldArray({
|
||||||
|
control,
|
||||||
|
name: 'signers',
|
||||||
|
});
|
||||||
|
|
||||||
|
const onAddPlaceholderRecipient = () => {
|
||||||
|
appendSigner({
|
||||||
|
formId: nanoid(12),
|
||||||
|
name: `Recipient ${placeholderRecipientCount}`,
|
||||||
|
email: `recipient.${placeholderRecipientCount}@documenso.com`,
|
||||||
|
});
|
||||||
|
|
||||||
|
setPlaceholderRecipientCount((count) => count + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRemoveSigner = (index: number) => {
|
||||||
|
removeSigner(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key === 'Enter' && event.target instanceof HTMLInputElement) {
|
||||||
|
onAddPlaceholderRecipient();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DocumentFlowFormContainerContent>
|
||||||
|
<div className="flex w-full flex-col gap-y-4">
|
||||||
|
<AnimatePresence>
|
||||||
|
{signers.map((signer, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={signer.id}
|
||||||
|
data-native-id={signer.nativeId}
|
||||||
|
className="flex flex-wrap items-end gap-x-4"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor={`signer-${signer.id}-email`}>
|
||||||
|
Email
|
||||||
|
<span className="text-destructive ml-1 inline-block font-medium">*</span>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id={`signer-${signer.id}-email`}
|
||||||
|
type="email"
|
||||||
|
value={signer.email}
|
||||||
|
disabled
|
||||||
|
className="bg-background mt-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor={`signer-${signer.id}-name`}>Name</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id={`signer-${signer.id}-name`}
|
||||||
|
type="text"
|
||||||
|
value={signer.name}
|
||||||
|
disabled
|
||||||
|
className="bg-background mt-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<FormErrorMessage className="mt-2" error={errors.signers?.[index]?.email} />
|
||||||
|
<FormErrorMessage className="mt-2" error={errors.signers?.[index]?.name} />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormErrorMessage
|
||||||
|
className="mt-2"
|
||||||
|
// Dirty hack to handle errors when .root is populated for an array type
|
||||||
|
error={'signers__root' in errors && errors['signers__root']}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button type="button" disabled={isSubmitting} onClick={() => onAddPlaceholderRecipient()}>
|
||||||
|
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
||||||
|
Add Placeholder Recipient
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DocumentFlowFormContainerContent>
|
||||||
|
|
||||||
|
<DocumentFlowFormContainerFooter>
|
||||||
|
<DocumentFlowFormContainerStep
|
||||||
|
title={documentFlow.title}
|
||||||
|
step={currentStep}
|
||||||
|
maxStep={totalSteps}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DocumentFlowFormContainerActions
|
||||||
|
loading={isSubmitting}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
canGoBack={currentStep > 1}
|
||||||
|
onGoBackClick={() => previousStep()}
|
||||||
|
onGoNextClick={() => void onFormSubmit()}
|
||||||
|
/>
|
||||||
|
</DocumentFlowFormContainerFooter>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZAddTemplatePlacholderRecipientsFormSchema = z
|
||||||
|
.object({
|
||||||
|
signers: z.array(
|
||||||
|
z.object({
|
||||||
|
formId: z.string().min(1),
|
||||||
|
nativeId: z.number().optional(),
|
||||||
|
email: z.string().min(1).email(),
|
||||||
|
name: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(schema) => {
|
||||||
|
const emails = schema.signers.map((signer) => signer.email.toLowerCase());
|
||||||
|
|
||||||
|
return new Set(emails).size === emails.length;
|
||||||
|
},
|
||||||
|
// Dirty hack to handle errors when .root is populated for an array type
|
||||||
|
{ message: 'Signers must have unique emails', path: ['signers__root'] },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TAddTemplatePlacholderRecipientsFormSchema = z.infer<
|
||||||
|
typeof ZAddTemplatePlacholderRecipientsFormSchema
|
||||||
|
>;
|
||||||
Reference in New Issue
Block a user