mirror of
https://github.com/documenso/documenso.git
synced 2025-11-17 10:11:35 +10:00
feat: wip
This commit is contained in:
166
apps/web/src/app/(dashboard)/template/page.tsx
Normal file
166
apps/web/src/app/(dashboard)/template/page.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Field, Recipient } from '@documenso/prisma/client';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||||
|
import { AddTemplateFormPartial } from '@documenso/ui/primitives/document-flow/add-template-details';
|
||||||
|
import { TAddTemplateSchema } from '@documenso/ui/primitives/document-flow/add-template-details.types';
|
||||||
|
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-template-fields';
|
||||||
|
import { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-template-fields.types';
|
||||||
|
import {
|
||||||
|
DocumentFlowFormContainer,
|
||||||
|
DocumentFlowFormContainerHeader,
|
||||||
|
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
|
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
import { toast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
type TemplatePageFlowStep = 'details' | 'fields';
|
||||||
|
|
||||||
|
export type DocumentPageProps = {
|
||||||
|
params: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TemplatePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [uploadedFile, setUploadedFile] = useState<{ name: string; file: string } | null>();
|
||||||
|
const [step, setStep] = useState<TemplatePageFlowStep>('details');
|
||||||
|
const [fields, setFields] = useState<Field[]>([]);
|
||||||
|
|
||||||
|
const documentFlow: Record<TemplatePageFlowStep, DocumentFlowStep> = {
|
||||||
|
details: {
|
||||||
|
title: 'Add Details',
|
||||||
|
description: 'Add the name and description of your template.',
|
||||||
|
stepIndex: 1,
|
||||||
|
onSubmit: () => onAddTemplateDetailsFormSubmit,
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
title: 'Add Fields',
|
||||||
|
description: 'Add all relevant fields for each recipient.',
|
||||||
|
stepIndex: 2,
|
||||||
|
onBackStep: () => setStep('details'),
|
||||||
|
onSubmit: () => onAddTemplateFieldsFormSubmit,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentDocumentFlow = documentFlow[step];
|
||||||
|
|
||||||
|
const onAddTemplateDetailsFormSubmit = (data: TAddTemplateSchema) => {
|
||||||
|
if (!uploadedFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
|
||||||
|
const templateDetails = {
|
||||||
|
document: uploadedFile,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(templateDetails);
|
||||||
|
|
||||||
|
setStep('fields');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAddTemplateFieldsFormSubmit = (data: TAddTemplateFieldsFormSchema) => {
|
||||||
|
if (!uploadedFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(data);
|
||||||
|
|
||||||
|
console.log('Submit fields in document flow');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFileDrop = async (file: File) => {
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const base64String = Buffer.from(arrayBuffer).toString('base64');
|
||||||
|
|
||||||
|
setUploadedFile({
|
||||||
|
name: file.name,
|
||||||
|
file: `data:application/pdf;base64,${base64String}`,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'Please try again later.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeholderRecipient: Recipient[] = [
|
||||||
|
{
|
||||||
|
id: -1,
|
||||||
|
documentId: -1,
|
||||||
|
email: '',
|
||||||
|
name: '',
|
||||||
|
token: '',
|
||||||
|
expired: null,
|
||||||
|
signedAt: null,
|
||||||
|
readStatus: 'OPENED',
|
||||||
|
signingStatus: 'NOT_SIGNED',
|
||||||
|
sendStatus: 'NOT_SENT',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<h1 className="mt-4 max-w-xs truncate text-2xl font-semibold md:text-3xl" title="Templates">
|
||||||
|
Templates
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-12 grid w-full grid-cols-12 gap-8">
|
||||||
|
<div className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7">
|
||||||
|
{uploadedFile ? (
|
||||||
|
<Card gradient>
|
||||||
|
<CardContent className="p-2">
|
||||||
|
<LazyPDFViewer document={uploadedFile.file} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<DocumentDropzone className="h-[80vh] max-h-[60rem]" onDrop={onFileDrop} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
|
<DocumentFlowFormContainer onSubmit={(e) => e.preventDefault()}>
|
||||||
|
<DocumentFlowFormContainerHeader
|
||||||
|
title={currentDocumentFlow.title}
|
||||||
|
description={currentDocumentFlow.description}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{step === 'details' && (
|
||||||
|
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
|
||||||
|
<AddTemplateFormPartial
|
||||||
|
documentFlow={documentFlow.details}
|
||||||
|
fields={fields}
|
||||||
|
numberOfSteps={Object.keys(documentFlow).length}
|
||||||
|
onSubmit={onAddTemplateDetailsFormSubmit}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'fields' && (
|
||||||
|
<AddTemplateFieldsFormPartial
|
||||||
|
documentFlow={documentFlow.fields}
|
||||||
|
recipients={placeholderRecipient}
|
||||||
|
fields={fields}
|
||||||
|
numberOfSteps={Object.keys(documentFlow).length}
|
||||||
|
onSubmit={onAddTemplateFieldsFormSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DocumentFlowFormContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
|
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||||
|
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||||
|
|
||||||
|
export type AddSignersActionInput = TAddSignersFormSchema & {
|
||||||
|
documentId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addSigners = async ({ documentId, signers }: AddSignersActionInput) => {
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
const { id: userId } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
await setRecipientsForDocument({
|
||||||
|
userId,
|
||||||
|
documentId,
|
||||||
|
recipients: signers.map((signer) => ({
|
||||||
|
id: signer.nativeId,
|
||||||
|
email: signer.email,
|
||||||
|
name: signer.name,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
};
|
||||||
38
packages/lib/server-only/template/create-template.ts
Normal file
38
packages/lib/server-only/template/create-template.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { DocumentContent } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
type CreateTemplateInput = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
document: DocumentContent;
|
||||||
|
userId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTemplate = async ({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
document,
|
||||||
|
userId,
|
||||||
|
}: CreateTemplateInput) => {
|
||||||
|
const createTemplateDocument = await prisma.documentContent.create({
|
||||||
|
data: {
|
||||||
|
content: document.content,
|
||||||
|
name: document.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdTemplate = await prisma.template.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
slug: nanoid(10),
|
||||||
|
description,
|
||||||
|
ownerId: userId,
|
||||||
|
documentId: createTemplateDocument.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return createdTemplate;
|
||||||
|
};
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "TemplateType" AS ENUM ('PRIVATE', 'PUBLIC');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Template" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"kind" "TemplateType" NOT NULL,
|
||||||
|
"ownerId" INTEGER NOT NULL,
|
||||||
|
"documentId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Template_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DocumentContent" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "DocumentContent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Template_slug_key" ON "Template"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Template_ownerId_idx" ON "Template"("ownerId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Template" ADD CONSTRAINT "Template_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Template" ADD CONSTRAINT "Template_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "DocumentContent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the `DocumentContent` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `Template` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Template" DROP CONSTRAINT "Template_documentId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Template" DROP CONSTRAINT "Template_ownerId_fkey";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "DocumentContent";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "Template";
|
||||||
|
|
||||||
|
-- DropEnum
|
||||||
|
DROP TYPE "TemplateType";
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "TemplateType" AS ENUM ('PRIVATE', 'PUBLIC');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Template" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"kind" "TemplateType" NOT NULL,
|
||||||
|
"ownerId" INTEGER NOT NULL,
|
||||||
|
"documentId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Template_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DocumentContent" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "DocumentContent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Template_slug_key" ON "Template"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Template_ownerId_idx" ON "Template"("ownerId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Template" ADD CONSTRAINT "Template_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Template" ADD CONSTRAINT "Template_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "DocumentContent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Template" ALTER COLUMN "kind" SET DEFAULT 'PRIVATE';
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Template" ADD COLUMN "description" TEXT;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Added the required column `name` to the `DocumentContent` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DocumentContent" ADD COLUMN "name" TEXT NOT NULL;
|
||||||
@ -174,20 +174,22 @@ enum TemplateType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Template {
|
model Template {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
slug String @unique
|
slug String @unique
|
||||||
kind TemplateType
|
description String?
|
||||||
ownerId Int
|
kind TemplateType @default(PRIVATE)
|
||||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
ownerId Int
|
||||||
documentId Int
|
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
document DocumentContent @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
documentId Int
|
||||||
|
document DocumentContent @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model DocumentContent {
|
model DocumentContent {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
|
name String
|
||||||
content String
|
content String
|
||||||
templates Template[]
|
templates Template[]
|
||||||
}
|
}
|
||||||
|
|||||||
103
packages/ui/primitives/document-flow/add-template-details.tsx
Normal file
103
packages/ui/primitives/document-flow/add-template-details.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { Field } from '@documenso/prisma/client';
|
||||||
|
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 { TAddTemplateSchema, ZAddTemplateSchema } from './add-template-details.types';
|
||||||
|
import {
|
||||||
|
DocumentFlowFormContainerActions,
|
||||||
|
DocumentFlowFormContainerContent,
|
||||||
|
DocumentFlowFormContainerFooter,
|
||||||
|
DocumentFlowFormContainerStep,
|
||||||
|
} from './document-flow-root';
|
||||||
|
import { DocumentFlowStep } from './types';
|
||||||
|
|
||||||
|
export type AddTemplateFormProps = {
|
||||||
|
documentFlow: DocumentFlowStep;
|
||||||
|
fields: Field[];
|
||||||
|
numberOfSteps: number;
|
||||||
|
onSubmit: (_data: TAddTemplateSchema) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AddTemplateFormPartial = ({
|
||||||
|
documentFlow,
|
||||||
|
numberOfSteps,
|
||||||
|
fields: _fields,
|
||||||
|
onSubmit,
|
||||||
|
}: AddTemplateFormProps) => {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<TAddTemplateSchema>({
|
||||||
|
resolver: zodResolver(ZAddTemplateSchema),
|
||||||
|
defaultValues: {
|
||||||
|
template: {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onFormSubmit = handleSubmit(onSubmit);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DocumentFlowFormContainerContent>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="name">Name</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
className="bg-background mt-2"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
{...register('template.name')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormErrorMessage className="mt-2" error={errors.template?.name} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="description">
|
||||||
|
Description <span className="text-muted-foreground">(Optional)</span>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="description"
|
||||||
|
className="bg-background mt-2"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
{...register('template.description')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormErrorMessage className="mt-2" error={errors.template?.description} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentFlowFormContainerContent>
|
||||||
|
|
||||||
|
<DocumentFlowFormContainerFooter>
|
||||||
|
<DocumentFlowFormContainerStep
|
||||||
|
title={documentFlow.title}
|
||||||
|
step={documentFlow.stepIndex}
|
||||||
|
maxStep={numberOfSteps}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DocumentFlowFormContainerActions
|
||||||
|
loading={isSubmitting}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onGoBackClick={documentFlow.onBackStep}
|
||||||
|
onGoNextClick={() => void onFormSubmit()}
|
||||||
|
/>
|
||||||
|
</DocumentFlowFormContainerFooter>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZAddTemplateSchema = z.object({
|
||||||
|
template: z.object({
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAddTemplateSchema = z.infer<typeof ZAddTemplateSchema>;
|
||||||
418
packages/ui/primitives/document-flow/add-template-fields.tsx
Normal file
418
packages/ui/primitives/document-flow/add-template-fields.tsx
Normal file
@ -0,0 +1,418 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { Caveat } from 'next/font/google';
|
||||||
|
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
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 { Field, FieldType, Recipient, SendStatus } from '@documenso/prisma/client';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
|
||||||
|
import { TAddTemplateFieldsFormSchema } from './add-template-fields.types';
|
||||||
|
import {
|
||||||
|
DocumentFlowFormContainerActions,
|
||||||
|
DocumentFlowFormContainerContent,
|
||||||
|
DocumentFlowFormContainerFooter,
|
||||||
|
DocumentFlowFormContainerStep,
|
||||||
|
} from './document-flow-root';
|
||||||
|
import { FieldItem } from './field-item';
|
||||||
|
import { DocumentFlowStep, FRIENDLY_FIELD_TYPE } from './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;
|
||||||
|
recipients: Recipient[];
|
||||||
|
fields: Field[];
|
||||||
|
numberOfSteps: number;
|
||||||
|
onSubmit: (_data: TAddTemplateFieldsFormSchema) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AddTemplateFieldsFormPartial = ({
|
||||||
|
documentFlow,
|
||||||
|
recipients,
|
||||||
|
fields,
|
||||||
|
numberOfSteps,
|
||||||
|
onSubmit,
|
||||||
|
}: AddTemplateFieldsFormProps) => {
|
||||||
|
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
} = useForm<TAddTemplateFieldsFormSchema>({
|
||||||
|
defaultValues: {
|
||||||
|
fields: fields.map((field) => ({
|
||||||
|
nativeId: field.id,
|
||||||
|
formId: `${field.id}-${field.documentId}`,
|
||||||
|
pageNumber: field.page,
|
||||||
|
type: field.type,
|
||||||
|
pageX: Number(field.positionX),
|
||||||
|
pageY: Number(field.positionY),
|
||||||
|
pageWidth: Number(field.width),
|
||||||
|
pageHeight: Number(field.height),
|
||||||
|
signerEmail:
|
||||||
|
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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 hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT;
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
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('click', onMouseClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', onMouseMove);
|
||||||
|
window.removeEventListener('click', onMouseClick);
|
||||||
|
};
|
||||||
|
}, [onMouseClick, onMouseMove, selectedField]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const $page = window.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),
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedSigner(recipients.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipients[0]);
|
||||||
|
}, [recipients]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DocumentFlowFormContainerContent>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{selectedField && (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none fixed z-50 cursor-pointer bg-white 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 || hasSelectedSignerBeenSent}
|
||||||
|
minHeight={fieldBounds.current.height}
|
||||||
|
minWidth={fieldBounds.current.width}
|
||||||
|
passive={isFieldWithinBounds && !!selectedField}
|
||||||
|
onResize={(options) => onFieldResize(options, index)}
|
||||||
|
onMove={(options) => onFieldMove(options, index)}
|
||||||
|
onRemove={() => remove(index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="-mx-2 flex-1 overflow-y-scroll 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 || selectedSigner?.sendStatus === SendStatus.SENT}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
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 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 || selectedSigner?.sendStatus === SendStatus.SENT}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
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 || selectedSigner?.sendStatus === SendStatus.SENT}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
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 || selectedSigner?.sendStatus === SendStatus.SENT}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
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={documentFlow.stepIndex}
|
||||||
|
maxStep={numberOfSteps}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DocumentFlowFormContainerActions
|
||||||
|
loading={isSubmitting}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onGoBackClick={documentFlow.onBackStep}
|
||||||
|
onGoNextClick={() => void onFormSubmit()}
|
||||||
|
/>
|
||||||
|
</DocumentFlowFormContainerFooter>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
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),
|
||||||
|
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>;
|
||||||
Reference in New Issue
Block a user