diff --git a/apps/web/src/app/(dashboard)/template/page.tsx b/apps/web/src/app/(dashboard)/template/page.tsx new file mode 100644 index 000000000..3362c2788 --- /dev/null +++ b/apps/web/src/app/(dashboard)/template/page.tsx @@ -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('details'); + const [fields, setFields] = useState([]); + + const documentFlow: Record = { + 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 ( +
+

+ Templates +

+ +
+
+ {uploadedFile ? ( + + + + + + ) : ( + + )} +
+ +
+ e.preventDefault()}> + + + {step === 'details' && ( +
+ +
+ )} + + {step === 'fields' && ( + + )} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/forms/template/create-template.action.ts b/apps/web/src/components/forms/template/create-template.action.ts new file mode 100644 index 000000000..4e6739bd6 --- /dev/null +++ b/apps/web/src/components/forms/template/create-template.action.ts @@ -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, + })), + }); +}; diff --git a/packages/lib/server-only/template/create-template.ts b/packages/lib/server-only/template/create-template.ts new file mode 100644 index 000000000..13d56a461 --- /dev/null +++ b/packages/lib/server-only/template/create-template.ts @@ -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; +}; diff --git a/packages/prisma/migrations/20230909113116_add_templates/migration.sql b/packages/prisma/migrations/20230909113116_add_templates/migration.sql new file mode 100644 index 000000000..f3a1760cd --- /dev/null +++ b/packages/prisma/migrations/20230909113116_add_templates/migration.sql @@ -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; diff --git a/packages/prisma/migrations/20230909195606_single_player_mode/migration.sql b/packages/prisma/migrations/20230909195606_single_player_mode/migration.sql new file mode 100644 index 000000000..358c12c6b --- /dev/null +++ b/packages/prisma/migrations/20230909195606_single_player_mode/migration.sql @@ -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"; diff --git a/packages/prisma/migrations/20230909195903_add_templates/migration.sql b/packages/prisma/migrations/20230909195903_add_templates/migration.sql new file mode 100644 index 000000000..f3a1760cd --- /dev/null +++ b/packages/prisma/migrations/20230909195903_add_templates/migration.sql @@ -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; diff --git a/packages/prisma/migrations/20230910120846_template_private/migration.sql b/packages/prisma/migrations/20230910120846_template_private/migration.sql new file mode 100644 index 000000000..0a69ccccb --- /dev/null +++ b/packages/prisma/migrations/20230910120846_template_private/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Template" ALTER COLUMN "kind" SET DEFAULT 'PRIVATE'; diff --git a/packages/prisma/migrations/20230910120946_template_description/migration.sql b/packages/prisma/migrations/20230910120946_template_description/migration.sql new file mode 100644 index 000000000..96913775e --- /dev/null +++ b/packages/prisma/migrations/20230910120946_template_description/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Template" ADD COLUMN "description" TEXT; diff --git a/packages/prisma/migrations/20230910121833_document_name/migration.sql b/packages/prisma/migrations/20230910121833_document_name/migration.sql new file mode 100644 index 000000000..f1ad863de --- /dev/null +++ b/packages/prisma/migrations/20230910121833_document_name/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 96f92e265..1bfb03504 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -174,20 +174,22 @@ enum TemplateType { } model Template { - id Int @id @default(autoincrement()) - name String - slug String @unique - kind TemplateType - ownerId Int - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) - documentId Int - document DocumentContent @relation(fields: [documentId], references: [id], onDelete: Cascade) + id Int @id @default(autoincrement()) + name String + slug String @unique + description String? + kind TemplateType @default(PRIVATE) + ownerId Int + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + documentId Int + document DocumentContent @relation(fields: [documentId], references: [id], onDelete: Cascade) @@index([ownerId]) } model DocumentContent { id Int @id @default(autoincrement()) + name String content String templates Template[] } diff --git a/packages/ui/primitives/document-flow/add-template-details.tsx b/packages/ui/primitives/document-flow/add-template-details.tsx new file mode 100644 index 000000000..6efc5a2ed --- /dev/null +++ b/packages/ui/primitives/document-flow/add-template-details.tsx @@ -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({ + resolver: zodResolver(ZAddTemplateSchema), + defaultValues: { + template: { + name: '', + description: '', + }, + }, + }); + + const onFormSubmit = handleSubmit(onSubmit); + + return ( + <> + +
+
+
+ + + + + +
+ +
+ + + + + +
+
+
+
+ + + + + void onFormSubmit()} + /> + + + ); +}; diff --git a/packages/ui/primitives/document-flow/add-template-details.types.ts b/packages/ui/primitives/document-flow/add-template-details.types.ts new file mode 100644 index 000000000..538a22c85 --- /dev/null +++ b/packages/ui/primitives/document-flow/add-template-details.types.ts @@ -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; diff --git a/packages/ui/primitives/document-flow/add-template-fields.tsx b/packages/ui/primitives/document-flow/add-template-fields.tsx new file mode 100644 index 000000000..678c0aba0 --- /dev/null +++ b/packages/ui/primitives/document-flow/add-template-fields.tsx @@ -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({ + 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(null); + const [selectedSigner, setSelectedSigner] = useState(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( + `${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( + `${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 ( + <> + +
+ {selectedField && ( + + + {FRIENDLY_FIELD_TYPE[selectedField]} + + + )} + + {localFields.map((field, index) => ( + onFieldResize(options, index)} + onMove={(options) => onFieldMove(options, index)} + onRemove={() => remove(index)} + /> + ))} + +
+
+ + + + + + + +
+
+
+
+ + + + + void onFormSubmit()} + /> + + + ); +}; diff --git a/packages/ui/primitives/document-flow/add-template-fields.types.ts b/packages/ui/primitives/document-flow/add-template-fields.types.ts new file mode 100644 index 000000000..12904c078 --- /dev/null +++ b/packages/ui/primitives/document-flow/add-template-fields.types.ts @@ -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; diff --git a/templates b/templates new file mode 100644 index 000000000..ff4806191 --- /dev/null +++ b/templates @@ -0,0 +1,10 @@ +Plan + +Take the document +Take the details of the template +Take the fields +---??? Store it in the database with a templates schema + +Issues? + +Persist template details information.