feat: templates

This commit is contained in:
Ephraim Atta-Duncan
2023-10-06 22:54:24 +00:00
committed by Mythie
parent 6d34ebd91b
commit 31a9127c9e
55 changed files with 2834 additions and 34 deletions

View File

@ -19,9 +19,10 @@ export const getRecipientsStats = async () => {
results.forEach((result) => {
const { readStatus, signingStatus, sendStatus, _count } = result;
stats[readStatus] += _count;
stats[signingStatus] += _count;
stats[sendStatus] += _count;
stats[readStatus as keyof typeof stats] += _count;
stats.TOTAL_RECIPIENTS += _count;
stats[signingStatus as keyof typeof stats] += _count;
stats[sendStatus as keyof typeof stats] += _count;
stats.TOTAL_RECIPIENTS += _count;
});

View File

@ -8,7 +8,7 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen
import type { FindResultSet } from '../../types/find-result-set';
export interface FindDocumentsOptions {
export type FindDocumentsOptions = {
userId: number;
term?: string;
status?: ExtendedDocumentStatus;
@ -19,7 +19,7 @@ export interface FindDocumentsOptions {
direction: 'asc' | 'desc';
};
period?: '' | '7d' | '14d' | '30d';
}
};
export const findDocuments = async ({
userId,

View 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;
};

View File

@ -27,6 +27,10 @@ export const removeSignedFieldWithToken = async ({
const { Document: document, Recipient: recipient } = field;
if (!document) {
throw new Error(`Document not found for field ${field.id}`);
}
if (document.status === DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has already been completed`);
}

View File

@ -0,0 +1,116 @@
import { prisma } from '@documenso/prisma';
import { 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('Document 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: {
id: field.signerId,
email: field.signerEmail,
},
},
},
}),
),
);
if (removedFields.length > 0) {
await prisma.field.deleteMany({
where: {
id: {
in: removedFields.map((field) => field.id),
},
},
});
}
return persistedFields;
};

View File

@ -33,6 +33,10 @@ export const signFieldWithToken = async ({
const { Document: document, Recipient: recipient } = field;
if (!document) {
throw new Error(`Document not found for field ${field.id}`);
}
if (document.status === DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has already been completed`);
}

View File

@ -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;
};

View File

@ -0,0 +1,98 @@
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(),
templateToken: nanoid(),
templateId,
},
}),
),
);
if (removedRecipients.length > 0) {
await prisma.recipient.deleteMany({
where: {
id: {
in: removedRecipients.map((recipient) => recipient.id),
},
},
});
}
return persistedRecipients;
};

View File

@ -0,0 +1,78 @@
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { 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(),
templateToken: recipient.templateToken,
})),
},
},
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.templateToken === recipient?.templateToken,
);
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;
};

View 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,
},
});
};

View 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 } });
};

View File

@ -0,0 +1,75 @@
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { 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(),
templateToken: recipient.templateToken,
})),
},
},
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.templateToken === recipient?.templateToken,
);
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;
};

View 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,
},
});
};

View File

@ -0,0 +1,37 @@
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([
await prisma.template.findMany({
where: {
userId,
},
include: {
templateDocumentData: true,
Field: true,
},
skip: Math.max(page - 1, 0) * perPage,
orderBy: {
createdAt: 'desc',
},
}),
await prisma.template.count({
where: {
User: {
id: userId,
},
},
}),
]);
return {
templates,
totalPages: Math.ceil(count / perPage),
};
};

View File

