Merge pull request #8 from ElTimuro/doc-76-place-signature-fields

Doc 76 place signature fields
This commit is contained in:
Timur Ercan
2023-02-15 13:43:12 +01:00
committed by GitHub
10 changed files with 367 additions and 103 deletions

View File

@ -4,6 +4,10 @@
Documenso aims to be the world's most trusted document signing tool. This trust is built by empowering you to self-host Documenso and review how it works under the hood. Join us in creating the new internet of trust. Documenso aims to be the world's most trusted document signing tool. This trust is built by empowering you to self-host Documenso and review how it works under the hood. Join us in creating the new internet of trust.
## Tools
This repos uses 📝 https://gitmoji.dev/ for more expressive commit messages.
## todos ## todos
- Sendgrid setup and nodemailer-sendgrid SMTP hint (import for interchangeability like SMTP) - Sendgrid setup and nodemailer-sendgrid SMTP hint (import for interchangeability like SMTP)

View File

@ -1,8 +1,11 @@
import { ResizableBox, ResizeCallbackData } from "react-resizable"; import { ResizableBox, ResizeCallbackData } from "react-resizable";
import React, { SyntheticEvent, useEffect, useState } from "react"; import React, { SyntheticEvent, useEffect, useState } from "react";
import Draggable from "react-draggable"; import Draggable from "react-draggable";
import { CircleStackIcon } from "@heroicons/react/24/outline"; import { CircleStackIcon, TrashIcon } from "@heroicons/react/24/solid";
import Logo from "../logo"; import Logo from "../logo";
import { IconButton } from "@documenso/ui";
import toast from "react-hot-toast";
import { XCircleIcon } from "@heroicons/react/20/solid";
const stc = require("string-to-color"); const stc = require("string-to-color");
type FieldPropsType = { type FieldPropsType = {
@ -10,17 +13,21 @@ type FieldPropsType = {
color: string; color: string;
type: string; type: string;
position: any; position: any;
positionX: number;
positionY: number;
id: string; id: string;
recipient: string; recipient: string;
}; };
onPositionChangedHandler: any; onPositionChanged: any;
onDelete: any;
}; };
export default function Field(props: FieldPropsType) { export default function Field(props: FieldPropsType) {
const [field, setField]: any = useState(props.field); const [field, setField]: any = useState(props.field);
const [position, setPosition]: any = useState( const [position, setPosition]: any = useState({
props.field.position || { x: 0, y: -842 } x: props.field.positionX,
); y: props.field.positionY,
});
const nodeRef = React.createRef<HTMLDivElement>(); const nodeRef = React.createRef<HTMLDivElement>();
const onControlledDrag = (e: any, position: any) => { const onControlledDrag = (e: any, position: any) => {
const { x, y } = position; const { x, y } = position;
@ -31,7 +38,7 @@ export default function Field(props: FieldPropsType) {
if (!position) return; if (!position) return;
const { x, y } = position; const { x, y } = position;
props.onPositionChangedHandler({ x, y }, props.field.id); props.onPositionChanged({ x, y }, props.field.id);
}; };
return ( return (
@ -41,17 +48,29 @@ export default function Field(props: FieldPropsType) {
position={position} position={position}
onDrag={onControlledDrag} onDrag={onControlledDrag}
onStop={onDragStop} onStop={onDragStop}
defaultPosition={{ x: 0, y: 0 }}
cancel="strong"
> >
<div <div
ref={nodeRef} ref={nodeRef}
style={{ background: stc(props.field.recipient) }} style={{ background: stc(props.field.recipient) }}
className="cursor-move opacity-90 p-2 m-auto w-auto flex-row-reverse text-lg font-bold text-center absolute" className="cursor-move opacity-80 p-2 m-auto w-auto flex-row-reverse text-lg font-bold text-center absolute top-0 left-0"
> >
<div className="m-auto w-auto flex-row-reverse text-lg font-bold text-center"> <div className="m-auto w-auto flex-row-reverse text-lg font-bold text-center">
{/* todo icons */} {/* todo icons */}
Signature {field.type}
<div className="text-xs text-center">{props.field.recipient}</div> <div className="text-xs text-center">{props.field.recipient}</div>
</div> </div>
<strong>
<IconButton
className="absolute top-0 right-0 -m-5"
color="secondary"
icon={XCircleIcon}
onClick={(event: any) => {
props.onDelete(props.field.id);
}}
></IconButton>
</strong>
</div> </div>
</Draggable> </Draggable>
); );

View File

@ -1,19 +1,166 @@
import { NEXT_PUBLIC_WEBAPP_URL } from "@documenso/lib/constants"; import { NEXT_PUBLIC_WEBAPP_URL } from "@documenso/lib/constants";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import React from "react"; import React, { useState } from "react";
import { Button } from "@documenso/ui";
import short from "short-uuid";
import toast from "react-hot-toast";
import { FieldType } from "@prisma/client";
const stc = require("string-to-color");
const PDFViewer = dynamic(() => import("./pdf-viewer"), { const PDFViewer = dynamic(() => import("./pdf-viewer"), {
ssr: false, ssr: false,
}); });
export default function PDFEditor(props: any) { export default function PDFEditor(props: any) {
const [selectedValue, setSelectedValue] = useState("");
const [fields, setFields] = useState<any[]>(props.document.Field);
const router = useRouter(); const router = useRouter();
function onPositionChangedHandler(position: any, id: any) {
if (!position) return;
const movedField = fields.find((e) => e.id == id);
movedField.positionX = position.x;
movedField.positionY = position.y;
upsertField(props.document, movedField);
// no instant redraw neccessary, postion information for saving or later rerender is enough
// setFields(newFields);
}
function onDeleteHandler(id: any) {
const field = fields.find((e) => e.id == id);
const fieldIndex = fields.map((item) => item.id).indexOf(id);
console.log(fieldIndex);
if (fieldIndex > -1) {
const fieldWithoutRemoved = [...fields];
const removedField = fieldWithoutRemoved.splice(fieldIndex, 1);
setFields(fieldWithoutRemoved);
deleteField(field).catch((err) => {
setFields(fieldWithoutRemoved.concat(removedField));
});
}
}
return ( return (
<PDFViewer <>
document={props.document} <select
pdfUrl={`${NEXT_PUBLIC_WEBAPP_URL}/api/documents/${router.query.id}`} className="mb-3 inline mt-1 w-full rounded-md border-gray-300 py-2 pl-3 pr-10 text-base focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm"
/> style={{ background: stc(selectedValue) }}
value={selectedValue}
onChange={(e) => setSelectedValue(e.target.value)}
>
{props?.document?.Recipient?.map((item: any) => (
<option
key={item.email + short.generate().toString()}
style={{
background: stc(
item.name ? `${item.name} <${item.email}>` : item.email
),
}}
>
{item.name ? `${item.name} <${item.email}>` : item.email}
</option>
))}
</select>
<Button
className="inline ml-1"
onClick={() => {
const signatureField = {
id: -1,
page: 0,
type: FieldType.SIGNATURE,
positionX: 0,
positionY: 0,
recipient: selectedValue,
};
upsertField(props?.document, signatureField).then((res) => {
setFields(fields.concat(res));
});
}}
>
Add Signature Field
</Button>
<Button color="secondary" className="inline ml-1">
Add Date Field
</Button>
<Button color="secondary" className="inline ml-1">
Add Text Field
</Button>
<PDFViewer
document={props.document}
fields={fields}
onPositionChanged={onPositionChangedHandler}
onDelete={onDeleteHandler}
pdfUrl={`${NEXT_PUBLIC_WEBAPP_URL}/api/documents/${router.query.id}`}
/>
</>
); );
} }
async function upsertField(document: any, field: any): Promise<any> {
try {
const created = await toast.promise(
fetch("/api/documents/" + document.id + "/fields", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(field),
}).then((res) => {
if (!res.ok) {
throw new Error(res.status.toString());
}
return res.json();
}),
{
loading: "Saving...",
success: "Saved.",
error: "Could not save :/",
},
{
id: "saving field",
style: {
minWidth: "200px",
},
}
);
return created;
} catch (error) {}
}
async function deleteField(field: any) {
if (!field.id) {
return;
}
try {
const deleted = toast.promise(
fetch("/api/documents/" + 0 + "/fields/" + field.id, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(field),
}).then((res) => {
if (!res.ok) {
throw new Error(res.status.toString());
}
return res;
}),
{
loading: "Deleting...",
success: "Deleted.",
error: "Could not delete :/",
},
{
id: "delete",
style: {
minWidth: "200px",
},
}
);
return deleted;
} catch (error) {}
}

View File

@ -2,21 +2,18 @@ import { Fragment, useState } from "react";
import { Document, Page } from "react-pdf/dist/esm/entry.webpack5"; import { Document, Page } from "react-pdf/dist/esm/entry.webpack5";
import Field from "./field"; import Field from "./field";
import short from "short-uuid"; import short from "short-uuid";
import { Button } from "@documenso/ui";
const stc = require("string-to-color");
export default function PDFViewer(props) { export default function PDFViewer(props) {
const [file, setFile] = useState(""); const [file, setFile] = useState("");
const [selectedValue, setSelectedValue] = useState("");
const [numPages, setNumPages] = useState(null); const [numPages, setNumPages] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [fields, setFields] = useState([]);
function onPositionChangedHandler(position, id) { function onPositionChangedHandler(position, id) {
if (!position) return; props.onPositionChanged(position, id);
const newFields = [...fields]; }
fields.find((e) => e.id == id).position = position;
// setFields(newFields); function onDeleteHandler(id) {
props.onDelete(id);
} }
function onFileChange(event) { function onFileChange(event) {
@ -36,50 +33,7 @@ export default function PDFViewer(props) {
return ( return (
<> <>
<div hidden={loading}> <div hidden={loading}>
<div className="max-w-xs mt-6"> <div className="max-w-xs mt-6"></div>
<select
className="mb-3 inline mt-1 w-full rounded-md border-gray-300 py-2 pl-3 pr-10 text-base focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm"
style={{ background: stc(selectedValue) }}
defaultValue={props?.document?.Recipient[0]}
value={selectedValue}
selectedIndex={0}
onChange={(e) => setSelectedValue(e.target.value)}
>
{props?.document?.Recipient?.map((item) => (
<option
key={item.email + short.generate().toString()}
style={{
background: stc(
item.name ? `${item.name} <${item.email}>` : item.email
),
}}
>
{item.name ? `${item.name} <${item.email}>` : item.email}
</option>
))}
</select>
</div>
<Button
className="inline ml-1"
onClick={() => {
setFields(
fields.concat({
id: short.generate().toString(),
type: "signature",
position: { x: 0, y: -842 },
recipient: selectedValue,
})
);
}}
>
Add Signature Field
</Button>
<Button color="secondary" className="inline ml-1">
Add Date Field
</Button>
<Button color="secondary" className="inline ml-1">
Add Text Field
</Button>
<Document <Document
file={props.pdfUrl} file={props.pdfUrl}
onLoadSuccess={onDocumentLoadSuccess} onLoadSuccess={onDocumentLoadSuccess}
@ -93,37 +47,34 @@ export default function PDFViewer(props) {
position: "relative", position: "relative",
background: "green", background: "green",
}} }}
className="mx-auto w-fit"
> >
<div <Page
style={{ className="mt-5"
width: "100%", key={`page_${index + 1}`}
background: "red", pageNumber={index + 1}
}} renderAnnotationLayer={false}
> renderTextLayer={false}
<Page onLoadSuccess={() => setLoading(false)}
className="mt-5" onRenderError={() => setLoading(false)}
key={`page_${index + 1}`} ></Page>
pageNumber={index + 1} {props?.fields
renderAnnotationLayer={false} .filter((item) => item.page === index)
renderTextLayer={false} .map((item) => (
onLoadSuccess={() => setLoading(false)}
onRenderError={() => setLoading(false)}
></Page>
{fields.map((item) => (
<Field <Field
key={item.id} key={item.id}
field={item} field={item}
className="absolute" className="absolute"
onPositionChangedHandler={onPositionChangedHandler} onPositionChanged={onPositionChangedHandler}
onDelete={onDeleteHandler}
></Field> ></Field>
))} ))}
</div>
</div> </div>
</Fragment> </Fragment>
))} ))}
</Document> </Document>
</div> </div>
<div className="mt-10 w-[600px]" hidden={!loading}> <div className="mx-auto mt-10 w-[600px]" hidden={!loading}>
<div className="ph-item"> <div className="ph-item">
<div className="ph-col-12"> <div className="ph-col-12">
<div className="ph-picture"></div> <div className="ph-picture"></div>

