diff --git a/client/src/components/Projects/ProjectContextMenu.jsx b/client/src/components/Projects/ProjectContextMenu.jsx
index 525b3870..f62770b4 100644
--- a/client/src/components/Projects/ProjectContextMenu.jsx
+++ b/client/src/components/Projects/ProjectContextMenu.jsx
@@ -12,7 +12,8 @@ import {
MdDelete,
MdRestoreFromTrash,
MdFileCopy,
- MdEdit
+ MdEdit,
+ MdOutlineIosShare
} from "react-icons/md";
const useStyles = createUseStyles({
@@ -49,6 +50,7 @@ const ProjectContextMenu = ({
handleDeleteModalOpen,
handleSnapshotModalOpen,
handleRenameSnapshotModalOpen,
+ handleShareSnapshotModalOpen,
handlePrintPdf,
handleHide
}) => {
@@ -86,6 +88,19 @@ const ProjectContextMenu = ({
) : null}
+ {project.dateSnapshotted && project.loginId == account?.id ? (
+
handleClick(handleShareSnapshotModalOpen)}
+ >
+
+ Share Snapshot
+
+ ) : null}
+
{!project.dateSnapshotted && project.loginId == account?.id ? (
)}
@@ -361,6 +363,7 @@ ProjectTableRow.propTypes = {
handleDeleteModalOpen: PropTypes.func.isRequired,
handleSnapshotModalOpen: PropTypes.func.isRequired,
handleRenameSnapshotModalOpen: PropTypes.func.isRequired,
+ handleShareSnapshotModalOpen: PropTypes.func.isRequired,
handleHide: PropTypes.func.isRequired,
handleCheckboxChange: PropTypes.func.isRequired,
checkedProjectIds: PropTypes.arrayOf(PropTypes.number).isRequired,
diff --git a/client/src/components/Projects/ProjectsPage.jsx b/client/src/components/Projects/ProjectsPage.jsx
index b8995c56..31b915f2 100644
--- a/client/src/components/Projects/ProjectsPage.jsx
+++ b/client/src/components/Projects/ProjectsPage.jsx
@@ -15,6 +15,7 @@ import * as projectService from "../../services/project.service";
import * as droService from "../../services/dro.service";
import SnapshotProjectModal from "./SnapshotProjectModal";
import RenameSnapshotModal from "./RenameSnapshotModal";
+import ShareSnapshotModal from "./ShareSnapshotModal";
import DeleteProjectModal from "./DeleteProjectModal";
import CopyProjectModal from "./CopyProjectModal";
@@ -160,6 +161,7 @@ const ProjectsPage = ({ contentContainerRef }) => {
const [renameSnapshotModalOpen, setRenameSnapshotModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [csvModalOpen, setCsvModalOpen] = useState(false);
+ const [shareSnapshotModalOpen, setShareSnapshotModalOpen] = useState(false);
const [selectedProject, setSelectedProject] = useState(null);
const [checkedProjectIds, setCheckedProjectIds] = useState([]);
const [selectAllChecked, setSelectAllChecked] = useState(false);
@@ -445,6 +447,15 @@ const ProjectsPage = ({ contentContainerRef }) => {
setRenameSnapshotModalOpen(false);
};
+ const handleShareSnapshotModalOpen = project => {
+ setSelectedProject(project);
+ setShareSnapshotModalOpen(true);
+ };
+
+ const handleShareSnapshotModalClose = async () => {
+ setShareSnapshotModalOpen(false);
+ };
+
const handleHide = async project => {
try {
if (!checkedProjectIds.length) {
@@ -941,6 +952,9 @@ const ProjectsPage = ({ contentContainerRef }) => {
handleRenameSnapshotModalOpen={
handleRenameSnapshotModalOpen
}
+ handleShareSnapshotModalOpen={
+ handleShareSnapshotModalOpen
+ }
handleHide={handleHide}
handleCheckboxChange={handleCheckboxChange}
checkedProjectIds={checkedProjectIds}
@@ -1017,6 +1031,11 @@ const ProjectsPage = ({ contentContainerRef }) => {
onClose={handleRenameSnapshotModalClose}
selectedProjectName={selectedProjectName}
/>
+
>
)}
diff --git a/client/src/components/Projects/ShareSnapshotModal.jsx b/client/src/components/Projects/ShareSnapshotModal.jsx
new file mode 100644
index 00000000..41f88b9c
--- /dev/null
+++ b/client/src/components/Projects/ShareSnapshotModal.jsx
@@ -0,0 +1,406 @@
+import React, { useState, useEffect } from "react";
+import PropTypes from "prop-types";
+import { createUseStyles, useTheme } from "react-jss";
+import Button from "../Button/Button";
+import ModalDialog from "../UI/AriaModal/ModalDialog";
+import clsx from "clsx";
+import * as projectShareService from "../../services/projectShare.service";
+
+const useStyles = createUseStyles(theme => ({
+ buttonFlexBox: {
+ display: "flex",
+ flexDirection: "row",
+ padding: "1em"
+ },
+ heading1: theme.typography.heading1,
+ heading2: theme.typography.heading2,
+ buttonColor: {
+ backgroundColor: theme.colors.secondary.lightGray
+ },
+ buttonDisabled: {
+ cursor: "default"
+ },
+ modal: {
+ width: "40em",
+ margin: "0 auto"
+ },
+ input: {
+ padding: "0 5em",
+ margin: "1.5rem 2.5rem 1.5rem 0.75rem"
+ },
+ emailList: {
+ height: "15em",
+ overflowY: "scroll",
+ lineHeight: "3em",
+ padding: "10px"
+ },
+ emptyList: {
+ display: "flex",
+ height: "15em",
+ border: "solid black 2px",
+ margin: "1em",
+ alignItems: "center",
+ fontSize: "18px",
+ fontWeight: "bold",
+ padding: "0em 2em"
+ },
+ viewPermissionsList: {
+ padding: "0em 4em"
+ },
+ email: {
+ display: "flex",
+ flexDirection: "row",
+ justifyContent: "space-between",
+ padding: "0px 12px",
+ fontSize: "18px",
+ "&:hover": {
+ backgroundColor: "#f2f2f2"
+ }
+ },
+ copyMessageBox: {
+ border: "solid 2px",
+ margin: "1em",
+ borderRadius: "8px",
+ display: "flex",
+ flexDirection: "column"
+ },
+ copyButton: {
+ backgroundColor: "darkBlue",
+ color: "white",
+ width: "100%",
+ margin: "0",
+ borderRadius: "0 0 6px 6px",
+ border: "none",
+ fontSize: "20px"
+ },
+ remove: {
+ border: "none",
+ background: "none",
+ color: "red",
+ fontSize: "18px",
+ fontWeight: "normal",
+ cursor: "pointer"
+ },
+ lineBreak: {
+ textAlign: "center",
+ borderBottom: "1px solid #000",
+ lineHeight: "0em",
+ margin: "10px 0 20px",
+ color: "black"
+ },
+ popupMessage: {
+ position: "absolute",
+ bottom: "-10%",
+ borderRadius: "10px",
+ padding: "14px",
+ fontWeight: "bold",
+ fontSize: "18px",
+ border: "1px solid #d8dce3",
+ boxSizing: "border-box",
+ boxShadow: "0px 5px 10px rgba(0, 46, 109, 0.5)"
+ }
+}));
+
+export default function ShareSnapshotModal({ mounted, onClose, project }) {
+ const theme = useTheme();
+ const classes = useStyles({ theme });
+ const [page, setPage] = useState(1);
+ const [sharedEmails, setSharedEmails] = useState([]);
+ const [selectedEmail, setSelectedEmail] = useState(null);
+ const [isCopied, setIsCopied] = useState(false);
+ const maybeDisabled = !sharedEmails.length && classes.buttonDisabled;
+ const tdmLink = "https://tdm-dev.azurewebsites.net";
+ const copyLink = `${tdmLink}/projects/${project ? project.id : -1}`;
+ const copyMessage = `Here's a snapshot of the current TDM Calculator plan for: [${
+ project ? project.name : ""
+ }](${copyLink}). \
+If you don't already have a [TDM Calculator](${tdmLink}) account, please set one up to see the above snapshot link.`;
+
+ const fetchProjectShareList = async () => {
+ const response = await projectShareService.getByProjectId(project.id);
+ setSharedEmails(response.data);
+ };
+
+ useEffect(() => {
+ if (project) {
+ fetchProjectShareList().catch(console.error);
+ }
+ }, [project, setSharedEmails]);
+
+ const shareProject = async (email, project) => {
+ if (email && project) {
+ let newProjectShare = {
+ email: email,
+ projectId: project.id
+ };
+ await projectShareService.post(newProjectShare).catch(console.error);
+ await fetchProjectShareList().catch(console.error);
+ }
+ };
+
+ const deleteProjectShare = async projectShare => {
+ if (projectShare) {
+ await projectShareService.del(projectShare.id).catch(console.error);
+ await fetchProjectShareList().catch(console.error);
+ }
+ };
+
+ const handleSubmitEmail = e => {
+ switch (e.key) {
+ case "Enter":
+ shareProject(e.target.value, project);
+ }
+ };
+
+ const closeProject = () => {
+ onClose();
+ setPage(1);
+ setSelectedEmail(null);
+ setIsCopied(false);
+ };
+
+ const modalContents = page => {
+ switch (Number(page)) {
+ case 1:
+ return (
+
+
+ Share "{project ? project.name : ""}" Snapshot?
+
+
+ {
+ handleSubmitEmail(e);
+ }}
+ />
+
+
+
+ People with viewing permission
+
+ {sharedEmails.length ? (
+
+ {sharedEmails.map(email => (
+
+ {email.email}
+ {
+ setPage(3);
+ setSelectedEmail(email);
+ }}
+ >
+ Remove
+
+
+ ))}
+
+ ) : (
+
+ No email addresses added. Please include at least one email to
+ share the project.
+
+ )}
+
+ {
+ setPage(2);
+ }}
+ disabled={sharedEmails.length ? false : true}
+ variant="contained"
+ >
+ Next
+
+
+
+
+ );
+ case 2:
+ return (
+
+
+
+ Share "{project ? project.name : ""}" Snapshot
+
+
+
+ Copy link with message
+
+
+
+ Here's a snapshot of the current TDM Calculator plan
+ for: {project.name} .
+ If you don't already have a{" "}
+ TDM Calculator account, please set one
+ up to see the above snapshot link.
+
+
{
+ navigator.clipboard.writeText(copyMessage);
+ setIsCopied(true);
+ }}
+ >
+ Copy to Clipboard
+
+
+
+
+ OR
+
+
+
+ Copy link
+
+
+
+ {copyLink}
+
+
{
+ navigator.clipboard.writeText(copyLink);
+ setIsCopied(true);
+ }}
+ >
+ Copy to Clipboard
+
+
+
+
+ {
+ setPage(1);
+ setIsCopied(false);
+ }}
+ >
+ Back
+
+ {
+ closeProject();
+ }}
+ disabled={!isCopied}
+ >
+ Done
+
+
+
+
+
+ );
+ case 3:
+ return (
+
+
+ Are you sure?
+
+
+
+ Are you sure you want to remove {selectedEmail.email} from
+ viewing this snapshot?
+
+
+ {
+ setPage(1);
+ setSelectedEmail("");
+ }}
+ >
+ Cancel
+
+ {
+ deleteProjectShare(selectedEmail);
+ setPage(1);
+ setSelectedEmail("");
+ }}
+ variant="contained"
+ >
+ Yes
+
+
+
+
+ );
+ }
+ };
+
+ return (
+
+ {
+ closeProject();
+ }}
+ initialFocus="#emailAddresses"
+ >
+ {modalContents(page)}
+
+
+ );
+}
+
+ShareSnapshotModal.propTypes = {
+ mounted: PropTypes.bool,
+ onClose: PropTypes.func,
+ project: PropTypes.any
+};
diff --git a/client/src/services/projectShare.service.js b/client/src/services/projectShare.service.js
new file mode 100644
index 00000000..7401ba45
--- /dev/null
+++ b/client/src/services/projectShare.service.js
@@ -0,0 +1,19 @@
+import axios from "axios";
+
+const baseUrl = "/api/projectShare";
+
+export function getById(id) {
+ return axios.get(`${baseUrl}/${id}`);
+}
+
+export function getByProjectId(projectId) {
+ return axios.get(`${baseUrl}/projectId/${projectId}`);
+}
+
+export function post(projectShare) {
+ return axios.post(baseUrl, projectShare);
+}
+
+export function del(id) {
+ return axios.delete(`${baseUrl}/${id}`);
+}
diff --git a/server/app/controllers/projectShare.controller.js b/server/app/controllers/projectShare.controller.js
new file mode 100644
index 00000000..108318a4
--- /dev/null
+++ b/server/app/controllers/projectShare.controller.js
@@ -0,0 +1,66 @@
+const projectShareService = require("../services/projectShare.service");
+const {
+ validate,
+ validationErrorMiddleware
+} = require("../../middleware/validate");
+const projectShareSchema = require("../schemas/projectShare");
+
+const getById = async (req, res) => {
+ try {
+ const project = await projectShareService.getById(req.params.id);
+ if (!project) {
+ return res.status(404).send("Project not found.");
+ }
+ res.status(200).json(project);
+ } catch (err) {
+ res.status(500).send(err);
+ }
+};
+
+const getByProjectId = async (req, res) => {
+ try {
+ const sharedEmails = await projectShareService.getByProjectId(
+ req.params.projectId
+ );
+ if (!sharedEmails) {
+ return res.status(404).send("Project not found.");
+ }
+ res.status(200).json(sharedEmails);
+ } catch (err) {
+ res.status(500).send(err);
+ }
+};
+
+const post = async (req, res) => {
+ try {
+ const response = await projectShareService.post(req.body);
+ res.status(201).json(response);
+ } catch (err) {
+ res.status(500).send(err);
+ }
+};
+
+const del = async (req, res) => {
+ try {
+ const projectShare = await projectShareService.getById(req.params.id);
+ if (!projectShare) {
+ return res.status(404).send("ProjectShare not found.");
+ }
+
+ await projectShareService.del(req.params.id);
+ res.sendStatus(204);
+ } catch (err) {
+ res.status(500).send(err);
+ }
+};
+
+module.exports = {
+ getById,
+ getByProjectId,
+ post: [
+ validate({ body: projectShareSchema }),
+ post,
+ validationErrorMiddleware
+ ],
+ del
+};
diff --git a/server/app/routes/index.js b/server/app/routes/index.js
index 5ed4472a..0b5c2e40 100644
--- a/server/app/routes/index.js
+++ b/server/app/routes/index.js
@@ -8,6 +8,7 @@ const feedbackRoutes = require("./feedback.routes");
const emailRoutes = require("./email.routes");
const configRoutes = require("./config.routes");
const droRoutes = require("./dro.routes");
+const projectShare = require("./projectShare.routes");
module.exports = router;
@@ -19,3 +20,4 @@ router.use("/feedbacks", feedbackRoutes);
router.use("/emails", emailRoutes);
router.use("/configs", configRoutes);
router.use("/dro", droRoutes);
+router.use("/projectShare", projectShare);
diff --git a/server/app/routes/projectShare.routes.js b/server/app/routes/projectShare.routes.js
new file mode 100644
index 00000000..af645539
--- /dev/null
+++ b/server/app/routes/projectShare.routes.js
@@ -0,0 +1,14 @@
+const router = require("express").Router();
+const projectShareController = require("../controllers/projectShare.controller");
+const jwtSession = require("../../middleware/jwt-session");
+
+module.exports = router;
+
+router.get("/:id", jwtSession.validateUser, projectShareController.getById);
+router.get(
+ "/projectId/:projectId",
+ jwtSession.validateUser,
+ projectShareController.getByProjectId
+);
+router.post("/", jwtSession.validateUser, projectShareController.post);
+router.delete("/:id", jwtSession.validateUser, projectShareController.del);
diff --git a/server/app/schemas/projectShare.js b/server/app/schemas/projectShare.js
new file mode 100644
index 00000000..1acf019b
--- /dev/null
+++ b/server/app/schemas/projectShare.js
@@ -0,0 +1,14 @@
+module.exports = {
+ type: "object",
+ required: ["email", "projectId"],
+ properties: {
+ email: {
+ type: "string",
+ minLength: 3,
+ pattern: "\\S+@\\S+"
+ },
+ projectId: {
+ type: "number"
+ }
+ }
+};
diff --git a/server/app/services/projectShare.service.js b/server/app/services/projectShare.service.js
new file mode 100644
index 00000000..1aa2e15c
--- /dev/null
+++ b/server/app/services/projectShare.service.js
@@ -0,0 +1,62 @@
+const { pool, poolConnect } = require("./tedious-pool");
+const mssql = require("mssql");
+
+const getById = async id => {
+ try {
+ await poolConnect;
+ const request = pool.request();
+ request.input("id", mssql.Int, id);
+ const response = await request.execute("ProjectShare_SelectById");
+ if (response.recordset && response.recordset.length > 0) {
+ return response.recordset[0];
+ } else {
+ return null;
+ }
+ } catch (err) {
+ return Promise.reject(err);
+ }
+};
+
+const getByProjectId = async projectId => {
+ try {
+ await poolConnect;
+ const request = pool.request();
+ request.input("ProjectId", mssql.Int, projectId);
+ const response = await request.execute("ProjectShare_SelectByProjectId");
+ return response.recordset;
+ } catch (err) {
+ return Promise.reject(err);
+ }
+};
+
+const post = async item => {
+ try {
+ await poolConnect;
+ const request = pool.request();
+ request.input("email", mssql.NVarChar, item.email); // 100
+ request.input("projectId", mssql.Int, item.projectId);
+ request.output("id", mssql.Int, null);
+ const response = await request.execute("ProjectShare_Insert");
+ return response.output;
+ } catch (err) {
+ return Promise.reject(err);
+ }
+};
+
+const del = async id => {
+ try {
+ await poolConnect;
+ const request = pool.request();
+ request.input("id", mssql.Int, id);
+ await request.execute("ProjectShare_Delete");
+ } catch (err) {
+ return Promise.reject(err);
+ }
+};
+
+module.exports = {
+ getById,
+ getByProjectId,
+ post,
+ del
+};
diff --git a/server/db/migration/V20241008.1756__add_projectShare_table.sql b/server/db/migration/V20241008.1756__add_projectShare_table.sql
new file mode 100644
index 00000000..df3d15b0
--- /dev/null
+++ b/server/db/migration/V20241008.1756__add_projectShare_table.sql
@@ -0,0 +1,6 @@
+CREATE TABLE ProjectShare (
+ id INT PRIMARY KEY IDENTITY(1,1),
+ email NVARCHAR(100) NOT NULL,
+ projectId INT NOT NULL FOREIGN KEY REFERENCES Project(id)
+);
+GO
\ No newline at end of file
diff --git a/server/db/migration/V20241008.1757__add_projectShare_insert.sql b/server/db/migration/V20241008.1757__add_projectShare_insert.sql
new file mode 100644
index 00000000..325218ad
--- /dev/null
+++ b/server/db/migration/V20241008.1757__add_projectShare_insert.sql
@@ -0,0 +1,11 @@
+CREATE PROCEDURE [dbo].[ProjectShare_Insert]
+ @email NVARCHAR(100),
+ @projectId INT,
+ @id INT OUTPUT
+AS
+BEGIN
+ INSERT INTO [dbo].[ProjectShare] (email, projectId)
+ VALUES (@email, @projectId);
+ SET @id = SCOPE_IDENTITY();
+END;
+GO
diff --git a/server/db/migration/V20241008.1758__add_projectShare_delete.sql b/server/db/migration/V20241008.1758__add_projectShare_delete.sql
new file mode 100644
index 00000000..c65c7629
--- /dev/null
+++ b/server/db/migration/V20241008.1758__add_projectShare_delete.sql
@@ -0,0 +1,8 @@
+CREATE PROCEDURE [dbo].[ProjectShare_Delete]
+ @id int
+AS
+BEGIN
+ DELETE FROM [dbo].[ProjectShare]
+ WHERE id = @id;
+END;
+GO
diff --git a/server/db/migration/V20241008.1759__add_projectShare_select_by_projectId.sql b/server/db/migration/V20241008.1759__add_projectShare_select_by_projectId.sql
new file mode 100644
index 00000000..0437b50a
--- /dev/null
+++ b/server/db/migration/V20241008.1759__add_projectShare_select_by_projectId.sql
@@ -0,0 +1,14 @@
+SET ANSI_NULLS ON
+GO
+SET QUOTED_IDENTIFIER ON
+GO
+CREATE proc [dbo].[ProjectShare_SelectByProjectId]
+ @projectId INT
+AS
+BEGIN
+ SELECT ps.id AS id, ps.email, p.id AS projectid
+ FROM [dbo].[ProjectShare] ps
+ INNER JOIN [dbo].[Project] p
+ ON ps.projectId = p.id and ps.projectId = @projectId;
+END
+GO
\ No newline at end of file
diff --git a/server/db/migration/V20241008.1760__add_projectShare_select_by_Id.sql b/server/db/migration/V20241008.1760__add_projectShare_select_by_Id.sql
new file mode 100644
index 00000000..8f4facaa
--- /dev/null
+++ b/server/db/migration/V20241008.1760__add_projectShare_select_by_Id.sql
@@ -0,0 +1,9 @@
+CREATE proc [dbo].[ProjectShare_SelectById]
+ @id INT
+AS
+BEGIN
+ SELECT id, email, projectId
+ FROM [dbo].[ProjectShare]
+ WHERE id = @id;
+END
+GO
\ No newline at end of file
diff --git a/server/db/migration/V20241008.1761__add_projectShare_test.sql b/server/db/migration/V20241008.1761__add_projectShare_test.sql
new file mode 100644
index 00000000..054be565
--- /dev/null
+++ b/server/db/migration/V20241008.1761__add_projectShare_test.sql
@@ -0,0 +1,9 @@
+INSERT INTO dbo.ProjectShare (email, projectId)
+VALUES
+('testing1@email.com', 2),
+('testing3@email.com', 2),
+('testing5@email.com', 2),
+('testing7@email.com', 2),
+('testing1@email.com', 3),
+('testing2@email.com', 3),
+('testing3@email.com', 4);
\ No newline at end of file