@ -0,0 +1,52 @@
-- CreateEnum
CREATE TYPE "TemplateStatus" AS ENUM ('PUBLIC', 'PRIVATE');
-- CreateTable
CREATE TABLE "Template" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"status" "TemplateStatus" NOT NULL DEFAULT 'PRIVATE',
"templateDataId" TEXT NOT NULL,
CONSTRAINT "Template_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TemplateData" (
"id" TEXT NOT NULL,
"type" "DocumentDataType" NOT NULL,
"data" TEXT NOT NULL,
"initialData" TEXT NOT NULL,
CONSTRAINT "TemplateData_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TemplateField" (
"id" SERIAL NOT NULL,
"templateId" INTEGER NOT NULL,
"type" "FieldType" NOT NULL,
"page" INTEGER NOT NULL,
"positionX" DECIMAL(65,30) NOT NULL DEFAULT 0,
"positionY" DECIMAL(65,30) NOT NULL DEFAULT 0,
"width" DECIMAL(65,30) NOT NULL DEFAULT -1,
"height" DECIMAL(65,30) NOT NULL DEFAULT -1,
"customText" TEXT NOT NULL,
"inserted" BOOLEAN NOT NULL,
CONSTRAINT "TemplateField_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Template_templateDataId_key" ON "Template"("templateDataId");
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_templateDataId_fkey" FOREIGN KEY ("templateDataId") REFERENCES "TemplateData"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TemplateField" ADD CONSTRAINT "TemplateField_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,15 @@
/*
Warnings:
- The `status` column on the `Template` table would be dropped and recreated. This will lead to data loss if there is data in the column.
*/
-- CreateEnum
CREATE TYPE "TemplateType" AS ENUM ('PUBLIC', 'PRIVATE');
-- AlterTable
ALTER TABLE "Template" DROP COLUMN "status",
ADD COLUMN "status" "TemplateType" NOT NULL DEFAULT 'PRIVATE';
-- DropEnum
DROP TYPE "TemplateStatus";

View File

@ -0,0 +1,14 @@
/*
Warnings:
- You are about to drop the `TemplateData` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "Template" DROP CONSTRAINT "Template_templateDataId_fkey";
-- DropTable
DROP TABLE "TemplateData";
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_templateDataId_fkey" FOREIGN KEY ("templateDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `inserted` on the `TemplateField` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "TemplateField" DROP COLUMN "inserted";

View File

@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `documentName` to the `Template` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Template" ADD COLUMN "documentName" TEXT NOT NULL;

View File

@ -0,0 +1,9 @@
/*
Warnings:
- Added the required column `updatedAt` to the `Template` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Template" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;

View File

@ -0,0 +1,51 @@
/*
Warnings:
- You are about to drop the column `description` on the `Template` table. All the data in the column will be lost.
- You are about to drop the column `documentName` on the `Template` table. All the data in the column will be lost.
- You are about to drop the column `status` on the `Template` table. All the data in the column will be lost.
- You are about to drop the column `templateDataId` on the `Template` table. All the data in the column will be lost.
- A unique constraint covering the columns `[tempateDocumentDataId]` on the table `Template` will be added. If there are existing duplicate values, this will fail.
- Added the required column `tempateDocumentDataId` to the `Template` table without a default value. This is not possible if the table is not empty.
- Added the required column `inserted` to the `TemplateField` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "Template" DROP CONSTRAINT "Template_templateDataId_fkey";
-- DropIndex
DROP INDEX "Template_templateDataId_key";
-- AlterTable
ALTER TABLE "Template" DROP COLUMN "description",
DROP COLUMN "documentName",
DROP COLUMN "status",
DROP COLUMN "templateDataId",
ADD COLUMN "tempateDocumentDataId" TEXT NOT NULL,
ADD COLUMN "type" "TemplateType" NOT NULL DEFAULT 'PRIVATE',
ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "TemplateField" ADD COLUMN "inserted" BOOLEAN NOT NULL,
ADD COLUMN "recipientId" INTEGER;
-- CreateTable
CREATE TABLE "TemplateRecipient" (
"id" SERIAL NOT NULL,
"templateId" INTEGER NOT NULL,
"placeholder" VARCHAR(255) NOT NULL,
CONSTRAINT "TemplateRecipient_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Template_tempateDocumentDataId_key" ON "Template"("tempateDocumentDataId");
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_tempateDocumentDataId_fkey" FOREIGN KEY ("tempateDocumentDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TemplateRecipient" ADD CONSTRAINT "TemplateRecipient_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TemplateField" ADD CONSTRAINT "TemplateField_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "TemplateRecipient"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,23 @@
/*
Warnings:
- You are about to drop the column `tempateDocumentDataId` on the `Template` table. All the data in the column will be lost.
- A unique constraint covering the columns `[templateDocumentDataId]` on the table `Template` will be added. If there are existing duplicate values, this will fail.
- Added the required column `templateDocumentDataId` to the `Template` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "Template" DROP CONSTRAINT "Template_tempateDocumentDataId_fkey";
-- DropIndex
DROP INDEX "Template_tempateDocumentDataId_key";
-- AlterTable
ALTER TABLE "Template" DROP COLUMN "tempateDocumentDataId",
ADD COLUMN "templateDocumentDataId" TEXT NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "Template_templateDocumentDataId_key" ON "Template"("templateDocumentDataId");
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_templateDocumentDataId_fkey" FOREIGN KEY ("templateDocumentDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `email` to the `TemplateRecipient` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "TemplateRecipient" ADD COLUMN "email" VARCHAR(255) NOT NULL;

View File

@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `token` to the `TemplateRecipient` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "TemplateRecipient" ADD COLUMN "token" TEXT NOT NULL;

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Recipient" ADD COLUMN "templateToken" TEXT;
-- AlterTable
ALTER TABLE "TemplateRecipient" ADD COLUMN "templateToken" TEXT;

View File

@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the column `placeholder` on the `TemplateRecipient` table. All the data in the column will be lost.
- Added the required column `name` to the `TemplateRecipient` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "TemplateRecipient" DROP COLUMN "placeholder",
ADD COLUMN "name" VARCHAR(255) NOT NULL;

View File

@ -0,0 +1,54 @@
/*
Warnings:
- You are about to drop the `TemplateField` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `TemplateRecipient` table. If the table is not empty, all the data it contains will be lost.
- A unique constraint covering the columns `[templateId,email]` on the table `Recipient` will be added. If there are existing duplicate values, this will fail.
*/
-- DropForeignKey
ALTER TABLE "Field" DROP CONSTRAINT "Field_recipientId_fkey";
-- DropForeignKey
ALTER TABLE "TemplateField" DROP CONSTRAINT "TemplateField_recipientId_fkey";
-- DropForeignKey
ALTER TABLE "TemplateField" DROP CONSTRAINT "TemplateField_templateId_fkey";
-- DropForeignKey
ALTER TABLE "TemplateRecipient" DROP CONSTRAINT "TemplateRecipient_templateId_fkey";
-- AlterTable
ALTER TABLE "Field" ADD COLUMN "templateId" INTEGER,
ALTER COLUMN "documentId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "Recipient" ADD COLUMN "templateId" INTEGER,
ALTER COLUMN "documentId" DROP NOT NULL,
ALTER COLUMN "readStatus" DROP NOT NULL,
ALTER COLUMN "signingStatus" DROP NOT NULL,
ALTER COLUMN "sendStatus" DROP NOT NULL;
-- DropTable
DROP TABLE "TemplateField";
-- DropTable
DROP TABLE "TemplateRecipient";
-- 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;

View File

@ -40,6 +40,7 @@ model User {
twoFactorEnabled Boolean @default(false)
twoFactorBackupCodes String?
VerificationToken VerificationToken[]
Template Template[]
@@index([email])
}
@ -154,6 +155,7 @@ model DocumentData {
data String
initialData String
Document Document?
Template Template?
}
model DocumentMeta {
@ -180,22 +182,27 @@ enum SigningStatus {
}
model Recipient {
id Int @id @default(autoincrement())
documentId Int
email String @db.VarChar(255)
name String @default("") @db.VarChar(255)
id Int @id @default(autoincrement())
documentId Int?
templateId Int?
email String @db.VarChar(255)
name String @default("") @db.VarChar(255)
token String
templateToken String?
expired DateTime?
signedAt DateTime?
readStatus ReadStatus @default(NOT_OPENED)
signingStatus SigningStatus @default(NOT_SIGNED)
sendStatus SendStatus @default(NOT_SENT)
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
readStatus ReadStatus? @default(NOT_OPENED)
signingStatus SigningStatus? @default(NOT_SIGNED)
sendStatus SendStatus? @default(NOT_SENT)
Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
Field Field[]
Signature Signature[]
@@unique([documentId, email])
@@unique([templateId, email])
@@index([documentId])
@@index([templateId])
@@index([token])
}
@ -210,7 +217,8 @@ enum FieldType {
model Field {
id Int @id @default(autoincrement())
documentId Int
documentId Int?
templateId Int?
recipientId Int?
type FieldType
page Int
@ -220,11 +228,13 @@ model Field {
height Decimal @default(-1)
customText String
inserted Boolean
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
Recipient Recipient? @relation(fields: [recipientId], references: [id], onDelete: Cascade)
Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
Recipient Recipient? @relation(fields: [recipientId], references: [id])
Signature Signature?
@@index([documentId])
@@index([templateId])
@@index([recipientId])
}
@ -254,3 +264,24 @@ model DocumentShareLink {
@@unique([documentId, email])
}
enum TemplateType {
PUBLIC
PRIVATE
}
model Template {
id Int @id @default(autoincrement())
userId Int
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String
type TemplateType @default(PRIVATE)
templateDocumentDataId String
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
Recipient Recipient[]
Field Field[]
@@unique([templateDocumentDataId])
}

View File

@ -6,6 +6,7 @@ import { profileRouter } from './profile-router/router';
import { recipientRouter } from './recipient-router/router';
import { shareLinkRouter } from './share-link-router/router';
import { singleplayerRouter } from './singleplayer-router/router';
import { templateRouter } from './template-router/router';
import { router } from './trpc';
import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router';
@ -19,6 +20,7 @@ export const appRouter = router({
shareLink: shareLinkRouter,
singleplayer: singleplayerRouter,
twoFactorAuthentication: twoFactorAuthenticationRouter,
template: templateRouter,
});
export type AppRouter = typeof appRouter;

View 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.',
});
}
}),
});