View File

@ -0,0 +1,36 @@
import {
defaultHandler,
defaultResponder,
getUserFromToken,
} from "@documenso/lib/server";
import prisma from "@documenso/prisma";
import { NextApiRequest, NextApiResponse } from "next";
import short from "short-uuid";
import { Document as PrismaDocument, FieldType } from "@prisma/client";
import { getDocument } from "@documenso/lib/query";
async function deleteHandler(req: NextApiRequest, res: NextApiResponse) {
const user = await getUserFromToken(req, res);
const { fid: fieldId } = req.query;
const body: {
id: number;
type: FieldType;
page: number;
position: { x: number; y: number };
} = req.body;
if (!user) return;
if (!fieldId) {
res.status(400).send("Missing parameter fieldId.");
return;
}
await prisma.field.delete({ where: { id: +fieldId } });
return res.status(200).end();
}
export default defaultHandler({
DELETE: Promise.resolve({ default: defaultResponder(deleteHandler) }),
});

View File

@ -0,0 +1,88 @@
import {
defaultHandler,
defaultResponder,
getUserFromToken,
} from "@documenso/lib/server";
import prisma from "@documenso/prisma";
import { NextApiRequest, NextApiResponse } from "next";
import short from "short-uuid";
import { Document as PrismaDocument, FieldType } from "@prisma/client";
import { getDocument } from "@documenso/lib/query";
async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const user = await getUserFromToken(req, res);
const { id: documentId } = req.query;
const body: {
id: number;
type: FieldType;
page: number;
position: { x: number; y: number };
} = req.body;
if (!user) return;
if (!documentId) {
res.status(400).send("Missing parameter documentId.");
return;
}
// todo encapsulate entity ownerships checks
const fields = await prisma.field.findMany({
where: { documentId: +documentId },
});
return res.status(200).end(JSON.stringify(fields));
}
async function postHandler(req: NextApiRequest, res: NextApiResponse) {
const user = await getUserFromToken(req, res);
const { id: documentId } = req.query;
const body: {
id: number;
type: FieldType;
page: number;
positionX: number;
positionY: number;
} = req.body;
if (!user) return;
if (!documentId) {
res.status(400).send("Missing parameter documentId.");
return;
}
const document: PrismaDocument = await getDocument(+documentId, req, res);
// todo encapsulate entity ownerships checks
if (document.userId !== user.id) {
return res.status(401).send("User does not have access to this document.");
}
const field = await prisma.field.upsert({
where: {
id: +body.id,
},
update: {
type: body.type,
page: +body.page,
positionX: +body.positionX,
positionY: +body.positionY,
},
create: {
documentId: +documentId,
type: body.type,
page: +body.page,
positionX: +body.positionX,
positionY: +body.positionY,
},
});
return res.status(201).end(JSON.stringify(field));
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(getHandler) }),
POST: Promise.resolve({ default: defaultResponder(postHandler) }),
});

