-
-
- >
);
-};
+}
\ 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