Files
OpenSign/apps/OpenSign/src/components/pdf/PrefillWidgetsModal.jsx
T
2026-01-07 09:40:36 +00:00

867 lines
30 KiB
React

import React, { forwardRef, useEffect, useMemo, useRef, useState } from "react";
import ModalUi from "../../primitives/ModalUi";
import {
getMonth,
getYear,
radioButtonWidget,
compressedFileSize,
textWidget,
months,
changeDateToMomentFormat,
convertBase64ToFile,
generatePdfName,
drawWidget,
getBase64MimeType,
isBase64
} from "../../constant/Utils";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import { range } from "pdf-lib";
import { useTranslation } from "react-i18next";
import moment from "moment";
import AsyncSelect from "react-select/async";
import axios from "axios";
import AddContact from "../../primitives/AddContact";
import Loader from "../../primitives/Loader";
import { useDispatch, useSelector } from "react-redux";
import {
resetWidgetState,
setPrefillImg
} from "../../redux/reducers/widgetSlice";
import * as utils from "../../utils";
import Draw from "./tab/Draw";
import PenColorComponent from "./tab/PenColorComponent";
const widgetTitle = "font-medium";
const ShowTextWidget = ({ position, handleWidgetDetails }) => {
const inputRef = useRef(null);
const [inputValue, setInputValue] = useState(position.options.response || "");
return (
<input
ref={inputRef}
rows={1}
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
handleWidgetDetails(position, e.target.value);
}}
className="op-input op-input-bordered op-input-sm focus:outline-none hover:border-base-content w-full text-xs"
/>
);
};
const ImageComponent = (props) => {
const { t } = useTranslation();
const prefillImg = useSelector((state) => state.widget.prefillImg);
const imageRefs = useRef({});
let imgUrl = "";
const isBase64Url = isBase64(props?.position?.SignUrl);
if (isBase64Url) {
imgUrl = props?.position?.SignUrl;
} else {
const getPrefillImg = prefillImg?.find(
(x) => x.id === props?.position?.key
);
imgUrl = getPrefillImg?.base64;
}
return (
<>
<span className={widgetTitle}>{props?.position.options?.name}</span>
{imgUrl ? (
<>
<div className="cursor-pointer op-card border-[1px] border-gray-400 flex flex-col w-full h-full justify-center items-center ">
<img
alt="print img"
ref={(el) => (imageRefs.current[props?.id] = el)} // Assign ref dynamicallys
src={imgUrl}
draggable="false"
className="object-contain h-full w-full aspect-[5/2]"
onLoad={() => props?.handleImageLoaded?.(props?.position.key)}
onError={() => props?.handleImageLoaded?.(props?.position.key)}
/>
</div>
<span
onClick={() => props?.handleClearImage(props?.position)}
className="flex justify-start text-blue-500 underline cursor-pointer"
>
{t("clear")}
</span>
</>
) : (
<div
className="cursor-pointer op-card border-[1px] op-border-hover flex flex-col overflow-hidden w-full h-full aspect-[5/2] justify-center items-center"
onClick={() => imageRefs.current[props?.id]?.click()}
>
<input
type="file"
onChange={(e) => props?.onImageChange?.(e, props?.position)}
className="filetype"
accept="image/png,image/jpeg"
ref={(el) => (imageRefs.current[props?.id] = el)} // Assign ref dynamically
hidden
/>
<i className="fa-light text-base-content fa-cloud-upload-alt text-[25px]"></i>
<div className="text-[10px] text-base-content">{t("upload")}</div>
</div>
)}
</>
);
};
function PrefillWidgetModal(props) {
const { t } = useTranslation();
const dispatch = useDispatch();
const canvasRefs = useRef({});
const [penColor, setPenColor] = useState("blue");
// Track already loaded image keys so they don't increment multiple times
const loadedSet = useRef(new Set());
const initializedRef = useRef(false); // prevent rerun on state updates
const [image, setImage] = useState(null);
const [currentWidget, setCurrentWidget] = useState("");
const [userList, setUserList] = useState([]);
const [totalImages, setTotalImages] = useState(0);
const [loadedImages, setLoadedImages] = useState(0);
const [loading, setLoading] = useState(false);
const years = range(1950, getYear(new Date()) + 16, 1);
// useMemo to memoize the calculation of unique widgets
const uniqueWidget = useMemo(() => {
//functions to used remove duplicate name values across all pages
if (!props.prefillData) return [];
//This will help us track which name values have already been encountered across all pages.
const uniqueNames = new Set();
//Filter and flatten placeholder widgets while keeping unique names
const filteredArray = props.prefillData?.placeHolder?.map((item) => ({
...item,
pos: item.pos.filter((curr) => {
if (uniqueNames.has(curr?.options?.name)) return false; //Duplicate name found, remove it
uniqueNames.add(curr?.options?.name); //First time seen, add to set
return true;
})
}));
//latten the filtered array and exclude read-only widgets
const flatArray = filteredArray?.flatMap((page) =>
page.pos
.filter((widget) => !widget.options?.isReadOnly)
.map((widget) => ({
widget,
pageNumber: page.pageNumber
}))
);
return flatArray || [];
}, [props.prefillData]);
useEffect(() => {
dispatch(resetWidgetState([]));
}, []);
// Reset loader state when modal closes
useEffect(() => {
if (!props?.isPrefillModal) {
initializedRef.current = false;
setTotalImages(0);
setLoadedImages(0);
setLoading(false);
}
}, [props?.isPrefillModal]);
useEffect(() => {
setLoading(true);
//function is used to save all image base64 in redux state to display prefill images
const savePrefillImg = async () => {
const prefillImg = await utils?.savePrefillImg(props.xyPosition);
if (Array.isArray(prefillImg)) {
prefillImg.forEach((img) => dispatch(setPrefillImg(img)));
}
setLoading(false);
};
savePrefillImg();
}, [props.xyPosition]);
useEffect(() => {
if (totalImages > 0 && loadedImages === totalImages) {
setLoading(false);
}
}, [loadedImages, totalImages]);
useEffect(() => {
if (image?.src) {
handleWidgetDetails(currentWidget);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [image]);
// Run only once per modal open
useEffect(() => {
if (props?.isPrefillModal && !initializedRef.current) {
const getImgWidgets = uniqueWidget?.filter(
(w) => w.widget?.type === "image" && w.widget?.options?.response
);
if (getImgWidgets?.length > 0) {
const imgCount = getImgWidgets.length;
setTotalImages(imgCount);
setLoadedImages(0);
setLoading(true);
initializedRef.current = true; // mark as initialized
}
}
}, [props?.isPrefillModal, uniqueWidget]);
// The getDatePickerDate function retrieves the date in the correct format supported by the DatePicker.
const getDatePickerDate = (selectedDate, format = "dd-MM-yyyy") => {
let date;
if (format && format === "dd-MM-yyyy") {
const [day, month, year] = selectedDate?.split("-");
date = new Date(`${year}-${month}-${day}`);
} else if (format && format === "dd.MM.yyyy") {
const [day, month, year] = selectedDate?.split(".");
date = new Date(`${year}.${month}.${day}`);
} else if (format && format === "dd/MM/yyyy") {
const [day, month, year] = selectedDate?.split("/");
date = new Date(`${year}/${month}/${day}`);
} else {
date = new Date(selectedDate);
}
return date;
};
const ExampleCustomInput = forwardRef(({ value, onClick }, ref) => (
<div
style={{ fontFamily: "Arial, sans-serif" }}
className="op-input op-input-bordered op-input-sm focus:outline-none hover:border-base-content w-full"
onClick={onClick}
ref={ref}
>
{value}
<i className="fa-light fa-calendar ml-[5px]"></i>
</div>
));
ExampleCustomInput.displayName = "ExampleCustomInput";
const handleDate = (position) => {
// The getDatePickerDate function retrieves the date in the correct format supported by the DatePicker.
const getDate = getDatePickerDate(
position?.options.response || new Date(),
position?.options?.validation?.format
);
return getDate;
};
//function to set date with required date format onchange date
const handleOnDateChange = (date, position) => {
const format = position?.options?.validation?.format || "MM/dd/yyyy";
let updateDate = date;
let newDate;
const isSpecialDateFormat =
format && ["dd-MM-yyyy", "dd.MM.yyyy", "dd/MM/yyyy"].includes(format);
if (isSpecialDateFormat) {
newDate = moment(updateDate).format(changeDateToMomentFormat(format));
} else {
//using moment package is used to change date as per the format provided in selectDate obj e.g. - MM/dd/yyyy -> 03/12/2024
newDate = new Date(updateDate);
newDate = moment(newDate.getTime()).format(
changeDateToMomentFormat(format)
);
}
handleWidgetDetails(position, newDate);
};
const handleSavePrefillImg = async (base64) => {
setLoading(true);
try {
const imageName = generatePdfName(16);
const imgType = image?.imgType || getBase64MimeType(base64);
const imageUrl = await convertBase64ToFile(
imageName,
base64 || image.src,
imgType
);
if (imageUrl) {
return imageUrl;
}
} catch (e) {
console.log("error in handleSavePrefillImg function ", e);
}
};
//function is used to handle prefill widgets details and check if there are any duplicate widget name field exist then update all duplicate value
const handleWidgetDetails = async (widgetDetails, response) => {
const widgetName = widgetDetails?.options?.name;
const getPrefill = props.xyPosition.find((x) => x?.Role === "prefill");
const getPlaceholder = getPrefill?.placeHolder;
let imgUrl;
if (widgetDetails?.type === "image") {
imgUrl = await handleSavePrefillImg(response);
}
const updatedData = getPlaceholder.map((page) => ({
...page,
pos: page.pos.map((item) => {
if (item.options.name === widgetName) {
if (widgetDetails?.type === "image") {
return {
...item,
SignUrl: imgUrl,
ImageType: image.imgType,
options: { ...item.options, response: imgUrl }
};
} else {
return {
...item,
options: { ...item.options, response: response }
};
}
} else {
return item;
}
})
}));
const newUpdateSigner = props.xyPosition.map((obj) => {
if (obj.Role === "prefill") {
return { ...obj, placeHolder: updatedData };
}
return obj;
});
props.setXyPosition(newUpdateSigner);
};
//function for set checked and unchecked value of checkbox
const handleCheckboxValue = (isChecked, ind, position) => {
let updateSelectedCheckbox = [];
updateSelectedCheckbox =
position.options?.defaultValue || position.options?.response || [];
if (isChecked) {
updateSelectedCheckbox.push(ind);
} else {
updateSelectedCheckbox = updateSelectedCheckbox.filter(
(data) => data !== ind
);
}
handleWidgetDetails(position, updateSelectedCheckbox);
};
//function for image upload or update
const onImageChange = (event, position) => {
if (event.target.files && event.target.files[0]) {
const file = event.target.files[0];
compressedFileSize(file, setImage);
setCurrentWidget(position);
}
};
const handleRadioCheck = (data, position) => {
const res = position?.options?.response;
const defaultCheck = position?.options?.defaultValue;
if (res === data || defaultCheck === data) {
return true;
} else {
return false;
}
};
//function for show checked checkbox
const selectCheckbox = (ind, position) => {
const res = position?.options?.response;
const defaultCheck = position?.options?.defaultValue;
if (res && res?.length > 0) {
const isSelectIndex = res.indexOf(ind);
if (isSelectIndex > -1) {
return true;
} else {
return false;
}
} else if (defaultCheck) {
const isSelectIndex = defaultCheck.indexOf(ind);
if (isSelectIndex > -1) {
return true;
} else {
return false;
}
} else {
return false;
}
};
const handleClearImage = (position) => {
let prefillPlaceholder = props?.xyPosition.filter(
(data) => data?.Role === "prefill"
);
const updatedArray = prefillPlaceholder[0]?.placeHolder?.map((page) => ({
...page,
pos: page.pos.map((item) => {
if (item.options.name === position.options.name) {
return {
...item,
...(item.SignUrl !== undefined && { SignUrl: "" }),
options: {
...position.options,
response: ""
}
};
}
return item;
})
}));
const newUpdateSigner = props.xyPosition.map((obj) => {
if (obj.Role === "prefill") {
return { ...obj, placeHolder: updatedArray };
}
return obj;
});
props.setXyPosition(newUpdateSigner);
};
const handleImageLoaded = (key) => {
// Prevent counting the same image multiple times.
// If this image (key) has not already been marked as loaded...
if (!loadedSet.current.has(key)) {
// Mark this image as loaded by adding its key to the Set
loadedSet.current.add(key);
// Increment the loadedImages state by 1
// (tracks how many images have finished loading)
setLoadedImages((prev) => prev + 1);
}
setLoading(false);
};
//function for set signature url
const handleSignatureChange = (drawImage, position) => {
handleWidgetDetails(position, drawImage);
};
const getCanvasRef = (widgetId) => {
if (!canvasRefs.current[widgetId]) {
canvasRefs.current[widgetId] = React.createRef();
}
return canvasRefs.current[widgetId];
};
const clearCanvasById = (widgetId) => {
const canvasRef = canvasRefs.current?.[widgetId];
if (canvasRef?.current) {
canvasRef.current.clear();
}
};
const handleWidgetType = (position, id) => {
switch (position?.type) {
case "checkbox":
return (
<>
<span className={widgetTitle}>{position.options?.name}</span>
<div className="flex flex-col gap-y-1">
{position.options?.values?.map((data, ind) => (
<div
key={ind}
className="select-none-cls flex items-center text-center gap-0.5"
>
<input
id={`checkbox-${position.key + ind}`}
className="mt-[2px] op-checkbox op-checkbox-xs"
type="checkbox"
checked={selectCheckbox(ind, position)}
onChange={(e) =>
handleCheckboxValue(e.target.checked, ind, position)
}
/>
<label
htmlFor={`checkbox-${position.key + ind}`}
className="text-xs mb-0 text-center ml-[3px] cursor-pointer"
>
{data}
</label>
</div>
))}
</div>
</>
);
case textWidget:
return (
<>
<span className={widgetTitle}>{position.options?.name}</span>
<ShowTextWidget
position={position}
handleWidgetDetails={handleWidgetDetails}
/>
</>
);
case "dropdown":
return (
<>
<span className={widgetTitle}>{position.options?.name}</span>
<select
className="op-select op-select-bordered op-select-sm focus:outline-none hover:border-base-content text-base-content w-full"
id="myDropdown"
value={
position?.options?.response || position?.options?.defaultValue
}
onChange={(e) => handleWidgetDetails(position, e.target.value)}
>
{/* Default/Title option */}
<option value="" disabled hidden>
{t("choose-one")}
</option>
{position?.options?.values?.map((data, ind) => (
<option key={ind} value={data}>
{data}
</option>
))}
</select>
</>
);
case "date":
return (
<>
<span className={widgetTitle}>{position.options?.name}</span>
<DatePicker
portalId="datepicker-portal-root"
renderCustomHeader={({ date, changeYear, changeMonth }) => (
<div className="flex justify-start ml-2">
<select
className="bg-transparent outline-none"
value={months[getMonth(date)]}
onChange={({ target: { value } }) =>
changeMonth(months.indexOf(value))
}
>
{months.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
<select
className="bg-transparent outline-none"
value={getYear(date)}
onChange={({ target: { value } }) => changeYear(value)}
>
{years.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
)}
closeOnScroll={true}
selected={handleDate(position)}
onChange={(date) => handleOnDateChange(date, position)}
customInput={<ExampleCustomInput />}
dateFormat={position?.options?.validation?.format || "MM/dd/yyyy"}
/>
</>
);
case "image":
return (
<ImageComponent
position={position}
onImageChange={onImageChange}
handleImageLoaded={handleImageLoaded}
handleClearImage={handleClearImage}
id={id}
/>
);
case radioButtonWidget:
return (
<>
<span className={widgetTitle}>{position.options?.name}</span>
<div className="flex flex-col gap-y-1">
{position.options?.values.map((data, ind) => (
<div
key={ind}
className="select-none-cls flex items-center text-center gap-0.5"
>
<input
id={`radio-${position.key + ind}`}
className="mt-[2px] op-radio op-radio-xs"
type="radio"
checked={handleRadioCheck(data, position)}
onChange={() => handleWidgetDetails(position, data)}
/>
<label
htmlFor={`radio-${position.key + ind}`}
className="text-xs mb-0 ml-[2px] cursor-pointer"
>
{data}
</label>
</div>
))}
</div>
</>
);
case drawWidget:
return (
<div>
<span className={widgetTitle}>{position.options?.name}</span>
<Draw
penColor={penColor}
canvasRef={getCanvasRef(position.key)}
currWidgetsDetails={position}
handleSignatureChange={handleSignatureChange}
prefillCls={"prefillCanvas"}
/>
<div className="flex flex-row justify-between mt-[10px]">
<PenColorComponent
penColor={penColor}
setPenColor={setPenColor}
/>
<span
onClick={() => {
clearCanvasById(position?.key);
handleClearImage(position);
}}
className="flex justify-start text-blue-500 underline cursor-pointer"
>
{t("clear")}
</span>
</div>
</div>
);
default:
return position?.SignUrl ? (
<div className="pointer-events-none">
<img
alt="image"
draggable="false"
src={position?.SignUrl}
className="w-full h-full"
/>
</div>
) : (
<div className="op-input op-input-bordered op-input-sm focus:outline-none hover:border-base-content w-full">
No widget
</div>
);
}
};
const handleEmbedPrefill = async (item) => {
//checking is there any draw widget response have base64 url then generate that base64 to url and save it
const prefillWidgets = props.xyPosition?.find((x) => x.Role === "prefill");
let updatedXyPosition = [];
if (prefillWidgets) {
const isDrawWidget = (prefillWidgets.placeHolder ?? []).some((ph) =>
(ph?.pos ?? []).some((p) => p?.type === drawWidget)
);
if (isDrawWidget) {
const getPrefill = props.xyPosition.find((x) => x?.Role === "prefill");
const getPlaceholder = getPrefill?.placeHolder ?? [];
const updatedData = await Promise.all(
getPlaceholder.map(async (page) => ({
...page,
pos: await Promise.all(
page.pos.map(async (item) => {
if (item?.type === drawWidget) {
const widgetResponse = item?.options?.response;
// Skip if no response
if (!widgetResponse) return item;
// If already URL, do not re-upload
if (
typeof widgetResponse === "string" &&
widgetResponse.startsWith("http")
) {
return item;
}
// Convert THIS widget's base64 → URL
const drawUrl = await handleSavePrefillImg(widgetResponse);
return {
...item,
options: {
...item.options,
response: drawUrl
}
};
}
return item;
})
)
}))
);
updatedXyPosition = props.xyPosition.map((obj) => {
if (obj.Role === "prefill") {
return {
...obj,
placeHolder: updatedData
};
}
return obj;
});
props.setXyPosition(updatedXyPosition);
}
}
await props.handleCreateDocument();
};
//`loadOptions` function to use show all list of signer in dropdown
const loadOptions = async (inputValue) => {
try {
const baseURL = localStorage.getItem("baseUrl");
const url = `${baseURL}functions/getsigners`;
const token =
{ "X-Parse-Session-Token": localStorage.getItem("accesstoken") };
const headers = {
"Content-Type": "application/json",
"X-Parse-Application-Id": localStorage.getItem("parseAppId"),
...token
};
const search = inputValue;
const axiosRes = await axios.post(url, { search }, { headers });
const contactRes = axiosRes?.data?.result || [];
if (contactRes) {
const res = JSON.parse(JSON.stringify(contactRes));
const result = res;
setUserList(result);
return await result.map((item) => ({
label: `${item.Name}<${item.Email}>`,
value: item.objectId
}));
}
} catch (error) {
console.log("err", error);
}
};
//`handleInputChange` function to get signers list from dropdown
const handleInputChange = (item, id) => {
const signerExist = props.forms.some((x) => x.label === item.label);
if (signerExist) {
alert(t("already-exist-signer"));
} else {
let newForm = [...props.forms];
let signerId = newForm[id].value;
newForm[id].label = item?.label;
// newForm[id].value = item?.value;
props.setForms(newForm);
const getSigner = userList.find((x) => x.objectId === item.value);
props.handleAddUser(getSigner, signerId);
}
};
//show modal to create new contact
const handleCreateNew = (e, id) => {
e.preventDefault();
props.setIsNewContact({ status: true, id: id });
};
const closePopup = () => {
props.setIsNewContact({ status: false, id: "" });
};
return (
<>
<ModalUi
title={uniqueWidget?.length > 0 ? t("prefill-widget") : "Recipients"}
isOpen={true}
handleClose={props.handleClosePrefillModal}
>
<div className="relative">
{(props?.isSubmit || loading) && (
<div className="absolute inset-0 flex items-center justify-center bg-white/70 z-[9999]">
<Loader />
</div>
)}
{uniqueWidget?.length > 0 && (
<div className="py-3 px-[10px] op-card border-[1px] border-gray-400 m-3 md:m-6 text-base-content flex flex-col relative">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-10 gap-y-4 w-full">
{uniqueWidget.map((x, id) => (
<div key={id} className="flex flex-col gap-2 w-full">
{handleWidgetType(x.widget, id)}
</div>
))}
</div>
</div>
)}
<div>
{props.forms.length > 0 && (
<div className="overflow-y-auto m-3">
{uniqueWidget?.length > 0 && (
<h1 className="font-medium text-[15px] mb-2">
{t("recipients")}
</h1>
)}
<div className="py-3 px-[10px] op-card border-[1px] border-gray-400 md:mx-3 text-base-content flex flex-col relative">
{props.forms?.map((field, id) => {
return (
<div className="flex flex-col" key={field?.value}>
<label>{field?.role}</label>
<div className="flex justify-between items-center gap-1">
<div className="flex-1">
<AsyncSelect
cacheOptions
defaultOptions
value={field}
loadingMessage={() => t("loading")}
noOptionsMessage={() => t("contact-not-found")}
loadOptions={loadOptions}
onChange={(item) => handleInputChange(item, id)}
unstyled
onFocus={() => loadOptions()}
classNames={{
control: () =>
"op-input op-input-bordered op-input-sm focus:outline-none hover:border-base-content w-full h-full text-[11px]",
valueContainer: () =>
"flex flex-row gap-x-[2px] gap-y-[2px] md:gap-y-0 w-full my-[2px]",
multiValue: () =>
"op-badge op-badge-primary h-full text-[11px]",
multiValueLabel: () => "mb-[2px]",
menu: () =>
"mt-1 shadow-md rounded-lg bg-base-200 text-base-content absolute z-9999",
menuList: () => "shadow-md rounded-lg",
option: () =>
"bg-base-200 text-base-content rounded-lg m-1 hover:bg-base-300 p-2",
noOptionsMessage: () =>
"p-2 bg-base-200 rounded-lg m-1 p-2"
}}
menuPortalTarget={document.getElementById(
"selectSignerModal"
)}
/>
</div>
<button
onClick={(e) => handleCreateNew(e, field.value)}
className="op-btn op-btn-accent op-btn-outline op-btn-sm"
>
<i className="fa-light fa-plus"></i>
</button>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
<div className="flex gap-2 mx-4 mb-3">
<button
disabled={props?.isSubmit}
className="op-btn op-btn-primary op-btn-sm w-[80px]"
onClick={() => handleEmbedPrefill(props?.item)}
>
<span>{t("next")}</span>
</button>
<button
className="op-btn op-btn-ghost op-btn-sm"
onClick={() => props.navigatePageToDoc()}
>
<span>{t("edit-draft")}</span>
</button>
</div>
</div>
</ModalUi>
<ModalUi
title={t("add-contact")}
isOpen={props.isNewContact.status}
handleClose={closePopup}
>
<AddContact
isDisableTitle
isAddYourSelfCheckbox
details={props.handleAddUser}
closePopup={closePopup}
newContactId={props.isNewContact.id}
/>
</ModalUi>
</>
);
}
export default PrefillWidgetModal;