View File

@ -1,7 +1,6 @@
import { ReactElement, useState } from "react"; import { ReactElement } from "react";
import Layout from "../../../components/layout"; import Layout from "../../../components/layout";
import { NextPageWithLayout } from "../../_app"; import { NextPageWithLayout } from "../../_app";
import dynamic from "next/dynamic";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { NEXT_PUBLIC_WEBAPP_URL } from "@documenso/lib"; import { NEXT_PUBLIC_WEBAPP_URL } from "@documenso/lib";
import { getUserFromToken } from "@documenso/lib/server"; import { getUserFromToken } from "@documenso/lib/server";
@ -15,7 +14,6 @@ import {
import { getDocument } from "@documenso/lib/query"; import { getDocument } from "@documenso/lib/query";
import { Document as PrismaDocument } from "@prisma/client"; import { Document as PrismaDocument } from "@prisma/client";
import { Button, Breadcrumb } from "@documenso/ui"; import { Button, Breadcrumb } from "@documenso/ui";
import short from "short-uuid";
import PDFEditor from "../../../components/editor/pdf-editor"; import PDFEditor from "../../../components/editor/pdf-editor";
const DocumentsDetailPage: NextPageWithLayout = (props: any) => { const DocumentsDetailPage: NextPageWithLayout = (props: any) => {
@ -116,26 +114,26 @@ export async function getServerSideProps(context: any) {
const { id: documentId } = context.query; const { id: documentId } = context.query;
const document: PrismaDocument = await getDocument( try {
+documentId, const document: PrismaDocument = await getDocument(
context.req, +documentId,
context.res context.req,
); context.res
);
// todo optimize querys // todo optimize querys
// todo no intersection groups // todo no intersection groups
if (!document) { return {
props: {
document: document,
},
};
} catch (error) {
return { return {
notFound: true, notFound: true,
}; };
} }
return {
props: {
document: document,
},
};
} }
DocumentsDetailPage.getLayout = function getLayout(page: ReactElement) { DocumentsDetailPage.getLayout = function getLayout(page: ReactElement) {

View File

@ -12,13 +12,14 @@ export const getDocument = async (
if (!documentId) Promise.reject("No documentId"); if (!documentId) Promise.reject("No documentId");
if (!req || !res) Promise.reject("No res or req"); if (!req || !res) Promise.reject("No res or req");
const document: PrismaDocument = await prisma.document.findFirst({ const document: PrismaDocument = await prisma.document.findFirstOrThrow({
where: { where: {
id: documentId, id: documentId,
userId: user.id, userId: user.id,
}, },
include: { include: {
Recipient: true, Recipient: true,
Field: true,
}, },
}); });

View File

@ -17,5 +17,5 @@ export const getDocumentsForUserFromToken = async (
}, },
}); });
return documents; return documents.map((e) => ({ ...e, document: "" }));
}; };

View File

@ -15,6 +15,19 @@ model Document {
status DocumentStatus @default(DRAFT) status DocumentStatus @default(DRAFT)
document String document String
Recipient Recipient[] Recipient Recipient[]
Field Field[]
}
model Field {
id Int @id @default(autoincrement())
documentId Int
recipientId Int?
type FieldType
page Int
positionX Int @default(0)
positionY Int @default(0)
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
Recipient Recipient? @relation(fields: [recipientId], references: [id], onDelete: Cascade)
} }
model Recipient { model Recipient {
@ -27,6 +40,7 @@ model Recipient {
signingStatus SigningStatus @default(NOT_SIGNED) signingStatus SigningStatus @default(NOT_SIGNED)
sendStatus SendStatus @default(NOT_SENT) sendStatus SendStatus @default(NOT_SENT)
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
Field Field[]
} }
model User { model User {
@ -68,6 +82,12 @@ model Session {
user User? @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
enum FieldType {
SIGNATURE
DATE
TEXT
}
enum IdentityProvider { enum IdentityProvider {
DOCUMENSO DOCUMENSO
GOOGLE GOOGLE