View 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>;

View File

@ -75,10 +75,20 @@ const DocumentDropzoneCardCenterVariants: Variants = {
},
};
const DocumentDescription = {
document: {
headline: 'Add a document',
},
template: {
headline: 'Upload Template Document',
},
};
export type DocumentDropzoneProps = {
className?: string;
disabled?: boolean;
onDrop?: (_file: File) => void | Promise<void>;
type?: 'document' | 'template';
[key: string]: unknown;
};
@ -86,6 +96,7 @@ export const DocumentDropzone = ({
className,
onDrop,
disabled,
type = 'document',
...props
}: DocumentDropzoneProps) => {
const { getRootProps, getInputProps } = useDropzone({
@ -157,7 +168,7 @@ export const DocumentDropzone = ({
<input {...getInputProps()} />
<p className="group-hover:text-foreground text-muted-foreground mt-8 font-medium">
Add a document
{DocumentDescription[type].headline}
</p>
<p className="text-muted-foreground/80 mt-1 text-sm ">Drag & drop your document here.</p>

View File

@ -24,7 +24,7 @@ export const ZDocumentFlowFormSchema = z.object({
formId: z.string().min(1),
nativeId: z.number().optional(),
type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1),
signerEmail: z.string().min(1).optional(),
pageNumber: z.number().min(1),
pageX: z.number().min(0),
pageY: z.number().min(0),

View 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 { Field, FieldType, Recipient } 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 {
DocumentFlowStep,
FRIENDLY_FIELD_TYPE,
} from '@documenso/ui/primitives/document-flow/types';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
// import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { 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[];
numberOfSteps: number;
onSubmit: (_data: TAddTemplateFieldsFormSchema) => void;
};
export const AddTemplateFieldsFormPartial = ({
documentFlow,
hideRecipients = false,
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.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)?.templateToken ?? '',
})),
},
});
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.templateToken ?? '',
});
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={documentFlow.stepIndex}
maxStep={numberOfSteps}
/>
<DocumentFlowFormContainerActions
loading={isSubmitting}
disabled={isSubmitting}
goNextLabel="Save Template"
onGoBackClick={() => {
documentFlow.onBackStep?.();
remove();
}}
onGoNextClick={() => void onFormSubmit()}
/>
</DocumentFlowFormContainerFooter>
</>
);
};

