diff --git a/ui/src/components/custom/search/matrixExplorer.tsx b/ui/src/components/custom/search/matrixExplorer.tsx index 6af94f9..922579e 100644 --- a/ui/src/components/custom/search/matrixExplorer.tsx +++ b/ui/src/components/custom/search/matrixExplorer.tsx @@ -27,6 +27,7 @@ interface CarverMatrix { hosts: string[]; participants: string[]; roleBased: boolean; + createdAt: string; } const MatrixExplorer: React.FC = () => { @@ -86,15 +87,16 @@ const MatrixExplorer: React.FC = () => { const filteredMatrices = matrices.filter((matrix) => { // Text search filter const term = searchTerm.toLowerCase(); - const matchesSearch = matrix.name.toLowerCase().includes(term) || + const matchesSearch = + matrix.name.toLowerCase().includes(term) || matrix.description.toLowerCase().includes(term); // Role-based matrix filter - if (roleBasedFilter !== 'all') { - if (roleBasedFilter === 'enabled' && !matrix.roleBased) { + if (roleBasedFilter !== "all") { + if (roleBasedFilter === "enabled" && !matrix.roleBased) { return false; } - if (roleBasedFilter === 'disabled' && matrix.roleBased) { + if (roleBasedFilter === "disabled" && matrix.roleBased) { return false; } } @@ -111,7 +113,7 @@ const MatrixExplorer: React.FC = () => { // Role-based filtering if (!userEmail) { - console.log('No user email available'); + console.log("No user email available"); return false; } @@ -125,19 +127,33 @@ const MatrixExplorer: React.FC = () => { matchesRole = isBoth; } else { if (roleFilters.host) { - matchesRole = isHost; + matchesRole = isHost; // Show all matrices where user is a host, including those where they are both } if (roleFilters.participant) { - matchesRole = matchesRole || isParticipant; + matchesRole = matchesRole || isParticipant; // Show all matrices where user is a participant, including those where they are both } if (!roleFilters.host && !roleFilters.participant) { - matchesRole = true; + matchesRole = true; // No role filters selected } } return matchesSearch && matchesRole; }); + // Sort matrices by timestamp (newest first) + const sortedMatrices = [...filteredMatrices].sort((a, b) => { + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); + + // Helper functions to check if user is host or participant + const isHost = (matrix: CarverMatrix): boolean => { + return userEmail ? matrix.hosts?.includes(userEmail) || false : false; + }; + + const isParticipant = (matrix: CarverMatrix): boolean => { + return userEmail ? matrix.participants?.includes(userEmail) || false : false; + }; + const handleMatrixSelect = (matrixId: number) => { window.location.href = `/EditMatrix?matrixId=${matrixId}`; }; @@ -381,16 +397,37 @@ const MatrixExplorer: React.FC = () => { gap: 1.5, boxSizing: 'border-box', pr: 0.5, + '&::-webkit-scrollbar': { + width: '6px', + }, + '&::-webkit-scrollbar-track': { + background: 'rgba(255, 255, 255, 0.05)', + borderRadius: '3px', + }, + '&::-webkit-scrollbar-thumb': { + background: 'rgba(255, 255, 255, 0.2)', + borderRadius: '3px', + '&:hover': { + background: 'rgba(255, 255, 255, 0.3)', + }, + }, }} > - {filteredMatrices.map((matrix) => ( - handleMatrixSelect(matrix.matrixId)} - titleColor="#ffffff" - /> - ))} + {sortedMatrices.length > 0 ? ( + sortedMatrices.map((matrix) => ( + handleMatrixSelect(matrix.matrixId)} + isHost={isHost(matrix)} + isParticipant={isParticipant(matrix)} + /> + )) + ) : ( + + No matrices found + + )} ); diff --git a/ui/src/components/custom/search/miniMatrixCard.tsx b/ui/src/components/custom/search/miniMatrixCard.tsx index ebefa13..4a590d4 100644 --- a/ui/src/components/custom/search/miniMatrixCard.tsx +++ b/ui/src/components/custom/search/miniMatrixCard.tsx @@ -1,16 +1,20 @@ import React from "react"; -import { Typography, Paper } from "@mui/material"; +import { Typography, Paper, Box } from "@mui/material"; +import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; +import PersonIcon from '@mui/icons-material/Person'; interface MiniMatrixCardProps { title: string; onSelectMatrix: () => void; - titleColor?: string; + isHost?: boolean; + isParticipant?: boolean; } const MiniMatrixCard: React.FC = ({ title, onSelectMatrix, - titleColor = "#ffffff", + isHost = false, + isParticipant = false, }) => { return ( = ({ }} onClick={onSelectMatrix} > - - {title || ""} - + + {isHost && ( + + )} + {!isHost && isParticipant && ( + + )} + + {title || ""} + + ); }; diff --git a/ui/src/components/navigation/Redirect.tsx b/ui/src/components/navigation/Redirect.tsx index 84463fb..433c690 100644 --- a/ui/src/components/navigation/Redirect.tsx +++ b/ui/src/components/navigation/Redirect.tsx @@ -1,6 +1,7 @@ import { useContext, useEffect, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { GlobalContext } from "../../context/GlobalContext"; +import { useUnsavedChanges } from "../../context/UnsavedChangesContext"; import { ADMIN_ROUTES, blueTheme, @@ -39,6 +40,7 @@ export const Redirect: React.FC = ({ children }) => { const location = useLocation(); const [height, setHeight] = useState(window.innerHeight); const [anchorEl, setAnchorEl] = useState(null); + const { hasUnsavedChanges, showUnsavedChangesDialog } = useUnsavedChanges(); // Map routes to display names for breadcrumbs const routeNames = { @@ -65,7 +67,11 @@ export const Redirect: React.FC = ({ children }) => { const handleProfile = () => { handleProfileMenuClose(); - navigate(ROUTES.profile); + if (hasUnsavedChanges) { + showUnsavedChangesDialog(() => navigate(ROUTES.profile)); + } else { + navigate(ROUTES.profile); + } }; const handleLogout = () => { @@ -134,7 +140,7 @@ export const Redirect: React.FC = ({ children }) => { const getBreadcrumbs = () => { const pathnames = location.pathname.split('/').filter((x) => x); let currentPath = ''; - let breadcrumbPaths: string[] = []; + const breadcrumbPaths: string[] = []; // Build the breadcrumb paths array pathnames.forEach((value) => { @@ -150,6 +156,14 @@ export const Redirect: React.FC = ({ children }) => { breadcrumbPaths.push(currentPath); }); + const handleNavigation = (path: string) => { + if (hasUnsavedChanges) { + showUnsavedChangesDialog(() => navigate(path)); + } else { + navigate(path); + } + }; + return ( } @@ -157,7 +171,7 @@ export const Redirect: React.FC = ({ children }) => { > navigate(ROUTES.landing)} + onClick={() => handleNavigation(ROUTES.landing)} sx={{ color: '#ffffff', textDecoration: 'none', @@ -189,7 +203,7 @@ export const Redirect: React.FC = ({ children }) => { navigate(path)} + onClick={() => handleNavigation(path)} sx={{ color: '#ffffff', textDecoration: 'none', @@ -234,7 +248,13 @@ export const Redirect: React.FC = ({ children }) => { flex: 1, }} > - navigate(ROUTES.landing)}> + { + if (hasUnsavedChanges) { + showUnsavedChangesDialog(() => navigate(ROUTES.landing)); + } else { + navigate(ROUTES.landing); + } + }}> {getBreadcrumbs()} @@ -252,7 +272,13 @@ export const Redirect: React.FC = ({ children }) => { <> Dashboard - navigate(ROUTES.admin)}> + { + if (hasUnsavedChanges) { + showUnsavedChangesDialog(() => navigate(ROUTES.admin)); + } else { + navigate(ROUTES.admin); + } + }}> diff --git a/ui/src/context/UnsavedChangesContext.tsx b/ui/src/context/UnsavedChangesContext.tsx new file mode 100644 index 0000000..d488e75 --- /dev/null +++ b/ui/src/context/UnsavedChangesContext.tsx @@ -0,0 +1,128 @@ +import React, { createContext, useContext, useState, ReactNode } from 'react'; + +interface UnsavedChangesContextType { + hasUnsavedChanges: boolean; + setHasUnsavedChanges: (value: boolean) => void; + showUnsavedChangesDialog: (callback: () => void) => void; +} + +const UnsavedChangesContext = createContext(undefined); + +export const useUnsavedChanges = () => { + const context = useContext(UnsavedChangesContext); + if (context === undefined) { + throw new Error('useUnsavedChanges must be used within an UnsavedChangesProvider'); + } + return context; +}; + +interface UnsavedChangesProviderProps { + children: ReactNode; +} + +export const UnsavedChangesProvider: React.FC = ({ children }) => { + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [pendingCallback, setPendingCallback] = useState<(() => void) | null>(null); + const [dialogOpen, setDialogOpen] = useState(false); + + const showUnsavedChangesDialog = (callback: () => void) => { + setPendingCallback(() => callback); + setDialogOpen(true); + }; + + const handleStayOnPage = () => { + setDialogOpen(false); + setPendingCallback(null); + }; + + const handleLeavePage = () => { + setDialogOpen(false); + if (pendingCallback) { + pendingCallback(); + setPendingCallback(null); + } + }; + + return ( + + {children} + {dialogOpen && ( +
+
+

+ Unsaved Changes +

+

+ You have unsaved changes. If you leave this page, your changes will be lost. Do you want to continue? +

+
+ + +
+
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 2c5a7e8..ca62977 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import { createBrowserRouter, RouterProvider } from "react-router-dom"; import "./index.css"; import { ContextProvider } from "./context/GlobalContext.tsx"; +import { UnsavedChangesProvider } from "./context/UnsavedChangesContext"; import { routesConfig, futureFlags } from "./helpers/helpers"; const router = createBrowserRouter(routesConfig, futureFlags); @@ -10,7 +11,9 @@ const router = createBrowserRouter(routesConfig, futureFlags); createRoot(document.getElementById("root")!).render( - + + + ); diff --git a/ui/src/pages/EditMatrix.tsx b/ui/src/pages/EditMatrix.tsx index 5da3ba6..1d2a10f 100644 --- a/ui/src/pages/EditMatrix.tsx +++ b/ui/src/pages/EditMatrix.tsx @@ -1,6 +1,6 @@ console.log("EditMatrix.tsx module is being loaded"); import React, { useState, useMemo, useEffect } from "react"; -import { useLocation } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import { Box, Typography, @@ -22,6 +22,11 @@ import { Chip, CircularProgress, Modal, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, } from "@mui/material"; import MatrixExplorer from "../components/custom/search/matrixExplorer"; import CategoryGroup from "../components/custom/editMatrix/categoryGroup"; @@ -44,25 +49,69 @@ import AttachFileIcon from '@mui/icons-material/AttachFile'; import ZoomInIcon from '@mui/icons-material/ZoomIn'; import DownloadIcon from '@mui/icons-material/Download'; import CloseIcon from '@mui/icons-material/Close'; +import { useUnsavedChanges } from "../context/UnsavedChangesContext"; + +// Define the MatrixConfig interface +interface MatrixConfig { + name: string; + description: string; + currentUserEmail?: string; + randomAssignment: boolean; + roleBased: boolean; + fivePointScoring: boolean; + hosts?: string[]; + participants?: string[]; + cMulti: number; + aMulti: number; + rMulti: number; + vMulti: number; + eMulti: number; + r2Multi: number; + [key: string]: string | number | boolean | string[] | undefined; // Add index signature +} + +// Define the MatrixUpdate interface +interface MatrixUpdate { + itemId: number; + [key: string]: unknown; +} interface MatrixItem { itemId: number; itemName: string; - criticality: Record; - accessibility: Record; - recoverability: Record; - vulnerability: Record; - effect: Record; - recognizability: Record; + criticality: Record; + accessibility: Record; + recoverability: Record; + vulnerability: Record; + effect: Record; + recognizability: Record; createdAt: string; - targetUsers: any[]; + targetUsers: string[]; images: MatrixImage[] | null; - [key: string]: any; + [key: string]: unknown; +} + +// Define the ConfigType interface to match what ExportPdfButton expects +interface ConfigType { + r2Multi: number; + randomAssignment: boolean; + roleBased: boolean; + fivePointScoring: boolean; + cMulti: number; + aMulti: number; + rMulti: number; + vMulti: number; + eMulti: number; + description: string; + name: string; + hosts?: string[]; + participants?: string[]; + currentUserEmail?: string; } // Host Pane Component const HostPane: React.FC<{ - config: any; + config: MatrixConfig; items: MatrixItem[]; categories: string[]; }> = ({ config, items, categories }) => { @@ -98,7 +147,7 @@ const HostPane: React.FC<{ if (config.randomAssignment) { // For random assignment: check if all assigned users have submitted scores items.forEach(item => { - const scores = item[key] || {}; + const scores = (item[key] as Record) || {}; const targetUsers = item.targetUsers || []; if (targetUsers.length === 0) return; // Skip if no users assigned @@ -116,11 +165,11 @@ const HostPane: React.FC<{ const participants = config.participants || []; if (participants.length === 0) return; - let totalPossibleSubmissions = items.length * participants.length; + const totalPossibleSubmissions = items.length * participants.length; let totalSubmissions = 0; items.forEach(item => { - const scores = item[key] || {}; + const scores = (item[key] as Record) || {}; const submittedUsers = participants.filter((user: string) => scores[user] !== undefined && scores[user] > 0 ); @@ -147,17 +196,70 @@ const HostPane: React.FC<{ // Calculate average scores with multipliers for the matrix view const getAverageScore = (item: MatrixItem, category: string): number => { const key = categoryToPropertyMap[category]; - const scores = item[key] || {}; + const scores = (item[key] as Record) || {}; const values = Object.values(scores) as number[]; if (values.length === 0) return 0; const average = values.reduce((sum, score) => sum + score, 0) / values.length; - const multiplier = config[categoryToMultiplierMap[category]] || 1; + const multiplierKey = categoryToMultiplierMap[category]; + const multiplier = (config[multiplierKey] as number) || 1; return Number((average * multiplier).toFixed(1)); }; + // Calculate participant progress + const participantProgress = useMemo(() => { + const progress: { [email: string]: number } = {}; + + if (!config.participants || config.participants.length === 0) { + return progress; + } + + config.participants.forEach((participant: string) => { + let totalPossibleSubmissions = 0; + let completedSubmissions = 0; + + if (config.randomAssignment) { + // For random assignment, count only assigned items + items.forEach(item => { + if (Array.isArray(item.targetUsers) && item.targetUsers.includes(participant)) { + totalPossibleSubmissions += categories.length; + + // Count completed categories for this item + categories.forEach(category => { + const key = categoryToPropertyMap[category]; + const scores = (item[key] as Record) || {}; + if (scores[participant] !== undefined && scores[participant] > 0) { + completedSubmissions++; + } + }); + } + }); + } else { + // For non-random assignment, all items are possible + totalPossibleSubmissions = items.length * categories.length; + + // Count all completed submissions + items.forEach(item => { + categories.forEach(category => { + const key = categoryToPropertyMap[category]; + const scores = (item[key] as Record) || {}; + if (scores[participant] !== undefined && scores[participant] > 0) { + completedSubmissions++; + } + }); + }); + } + + progress[participant] = totalPossibleSubmissions > 0 + ? (completedSubmissions / totalPossibleSubmissions) * 100 + : 0; + }); + + return progress; + }, [items, categories, config.randomAssignment, config.participants, categoryToPropertyMap]); + return ( @@ -278,7 +380,7 @@ const HostPane: React.FC<{ - {category.charAt(0)} ({config[categoryToMultiplierMap[category]]}x) + {category.charAt(0)} ({String(config[categoryToMultiplierMap[category]] || 1)}x) {Math.round(categoryCompletions[category])}% @@ -301,8 +403,146 @@ const HostPane: React.FC<{ - {/* Matrix Overview */} + {/* Participants Overview */} + + + + Matrix Participants + + + {/* Hosts Section */} + + + + + Hosts + + + {config.hosts?.map((host: string, index: number) => ( + + + {host} + + ))} + + + + + {/* Participants Section */} + + + + + Participants + + + {config.participants?.map((participant: string, index: number) => { + const progress = participantProgress[participant] || 0; + return ( + + + + {participant} + + {Math.round(progress)}% + + + + + ); + })} + + + + + + + + {/* Matrix Overview */} + - + @@ -402,6 +642,7 @@ const HostPane: React.FC<{ const EditMatrixContent: React.FC = () => { const location = useLocation(); + const navigate = useNavigate(); const { config, @@ -411,6 +652,8 @@ const EditMatrixContent: React.FC = () => { getItemImages } = useMultiMatrix(); + const { setHasUnsavedChanges, hasUnsavedChanges } = useUnsavedChanges(); + const currentEmail = config.currentUserEmail; const isRoleBased = config.roleBased; const isHost = isRoleBased && currentEmail ? config.hosts?.includes(currentEmail) : false; @@ -428,6 +671,10 @@ const EditMatrixContent: React.FC = () => { : 'participant' : 'participant' ); + + // New state for tracking unsaved changes and navigation + const [pendingNavigation, setPendingNavigation] = useState(null); + const [unsavedChangesDialogOpen, setUnsavedChangesDialogOpen] = useState(false); const categoriesConst = [ "Criticality", @@ -465,6 +712,36 @@ const EditMatrixContent: React.FC = () => { } }, [isRoleBased, isHost, isParticipant]); + // Track changes to the updates array + useEffect(() => { + if (updates.length > 0) { + setHasUnsavedChanges(true); + } else { + setHasUnsavedChanges(false); + } + }, [updates, setHasUnsavedChanges]); + + // Reset unsaved changes after successful save + useEffect(() => { + if (successToast) { + setHasUnsavedChanges(false); + } + }, [successToast, setHasUnsavedChanges]); + + // Handle dialog actions + const handleStayOnPage = () => { + setUnsavedChangesDialogOpen(false); + setPendingNavigation(null); + }; + + const handleLeavePage = () => { + setUnsavedChangesDialogOpen(false); + if (pendingNavigation) { + navigate(pendingNavigation); + setPendingNavigation(null); + } + }; + // Compute displayed items based on role const displayedItems = useMemo(() => { if (!currentEmail) return rawItems; @@ -548,14 +825,14 @@ const EditMatrixContent: React.FC = () => { } // Format updates to send only the current user's scores - const formattedUpdates = updates.map((update: any) => { - const formattedUpdate: any = { itemId: update.itemId }; + const formattedUpdates = updates.map((update: MatrixUpdate) => { + const formattedUpdate: Record = { itemId: update.itemId }; // For each category in the update Object.entries(update).forEach(([key, value]) => { if (key !== 'itemId') { // If the value is an object with user scores, get current user's score - const scores = value as { [email: string]: number }; + const scores = value as Record; formattedUpdate[key] = scores[currentEmail || ''] || 0; } }); @@ -567,6 +844,7 @@ const EditMatrixContent: React.FC = () => { .put(`/api/carvermatrices/${matrixId}/carveritems/update`, formattedUpdates) .then(() => { setSuccessToast(true); + setHasUnsavedChanges(false); // Add delay before refreshing setTimeout(() => { window.location.reload(); @@ -578,7 +856,13 @@ const EditMatrixContent: React.FC = () => { }; const handleRefresh = () => { - window.location.reload(); + // Use handleNavigation instead of direct window.location.reload + if (hasUnsavedChanges && activeView === 'participant') { + setPendingNavigation(window.location.pathname); + setUnsavedChangesDialogOpen(true); + } else { + window.location.reload(); + } }; const handleViewChange = (_: React.SyntheticEvent, newValue: 'host' | 'participant') => { @@ -658,6 +942,22 @@ const EditMatrixContent: React.FC = () => { ); }; + // Add a useEffect to handle browser navigation events + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (hasUnsavedChanges && activeView === 'participant') { + e.preventDefault(); + e.returnValue = ''; + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, [hasUnsavedChanges, activeView]); + return ( { fontFamily: "'Roboto Condensed', sans-serif", }} > - {config.name || "Matrix Editor"} + {(config.name as string) || "Matrix Editor"} {activeView !== 'host' && ( @@ -1298,6 +1598,54 @@ const EditMatrixContent: React.FC = () => { )} + + {/* Unsaved Changes Dialog */} + + + Unsaved Changes + + + + You have unsaved changes. If you leave this page, your changes will be lost. Do you want to continue? + + + + + + + ); }; diff --git a/ui/src/pages/ViewMatrix.tsx b/ui/src/pages/ViewMatrix.tsx index b27c0ac..cbe5425 100644 --- a/ui/src/pages/ViewMatrix.tsx +++ b/ui/src/pages/ViewMatrix.tsx @@ -176,14 +176,16 @@ const ViewMatrix: React.FC = () => { console.log("Is host:", isHost); console.log("Is participant:", isParticipant); console.log("Is both:", isBoth); - console.log("Role filters:", roleFilters); console.log("Matches role:", matchesRole); - console.log("Matches search:", matchesSearch); - console.log("Final result:", matchesSearch && matchesRole); return matchesSearch && matchesRole; }); + // Sort matrices by timestamp (newest first) + const sortedMatrices = [...filteredMatrices].sort((a, b) => { + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); + const transformItemsForPdf = (items: CarverMatrix["items"]) => { return items.map((item) => { const getAverageScore = (scores: number | Record) => { @@ -525,8 +527,8 @@ const ViewMatrix: React.FC = () => { width: "100%", }} > - {filteredMatrices.length > 0 ? ( - filteredMatrices.map((matrix) => ( + {sortedMatrices.length > 0 ? ( + sortedMatrices.map((matrix) => (