diff --git a/client/package-lock.json b/client/package-lock.json index 7d3f9f0..58abe7d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -17,6 +17,7 @@ "date-fns": "^2.28.0", "jwt-decode": "^4.0.0", "litepicker": "^2.0.12", + "lodash": "^4.17.21", "react": "^17.0.2", "react-date-range": "^1.4.0", "react-dom": "^17.0.2", diff --git a/client/package.json b/client/package.json index 0c4da94..be196c1 100644 --- a/client/package.json +++ b/client/package.json @@ -20,6 +20,7 @@ "date-fns": "^2.28.0", "jwt-decode": "^4.0.0", "litepicker": "^2.0.12", + "lodash": "^4.17.21", "react": "^17.0.2", "react-date-range": "^1.4.0", "react-dom": "^17.0.2", @@ -30,8 +31,8 @@ "web-vitals": "^2.1.4" }, "devDependencies": { - "react-scripts": "5.0.0", - "@svgr/webpack": "^6.2.1" + "@svgr/webpack": "^6.2.1", + "react-scripts": "5.0.0" }, "overrides": { "@svgr/webpack": "$@svgr/webpack" diff --git a/client/src/CheckpointCTAs.js b/client/src/CheckpointCTAs.js index b5bfa45..f0166c2 100644 --- a/client/src/CheckpointCTAs.js +++ b/client/src/CheckpointCTAs.js @@ -1,46 +1,31 @@ import React from 'react'; -import { useModal } from './context/modal-context' -import axios from 'axios'; +import { useNavigate } from 'react-router-dom'; +import { useSelector } from 'react-redux'; export default function CheckpointCTAs() { - const { setModal } = useModal() + const navigate = useNavigate(); + const assessmentId = useSelector((state) => state.checkpoints.activeAssessment.id); // Get assessmentId from Redux state - // if ( !activeCheckpoint || activeCheckpoint.length === 0) return

; + const handleViewReport = () => { + navigate(`/assessment/${assessmentId}/report`); + }; return ( - <> -
- +
+ + - - - ); -}; +} \ No newline at end of file diff --git a/client/src/DownloadReportButton.js b/client/src/DownloadReportButton.js new file mode 100644 index 0000000..611752b --- /dev/null +++ b/client/src/DownloadReportButton.js @@ -0,0 +1,48 @@ +// src/DownloadReportButton.js + +import React from 'react'; +import axiosInstance from './axiosInstance'; + +export default function DownloadReportButton({ assessmentId }) { + const handleDownloadReport = async (format) => { + try { + const response = await axiosInstance.get(`/assessments/${assessmentId}/report`, { + headers: { + Accept: format === 'csv' ? 'text/csv' : 'application/json' + }, + responseType: 'blob' // Important for downloading files + }); + + // Create a link element to trigger the download + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `assessment_report_${assessmentId}.${format}`); + document.body.appendChild(link); + link.click(); + link.remove(); + } catch (error) { + console.error(`Error downloading the ${format} report:`, error); + } + }; + + return ( +
+ + +
+ + + + + + ); +} \ No newline at end of file diff --git a/client/src/MitigateForm.js b/client/src/MitigateForm.js index a2e9129..a3972a6 100644 --- a/client/src/MitigateForm.js +++ b/client/src/MitigateForm.js @@ -1,83 +1,77 @@ -import React, { useState } from 'react'; -import { useSelector, useDispatch } from 'react-redux' +import React, { useState, useEffect, useCallback } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { debounce } from 'lodash'; import SaveAndContinue from './SaveAndContinue'; import SaveAndContinueDisabled from './SaveAndContinueDisabled'; - -import { - updateCheckpointAnswers, -} from "./checkpointsSlice"; - +import { updateCheckpointAnswers } from './checkpointsSlice'; export default function MitigateForm(props) { const dispatch = useDispatch(); const activeCheckpointAnswer = useSelector((state) => state.checkpoints.activeCheckpointAnswer); - const { fields } = props; const form_data = {}; fields.forEach((field, i) => { const field_name = field.name; - let val = ""; - if (typeof(activeCheckpointAnswer.form_data) !== "undefined" && typeof(activeCheckpointAnswer.form_data[field_name]) !== "undefined") { + let val = ''; + if (typeof activeCheckpointAnswer.form_data !== 'undefined' && typeof activeCheckpointAnswer.form_data[field_name] !== 'undefined') { val = activeCheckpointAnswer.form_data[field_name]; } - form_data[field_name] = val; }); - const [formData, setformData] = useState(form_data); - - + const [formData, setFormData] = useState(form_data); + // Function to dispatch the updateCheckpointAnswers action + const debouncedDispatch = useCallback( + debounce((answer) => { + dispatch(updateCheckpointAnswers(answer)); + }, 1000), // Adjust the debounce delay as needed (1000ms = 1 second) + [] + ); const handleChange = (name, value) => { - - const new_form_data = {...formData}; - + const new_form_data = { ...formData }; new_form_data[name] = value; - const answer = {...activeCheckpointAnswer}; + const answer = { ...activeCheckpointAnswer }; answer.form_data = new_form_data; - dispatch(updateCheckpointAnswers(answer)) - setformData(new_form_data) - - } - return ( - -
+ // Update local state immediately + setFormData(new_form_data); - {fields.map((field, i) => { - return ( -
- - -
+ // Dispatch the debounced action + debouncedDispatch(answer); + }; - ); - })} - - { - ( - !activeCheckpointAnswer.option.explain_risk - || - typeof(activeCheckpointAnswer.form_data) !== "undefined" && activeCheckpointAnswer.form_data.mitigating_actions.length - ) ? : - } + useEffect(() => { + // Cleanup function to cancel any pending debounced actions when the component unmounts + return () => { + debouncedDispatch.cancel(); + }; + }, [debouncedDispatch]); + return ( +
+ {fields.map((field, i) => ( +
+ + +
+ ))} + {(!activeCheckpointAnswer.option.explain_risk || + (typeof activeCheckpointAnswer.form_data !== 'undefined' && + activeCheckpointAnswer.form_data.mitigating_actions.length)) ? ( + + ) : ( + + )}
); - - - - -} +} \ No newline at end of file diff --git a/client/src/Report.js b/client/src/Report.js index 1d99f71..3d9aac6 100644 --- a/client/src/Report.js +++ b/client/src/Report.js @@ -1,6 +1,7 @@ import React from 'react'; import { useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; +import DownloadReportButton from './DownloadReportButton'; export default function Report() { useParams(); @@ -20,6 +21,7 @@ export default function Report() { }; return ( + <>
@@ -34,6 +36,8 @@ export default function Report() {
+ + ); function AnswersList(props) { diff --git a/client/src/assets/scss/checkpoint/ctas.scss b/client/src/assets/scss/checkpoint/ctas.scss index 1376326..0c2dc01 100644 --- a/client/src/assets/scss/checkpoint/ctas.scss +++ b/client/src/assets/scss/checkpoint/ctas.scss @@ -88,9 +88,11 @@ border: none; width: calc(100% + 7vw); margin-top: 50px; - @media (min-width: 1440px){ + + @media (min-width: 1440px) { width: calc(100% + 100px); } + &:before { content: ""; display: block; @@ -101,7 +103,6 @@ left: 0; background: #2254F4; box-shadow: -10px 10px 0px #178CFF; - transform: rotate(-2deg); } @@ -109,7 +110,6 @@ position: relative; z-index: 2; padding: 30px 20px; - justify-content: flex-start; display: flex; flex-direction: column; align-items: flex-start; @@ -126,61 +126,89 @@ color: #fff; background: $light-blue; padding: 0.3em 0.5em; - @media (min-width: 768px){ + + @media (min-width: 768px) { font-size: vh(18px); } - @media (min-width: 768px) and (min-height: 900px){ + + @media (min-width: 768px) and (min-height: 900px) { font-size: vw(18px); } - @media (min-width: 1440px) and (min-height: 900px){ + + @media (min-width: 1440px) and (min-height: 900px) { font-size: 18px; } } .cta-content { display: flex; - + align-items: center; padding-right: 60px; + .cta-image { + width: 75px; + height: 98px; + background-image: url('../../img/report-a.svg'); + background-size: contain; + background-repeat: no-repeat; + flex-shrink: 0; + margin-right: 15px; + + @media (min-width: 768px) { + width: vw(75px); + height: vw(98px); + } + + @media (min-width: 1440px) { + width: 75px; + height: 98px; + } + } + .cta-text { text-align: left; color: #fff; display: flex; + flex-direction: column; font-size: 1.6rem; - @media (min-width: 768px){ + + @media (min-width: 768px) { font-size: 1.4rem; } - @media (min-width: 1024px){ + + @media (min-width: 1024px) { font-size: 1.5rem; } - @media (min-width: 1440px) and (min-height: 900px){ + + @media (min-width: 1440px) and (min-height: 900px) { font-size: 1.6rem; } - &:before { - content: ""; - display: inline-block; - width: 75px; - height: 98px; - background-image: url('../../img/report-a.svg'); - background-size: contain; - background-repeat: no-repeat; - flex-shrink: 0; - margin-right: 15px; - @media (min-width: 768px){ - width: vw(75px); - height: vw(98px); - } - @media (min-width: 1440px) { - width: 75px; - height: 98px; + + .text { + margin-bottom: 10px; + } + + .buttons-container { + display: flex; + flex-wrap: wrap; + gap: 10px; + + .button { + position: relative; + display: inline-block; + padding: 10px 20px; + background: #fff; + color: #2254F4; + border: 2px solid #2254F4; + border-radius: 5px; + text-align: center; + cursor: pointer; + + svg { + margin-left: 5px; + } } } } } - - .button { - position: absolute; - left: 20px; - bottom: -20px; - } } diff --git a/client/src/assets/scss/data/form.scss b/client/src/assets/scss/data/form.scss index 8cc31a6..cd4a476 100644 --- a/client/src/assets/scss/data/form.scss +++ b/client/src/assets/scss/data/form.scss @@ -87,8 +87,6 @@ fieldset { } } - - input, textarea { box-shadow: none; display: inline-block; diff --git a/server/package-lock.json b/server/package-lock.json index 6045611..42b5acf 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -15,6 +15,7 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "express-session": "^1.18.0", + "json2csv": "^6.0.0-alpha.2", "jsonwebtoken": "^9.0.2", "mongoose": "^8.4.1", "morgan": "^1.10.0", @@ -51,6 +52,11 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@streamparser/json": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@streamparser/json/-/json-0.0.6.tgz", + "integrity": "sha512-vL9EVn/v+OhZ+Wcs6O4iKE9EUpwHUqHmCtNUMWjqp+6dr85+XPOSGTEsqYNq1Vn04uk9SWlOVmx9J48ggJVT2Q==" + }, "node_modules/@types/webidl-conversions": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", @@ -273,6 +279,14 @@ "color-support": "bin.js" } }, + "node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -815,6 +829,23 @@ "node": ">=8" } }, + "node_modules/json2csv": { + "version": "6.0.0-alpha.2", + "resolved": "https://registry.npmjs.org/json2csv/-/json2csv-6.0.0-alpha.2.tgz", + "integrity": "sha512-nJ3oP6QxN8z69IT1HmrJdfVxhU1kLTBVgMfRnNZc37YEY+jZ4nU27rBGxT4vaqM/KUCavLRhntmTuBFqZLBUcA==", + "dependencies": { + "@streamparser/json": "^0.0.6", + "commander": "^6.2.0", + "lodash.get": "^4.4.2" + }, + "bin": { + "json2csv": "bin/json2csv.js" + }, + "engines": { + "node": ">= 12", + "npm": ">= 6.13.0" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -868,6 +899,11 @@ "node": ">=12.0.0" } }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", diff --git a/server/package.json b/server/package.json index c9ec177..a85da23 100644 --- a/server/package.json +++ b/server/package.json @@ -15,6 +15,7 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "express-session": "^1.18.0", + "json2csv": "^6.0.0-alpha.2", "jsonwebtoken": "^9.0.2", "mongoose": "^8.4.1", "morgan": "^1.10.0", diff --git a/server/routes/assessments.js b/server/routes/assessments.js index e4a2d5c..1de8dcf 100644 --- a/server/routes/assessments.js +++ b/server/routes/assessments.js @@ -3,6 +3,7 @@ const router = express.Router(); const Assessment = require('../models/Assessment'); const User = require('../models/User'); const ensureAuthenticated = require('../middleware/ensureAuthenticated'); +const { Parser } = require('json2csv'); // Get all assessments router.get('/', ensureAuthenticated, async (req, res) => { @@ -60,6 +61,61 @@ router.get('/:id', ensureAuthenticated, async (req, res) => { } }); +// Utility function to map answers with checkpoints +function mapAnswersWithCheckpoints(assessment, checkpoints) { + return assessment.answers.map(answer => { + const checkpoint = checkpoints.find(cp => cp.id === answer.id); + const mitigatingActions = answer.option.explain_risk && answer.form_data ? answer.form_data.mitigating_actions : ""; + return { + question: checkpoint.title, + category: checkpoint.category, + answer: answer.option.option, + risk_level: answer.option.risk_level, + mitigating_actions: mitigatingActions + }; + }); +} + + +// Route to get report in JSON or CSV format using content negotiation +router.get('/:id/report', ensureAuthenticated, async (req, res) => { + try { + const assessment = await Assessment.findById(req.params.id); + if (!assessment) { + return res.status(404).json({ message: 'Assessment not found' }); + } + + const checkpoints = require('../data/checkpoints.json'); + const mappedAnswers = mapAnswersWithCheckpoints(assessment, checkpoints); + + const report = { + dataset_name: assessment.data_capture.dataset_name.value, + dataset_description: assessment.data_capture.dataset_description.value, + sharing_reason: assessment.data_capture.sharing_reason.value, + answers: mappedAnswers + }; + + const accept = req.headers.accept; + + if (accept.includes('text/csv')) { + const fields = ['question', 'category', 'answer', 'risk_level', 'mitigating_actions']; + const opts = { fields }; + const parser = new Parser(opts); + const csv = parser.parse(mappedAnswers); + res.header('Content-Type', 'text/csv'); + res.attachment(`assessment_report_${req.params.id}.csv`); + return res.send(csv); + } else if (accept.includes('application/json')) { + res.header('Content-Type', 'application/json'); + return res.json(report); + } else { + res.status(406).send('Not Acceptable'); + } + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + // Create a new assessment router.post('/', ensureAuthenticated, async (req, res) => { const userId = req.user._id; // Fetch the user ID from req.user