View File

@ -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>;

View File

@ -0,0 +1,205 @@
'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 { Controller, useFieldArray, useForm } from 'react-hook-form';
import { nanoid } from '@documenso/lib/universal/id';
import { 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 { DocumentFlowStep } from '../document-flow/types';
import {
TAddTemplatePlacholderRecipientsFormSchema,
ZAddTemplatePlacholderRecipientsFormSchema,
} from './add-template-placeholder-recipients.types';
export type AddTemplatePlaceholderRecipientsFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
fields: Field[];
numberOfSteps: number;
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
};
export const AddTemplatePlaceholderRecipientsFormPartial = ({
documentFlow,
numberOfSteps,
recipients,
fields: _fields,
onSubmit,
}: AddTemplatePlaceholderRecipientsFormProps) => {
const initialId = useId();
const [placeholderRecipientCount, setPlaceholderRecipientCount] = useState(1);
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: `John Doe`,
email: `johndoe@documenso.com`,
},
],
},
});
const onFormSubmit = handleSubmit(onSubmit);
const {
append: appendSigner,
fields: signers,
remove: removeSigner,
} = useFieldArray({
control,
name: 'signers',
});
const onAddPlaceholderRecipient = () => {
setPlaceholderRecipientCount(placeholderRecipientCount + 1);
appendSigner({
formId: nanoid(12),
name: `John Doe ${placeholderRecipientCount}`,
email: `johndoe${placeholderRecipientCount}@documenso.com`,
});
};
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>
<Controller
control={control}
name={`signers.${index}.email`}
render={({ field }) => (
<Input
id={`signer-${signer.id}-email`}
type="email"
className="bg-background mt-2"
disabled={isSubmitting}
onKeyDown={onKeyDown}
{...field}
/>
)}
/>
</div>
<div className="flex-1">
<Label htmlFor={`signer-${signer.id}-name`}>Name</Label>
<Controller
control={control}
name={`signers.${index}.name`}
render={({ field }) => (
<Input
id={`signer-${signer.id}-name`}
type="text"
className="bg-background mt-2"
disabled={isSubmitting}
onKeyDown={onKeyDown}
{...field}
/>
)}
/>
</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={documentFlow.stepIndex}
maxStep={numberOfSteps}
/>
<DocumentFlowFormContainerActions
loading={isSubmitting}
disabled={isSubmitting}
onGoBackClick={documentFlow.onBackStep}
onGoNextClick={() => void onFormSubmit()}
/>
</DocumentFlowFormContainerFooter>
</>
);
};

View File

@ -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().optional(),
}),
),
})
.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
>;