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} + +
    + ))} +
    + ) : ( +
    + No email addresses added. Please include at least one email to + share the project. +
    + )} +
    + +
    +
    +
    + ); + 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. +

    + +
    +
    + + OR + +
    +
    + Copy link +
    +
    +

    + {copyLink} +

    + +
    +
    +
    + + +
    +
    +
    Successfully Copied!
    +
    +
    +
    + ); + case 3: + return ( +
    +
    + Are you sure? +
    +
    +
    + Are you sure you want to remove {selectedEmail.email} from + viewing this snapshot? +
    +
    + + +
    +
    +
    + ); + } + }; + + 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