diff --git a/client/src/App.jsx b/client/src/App.jsx index ac6fda2..71ee91b 100755 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,5 +1,5 @@ -import { ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; +import { ToastContainer, Slide } from 'react-toastify'; import Router from "./Router"; import Loading from './components/Loading'; @@ -17,7 +17,13 @@ export default function App() { - + ) } diff --git a/client/src/Router.jsx b/client/src/Router.jsx index 5ade7f9..109d6f8 100755 --- a/client/src/Router.jsx +++ b/client/src/Router.jsx @@ -3,12 +3,13 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import Home from './pages/home/Home'; import Login from './pages/login/Login'; +import Retro from './pages/retro/Retro'; +import Group from './pages/groups/Group'; import RetroBoard from './pages/retro/RetroBoard'; import ServerUnavl from './pages/503/ServerUnavl'; import PageNotFound from './pages/404/PageNotFound'; import { AuthProvider } from './contexts/AuthContext'; import PrivateRoutes from './middleware/PrivateRoutes'; -import Retro from './pages/retro/Retro'; // Router component to render the application routes @@ -34,6 +35,7 @@ export default function Router() { } /> }> } /> + } /> } /> } /> diff --git a/client/src/components/group/GroupView.jsx b/client/src/components/group/GroupView.jsx deleted file mode 100644 index aa74f71..0000000 --- a/client/src/components/group/GroupView.jsx +++ /dev/null @@ -1,180 +0,0 @@ -/* eslint-disable react-hooks/exhaustive-deps */ -import PropTypes from 'prop-types'; -import { useEffect, useState } from 'react'; -import { - Dialog, DialogTitle, DialogContent, Button, IconButton, InputAdornment, - TableContainer, Paper, Table, TableHead, TableRow, TableCell, TableBody, TextField, -} from '@mui/material'; -import DeleteIcon from '@mui/icons-material/Delete'; - -import "./Group.css"; -import { useAuth } from '../../hooks/useAuth'; -import { useLoading } from '../../hooks/useLoading'; -import { toast } from 'react-toastify'; -import ConfirmPop from '../ConfirmPop'; - - -export default function GroupView({ openData, setOpenData, isOwner }) { - - const { http } = useAuth(); - const { setLoading } = useLoading(); - const [open, setOpen] = useState(false); - const [members, setMembers] = useState([]); - const [newMember, setNewMember] = useState(''); - const [tempLoad, setTempLoad] = useState(false); - - useEffect(() => { - if (!http.defaults.headers.common.Authorization) return null; - if (!openData?._id) return null; - - fetchMembers(); - }, [http, openData]); - - - const fetchMembers = async () => { - try { - setLoading(true); - const response = await http.get('/group/fetch/' + openData?._id); - setMembers(response?.data?.members); - } catch (error) { - console.error(error); - } finally { - setLoading(false); - } - }; - - - const addMember = async (email) => { - try { - setTempLoad(true); - const response = await http.post('/group/add-member', { email, groupId: openData?._id }); - toast.success(response?.data?.message ?? 'Member added successfully'); - setNewMember(''); - fetchMembers(); - } catch (error) { - console.error(error); - toast.error(error?.response?.data?.message ?? 'Failed to add member'); - } finally { - setTempLoad(false); - } - }; - - - const deleteMember = async (email) => { - try { - setLoading(true); - await http.delete('/group/delete-member', { data: { groupId: openData?._id, email } }); - toast.success('Member removed successfully'); - fetchMembers(); - } catch (error) { - console.error(error); - toast.error(error?.response?.data?.message ?? 'Failed to remove member'); - } finally { - setLoading(false); - } - }; - - - const deleteGroup = async () => { - try { - setOpen(false); - setLoading(true); - await http.delete(`/group/delete/${openData?._id}`); - toast.success('Group deleted successfully'); - localStorage.removeItem('group'); - setOpenData(null); - } catch (error) { - console.error(error); - toast.error(error?.response?.data?.message ?? 'Failed to delete group'); - } finally { - setLoading(false); - } - }; - - - return ( - setOpenData(null)} - > - {open && } - - Group Details | {openData?.name} - setOpenData(null)}>Close - - - - - - - # - Member Name - Member Email - Joined On - - - - - {members?.length > 0 && members.map((member, index) => ( - - {index + 1} - - {member?.name} - - {member?.email} - - {new Date(member?.createdAt).toLocaleString()} - - - deleteMember(member?.email)}> - - - - - ))} - - - - {isOwner && ( - - setNewMember(e.target.value)} - slotProps={{ - input: { - endAdornment: ( - - addMember(newMember)}> - ADD - - - ), - }, - }} - /> - setOpen(true)}> - DELETE GROUP - - - )} - - - ) -} - -GroupView.propTypes = { - openData: PropTypes.object.isRequired, - setOpenData: PropTypes.func.isRequired, - isOwner: PropTypes.bool.isRequired, -}; diff --git a/client/src/hooks/useGetRetroData.js b/client/src/hooks/useGetRetroData.js new file mode 100644 index 0000000..2b89cc1 --- /dev/null +++ b/client/src/hooks/useGetRetroData.js @@ -0,0 +1,23 @@ +import { useAuth } from "./useAuth"; +import { useLoading } from "./useLoading"; + +export const useGetRetroData = () => { + const { http } = useAuth(); + const { setLoading } = useLoading(); + + const getRetroData = async () => { + try { + setLoading(true); + const response = await http.get('/group/fetch'); + localStorage.setItem('retroData', JSON.stringify(response?.data)); + return response?.data; + } catch (error) { + console.error("Error fetching retro:", error); + return null; + } finally { + setLoading(false); + } + }; + + return { getRetroData }; +}; diff --git a/client/src/hooks/useRetroSocket.js b/client/src/hooks/useRetroSocket.js index 132d102..d732514 100755 --- a/client/src/hooks/useRetroSocket.js +++ b/client/src/hooks/useRetroSocket.js @@ -60,19 +60,16 @@ export const useRetroSocket = (retroId) => { // update mood const updateMood = (emoji) => { - console.log("UPDATING EMOJI", { emoji, email: userData?.email }); socket?.emit("updateMood", { emoji, email: userData?.email }); }; // add review const addReview = (column, comment) => { - console.log("ADDING REVIEW", { column, comment, email: userData?.email }); socket?.emit("addReview", { column, comment, email: userData?.email }); }; // update review const updateReview = (column, comment, index) => { - console.log("UPDATING REVIEW", { column, comment, index, email: userData?.email }); socket?.emit("updateReview", { column, comment, index, email: userData?.email }); }; diff --git a/client/src/layout/Header.jsx b/client/src/layout/Header.jsx index c7fc24a..7a05d68 100755 --- a/client/src/layout/Header.jsx +++ b/client/src/layout/Header.jsx @@ -9,8 +9,10 @@ import AdbIcon from '@mui/icons-material/Adb'; import MenuIcon from '@mui/icons-material/Menu'; import CloseIcon from '@mui/icons-material/Close'; import HomeIcon from '@mui/icons-material/Home'; +import GroupIcon from '@mui/icons-material/Group'; +import LogoutIcon from '@mui/icons-material/Logout'; import ReviewsIcon from '@mui/icons-material/Reviews'; -import DescriptionIcon from '@mui/icons-material/Description'; +import AccountCircleIcon from '@mui/icons-material/AccountCircle'; import './Header.css'; import { useAuth } from '../hooks/useAuth'; @@ -26,8 +28,7 @@ export default function Header() { const [popUser, setPopUser] = useState(null); const [openLogout, setOpenLogout] = useState(false); - const settings = ['Account', 'Logout']; - const isActive = (page) => location.pathname === '/' + page; + const isActive = (page) => location.pathname.split('/')[1] === page; const toggleDrawer = (page) => { setOpen(!open); @@ -42,8 +43,7 @@ export default function Header() { const handleCloseUserMenu = (setting) => { setPopUser(null); - if (setting === 'Account') navigate('/account'); - else if (setting === 'Logout') setOpenLogout(true); + if (setting === 'logout') setOpenLogout(true); }; return ( @@ -60,11 +60,14 @@ export default function Header() { navigate('/home')} className={isActive('home') ? "active-route" : "non-active-route"}> Home + navigate('/group')} className={isActive('group') ? "active-route" : "non-active-route"}> + Group + navigate('/retro')} className={isActive('retro') ? "active-route" : "non-active-route"}> Retro-Board - navigate('/journal')} className={isActive('journal') ? "active-route" : "non-active-route"}> - Reports + navigate('/account')} className={isActive('account') ? "active-route" : "non-active-route"}> + Account @@ -84,7 +87,7 @@ export default function Header() { - + @@ -105,11 +108,11 @@ export default function Header() { open={Boolean(popUser)} onClose={handleCloseUserMenu} > - {settings.map((setting) => ( - handleCloseUserMenu(setting)}> - {setting} - - ))} + handleCloseUserMenu("logout")}> + + Logout + + @@ -129,11 +132,14 @@ export default function Header() { toggleDrawer('home')} className={isActive('home') ? "pop-active" : "pop-non-active"}> Home + toggleDrawer('group')} className={isActive('group') ? "pop-active" : "pop-non-active"}> + Group + toggleDrawer('retro')} className={isActive('retro') ? "pop-active" : "pop-non-active"}> Retro-Board - toggleDrawer('journal')} className={isActive('journal') ? "pop-active" : "pop-non-active"}> - Reports + toggleDrawer('account')} className={isActive('account') ? "pop-active" : "pop-non-active"}> + Account diff --git a/client/src/middleware/PrivateRoutes.jsx b/client/src/middleware/PrivateRoutes.jsx index dfbf605..bd206be 100755 --- a/client/src/middleware/PrivateRoutes.jsx +++ b/client/src/middleware/PrivateRoutes.jsx @@ -10,14 +10,12 @@ import { useAuth } from '../hooks/useAuth'; // PrivateRoutes component to protect routes export default function PrivateRoutes() { - const { isAuthLoading, isAuthenticated, logout } = useAuth(); + const { isAuthLoading, isAuthenticated, http, logout } = useAuth(); useEffect(() => { - if (isAuthLoading) return; - if (!isAuthenticated) logout(); - - }, [isAuthLoading, isAuthenticated, logout]); + if (isAuthLoading || !http.defaults.headers.common.Authorization) return; + }, [isAuthLoading, isAuthenticated, http, logout]); if (!isAuthenticated) { return null; @@ -32,10 +30,16 @@ export default function PrivateRoutes() { // // <> - - - - + {!isAuthLoading && isAuthenticated && http.defaults.headers.common.Authorization ? ( + <> + + + + + > + ) : + null + } > ); }; diff --git a/client/src/components/group/Group.css b/client/src/pages/groups/Group.css old mode 100644 new mode 100755 similarity index 75% rename from client/src/components/group/Group.css rename to client/src/pages/groups/Group.css index 7dfd4ef..0fb202f --- a/client/src/components/group/Group.css +++ b/client/src/pages/groups/Group.css @@ -1,17 +1,18 @@ -.group-view-title { - display: flex; - align-items: center; - justify-content: space-between; -} - -.group-view-items { - width: 100%; - margin-top: 15px; - display: flex; - align-items: center; - justify-content: space-between; - - .group-view-textfield { - width: 80%; - } +.group-description { + display: flex; + padding: 0 10px; + margin-bottom: 10px; + justify-content: space-between; +} + +.group-view-items { + width: 100%; + margin-top: 15px; + display: flex; + align-items: center; + justify-content: space-between; + + .group-view-textfield { + width: 80%; + } } \ No newline at end of file diff --git a/client/src/pages/groups/Group.jsx b/client/src/pages/groups/Group.jsx new file mode 100644 index 0000000..c6bdd22 --- /dev/null +++ b/client/src/pages/groups/Group.jsx @@ -0,0 +1,132 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { useEffect, useState, useCallback } from 'react'; +import Grid from '@mui/material/Grid2'; +import { + Typography, Container, Divider, Card, Accordion, AccordionSummary, AccordionDetails, Button +} from '@mui/material'; +import GroupAddIcon from '@mui/icons-material/GroupAdd'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; + +import './Group.css'; +import GroupAdd from './GroupAdd'; +import GroupMembers from './GroupMembers'; +import { useAuth } from '../../hooks/useAuth'; +import { useLoading } from '../../hooks/useLoading'; +import { useGetRetroData } from '../../hooks/useGetRetroData'; + +// Retro page component +export default function Group() { + document.title = "Retro | Group"; + + const { setLoading } = useLoading(); + const { http, userData } = useAuth(); + const { getRetroData } = useGetRetroData(); + + const [groups, setGroups] = useState([]); + const [members, setMembers] = useState({}); + const [openAdd, setOpenAdd] = useState(false); + const [expanded, setExpanded] = useState(null); + const [groupChanged, setGroupChanged] = useState(false); + + // Fetch members when a group is expanded + const fetchMembers = useCallback(async (groupId) => { + if (!groupId) return; + + setLoading(true); + try { + const response = await http.get(`/group/fetch/${groupId}`); + setMembers((prev) => ({ ...prev, [groupId]: response?.data?.members || [] })); + } catch (error) { + console.error("Error fetching members:", error); + } finally { + setLoading(false); + } + }, [http, setLoading, members]); + + + useEffect(() => { + const localData = JSON.parse(localStorage.getItem('retroData'))?.groups ?? null; + if (localData) { + setGroups(localData); + setExpanded(localData[0]?._id || null); + } else { + getRetroData().then((data) => { + if (data?.groups?.length > 0) { + setGroups(data.groups); + setExpanded(data.groups[0]?._id || null); + } else { + setGroups([]); + } + }); + } + if (groupChanged) setGroupChanged(false); + }, [openAdd, groupChanged]); + + useEffect(() => { + if (expanded) { + fetchMembers(expanded); + } + }, [expanded]); + + return ( + + {openAdd && } + + + Your Groups 📝 + + + setOpenAdd(true)}> + Create New + + + + + + + {groups.length > 0 ? ( + groups.map((group) => ( + setExpanded(group._id)} + > + }> + + + Group Name: {group.name} + + + [{group?.status}] + + + + + + + Owner: {group?.ownerEmail} + + + Created On: {new Date(group?.createdAt).toLocaleString()} + + + fetchMembers(group._id)} + /> + + + )) + ) : ( + + No groups available. Create a new one! + + )} + + + ); +}; diff --git a/client/src/components/group/GroupAdd.jsx b/client/src/pages/groups/GroupAdd.jsx similarity index 53% rename from client/src/components/group/GroupAdd.jsx rename to client/src/pages/groups/GroupAdd.jsx index 39486e8..e4275dc 100644 --- a/client/src/components/group/GroupAdd.jsx +++ b/client/src/pages/groups/GroupAdd.jsx @@ -1,9 +1,47 @@ -import PropTypes from 'prop-types'; import { Dialog, DialogTitle, DialogContent, DialogContentText, TextField, DialogActions, Button } from '@mui/material'; +import PropTypes from 'prop-types'; +import { toast } from 'react-toastify'; + +import { useAuth } from '../../hooks/useAuth'; +import { useLoading } from '../../hooks/useLoading'; + + +export default function GroupAdd({ openAdd, setOpenAdd }) { + + const { http } = useAuth(); + const { setLoading } = useLoading(); + + const handleGroupAdd = async (data) => { + const rRegex = /^(?:["'])|(?:["'])$/g; + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + + const members = data.members + .split(',') + .map(email => email.trim().replace(rRegex, '')) // Now correctly removes both leading & trailing quotes + .filter(email => emailRegex.test(email)); + + if (members.length === 0 || data.members.trim() === "" || members.some(email => email === "")) { + toast.info("Invalid email(s) entered. Please check and try again."); + return; + } -export default function GroupAdd({ openAdd, setOpenAdd, handleAdd }) { + try { + setLoading(true); + const response = await http.post('/group/add', { name: data.name, members }); + localStorage.removeItem('retroData'); + toast.success("Group added successfully!"); + setOpenAdd(false); + if (response.data?.memberNotFound?.length > 0) + toast.info(`The following members were not found: ${response.data.memberNotFound.join(', ')}`); + } catch (error) { + toast.error(error.response?.data?.message || "An error occurred. Please try again later."); + console.error(error); + } finally { + setLoading(false); + } + }; return ( { + if (!newMember.trim()) return toast.warn('Please enter an email'); + + setTempLoad(true); + try { + const response = await http.post('/group/add-member', { email: newMember, groupId }); + toast.success(response?.data?.message ?? 'Member added successfully'); + setNewMember(''); + refreshMembers(); + } catch (error) { + console.error("Error adding member:", error); + toast.error(error?.response?.data?.message ?? 'Failed to add member'); + } finally { + setTempLoad(false); + } + }; + + const deleteMember = async (email) => { + setLoading(true); + try { + await http.delete('/group/delete-member', { data: { groupId, email } }); + toast.success('Member removed successfully'); + refreshMembers(); + } catch (error) { + console.error("Error removing member:", error); + toast.error(error?.response?.data?.message ?? 'Failed to remove member'); + } finally { + setLoading(false); + } + }; + + const deleteGroup = async () => { + setLoading(true); + try { + await http.delete(`/group/delete/${groupId}`); + toast.success('Group deleted successfully'); + localStorage.removeItem('retroData'); + setGroupChanged(true); + } catch (error) { + console.error("Error deleting group:", error); + toast.error(error?.response?.data?.message ?? 'Failed to delete group'); + } finally { + setLoading(false); + setOpen(false); + } + }; + + return ( + + {open && } + + + + + + # + Member Name + Member Email + Joined On + {isOwner && Actions} + + + + {members.map((member, index) => ( + + {index + 1} + {member.name} + {member.email} + {new Date(member.createdAt).toLocaleString()} + {isOwner && ( + + deleteMember(member.email)}> + + + + )} + + ))} + + + + + {isOwner && ( + + setNewMember(e.target.value)} + slotProps={{ + input: { + endAdornment: ( + + addMember()}> + ADD + + + ), + }, + }} + /> + setOpen(true)}> + DELETE GROUP + + + )} + + ); +} + +GroupMembers.propTypes = { + isOwner: PropTypes.bool.isRequired, + members: PropTypes.array.isRequired, + groupId: PropTypes.string.isRequired, + refreshMembers: PropTypes.func.isRequired, + setGroupChanged: PropTypes.func.isRequired +}; diff --git a/client/src/pages/home/Home.css b/client/src/pages/home/Home.css index 76fdc16..6faa9ab 100644 --- a/client/src/pages/home/Home.css +++ b/client/src/pages/home/Home.css @@ -9,6 +9,11 @@ padding: 10px; } +.home-table { + max-height: 65vh; + overflow-y: auto; +} + .home-group-header { display: flex; align-items: center; diff --git a/client/src/pages/home/Home.jsx b/client/src/pages/home/Home.jsx index d32bf47..ba05bdc 100755 --- a/client/src/pages/home/Home.jsx +++ b/client/src/pages/home/Home.jsx @@ -1,21 +1,20 @@ /* eslint-disable react-hooks/exhaustive-deps */ import Grid from '@mui/material/Grid2'; import { - Button, Card, Container, Divider, Typography, Paper, Table, Tooltip, - TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton + Button, Card, Container, Divider, Typography, Paper, Table, Tooltip, TableBody, TableCell, + TableContainer, TableHead, TableRow, IconButton, Menu, MenuItem } from "@mui/material"; -import DeleteIcon from '@mui/icons-material/Delete'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; import { DotLottieReact } from '@lottiefiles/dotlottie-react'; import { useNavigate } from 'react-router-dom'; import { useEffect, useState } from 'react'; import { toast } from 'react-toastify'; import './Home.css'; +import RetroAdd from './RetroAdd'; import { useAuth } from '../../hooks/useAuth'; import { useLoading } from '../../hooks/useLoading'; -import GroupAdd from '../../components/group/GroupAdd'; -import GroupView from '../../components/group/GroupView'; -import RetroAdd from '../../components/retro/RetroAdd'; +import { useGetRetroData } from '../../hooks/useGetRetroData'; // Home page component @@ -25,78 +24,55 @@ export default function Home() { const navigate = useNavigate(); const { setLoading } = useLoading(); const { http, userData } = useAuth(); - const [grData, setGRData] = useState(null); - const [openRAdd, setOpenRAdd] = useState(null); - const [openGAdd, setOpenGAdd] = useState(false); - const [openGView, setOpenGView] = useState(null); + const { getRetroData } = useGetRetroData(); + const [data, setData] = useState(null); + const [openAdd, setOpenAdd] = useState(null); + const [anchorEl, setAnchorEl] = useState(null); + const [selectedRetro, setSelectedRetro] = useState(null); + + const retroOptions = ["Open Retro", "Edit Retro", "Delete Retro"]; - useEffect(() => { - if (!http.defaults.headers.common.Authorization) { - return; - } + useEffect(() => { const localData = JSON.parse(localStorage.getItem('retroData')) ?? null; if (localData) { - setGRData(localData); + setData(localData); return; } - handleFetchData(); - }, [http.defaults.headers.common.Authorization]); + }, [openAdd]); const handleFetchData = async () => { - try { - setLoading(true); - const response = await http.get('/group/fetch'); - setGRData(response?.data); - localStorage.setItem('retroData', JSON.stringify(response?.data)); - } catch (error) { - console.error(error); - } finally { - setLoading(false); - } + getRetroData().then((response) => { + setData(response); + }); }; + const handleMenuOpen = (event, retro) => { + setAnchorEl(event.currentTarget); + setSelectedRetro(retro); + }; - const handleGroupAdd = async (data) => { - const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; - - const members = data.members - .split(',') - .map(email => email.trim().replace(/^["']|["']$/g, '')) - .filter(email => emailRegex.test(email)); - - if (members.length === 0 || data.members.trim() === "" || members.some(email => email === "")) { - toast.info("Invalid email(s) entered. Please check and try again."); - return; - } - - try { - setLoading(true); - const response = await http.post('/group/add', { name: data.name, members }); - setOpenGAdd(false); - handleFetchData(); - toast.success("Group added successfully!"); - if (response.data?.memberNotFound?.length > 0) - toast.info(`The following members were not found: ${response.data.memberNotFound.join(', ')}`); - } catch (error) { - toast.error(error.response?.data?.message || "An error occurred. Please try again later."); - console.error(error); - } finally { - setLoading(false); + const handleMenuClose = (option) => { + if (option === "Open Retro") { + navigate(`/retro/${selectedRetro?._id}`); + } else if (option === "Edit Retro") { + toast.info("Feature coming soon!"); + } else if (option === "Delete Retro") { + handleDeleteRetro(selectedRetro?._id); } + setAnchorEl(null); + setSelectedRetro(null); }; - - const handleRetroAdd = async (data) => { + const handleDeleteRetro = async (retroId) => { try { setLoading(true); - const response = await http.post('/retro/add', data); - setOpenRAdd(null); + await http.delete(`/retro/delete/${retroId}`); handleFetchData(); - toast.success(response?.data?.message ?? "Retro created successfully!"); + toast.success("Retro deleted successfully!"); } catch (error) { toast.error(error.response?.data?.message || "An error occurred. Please try again later."); console.error(error); @@ -108,9 +84,7 @@ export default function Home() { return ( - {openGAdd && } - {openRAdd !== null && } - {openGView !== null && } + {openAdd !== null && } 👋 Hello {userData ? userData?.name.split(" ")[0] : "Guest"}, @@ -138,92 +112,65 @@ export default function Home() { - - Your Groups 🌟 - - setOpenGAdd(!openGAdd)}> - + Create New - - - - - - - - # - Group Name - Owner - Created On - - - - {grData?.groups?.length > 0 && grData?.groups?.map((group, index) => ( - setOpenGView(group)}> - {index + 1} - - {group?.name} - - {group?.ownerEmail} - - {new Date(group?.createdAt).toLocaleString()} - - - ))} - - - - - - - - - + Your Retros 📝 - setOpenRAdd(grData?.groups)}> + setOpenAdd(data?.groups)}> + Create New - + - # + # Retro Name - Group - Status + Group + Status Created On - Action + - {grData?.retros?.length > 0 && grData?.retros?.map((retro, index) => ( - - {index + 1} - navigate(`/retro/${retro?._id}`)}> - {retro?.name} - - - {grData?.groups?.find(group => group._id === retro?.group)?.name} - - {retro?.status} - - {new Date(retro?.createdAt).toLocaleString()} - - - - - + {data?.retros?.slice() + .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + .map((retro, index) => ( + + {index + 1} + navigate(`/retro/${retro?._id}`)}> + {retro?.name} + + + {data?.groups?.find(group => group._id === retro?.group)?.name} + + + + {retro?.status} + + + {new Date(retro?.createdAt).toLocaleString()} + + + handleMenuOpen(e, retro)}> + + - - - - ))} + + + ))} + + + {retroOptions.map(option => ( + handleMenuClose(option)}> + {option} + + ))} + diff --git a/client/src/components/retro/RetroAdd.jsx b/client/src/pages/home/RetroAdd.jsx similarity index 71% rename from client/src/components/retro/RetroAdd.jsx rename to client/src/pages/home/RetroAdd.jsx index ce7cea2..04860ff 100644 --- a/client/src/components/retro/RetroAdd.jsx +++ b/client/src/pages/home/RetroAdd.jsx @@ -1,14 +1,36 @@ +import { useState } from 'react'; import PropTypes from 'prop-types'; +import { toast } from 'react-toastify'; import { Dialog, DialogTitle, DialogContent, DialogContentText, TextField, DialogActions, Button, FormControl, InputLabel, Select, MenuItem } from '@mui/material'; -import { useState } from 'react'; -export default function RetroAdd({ openAdd, setOpenAdd, handleAdd }) { +import { useAuth } from '../../hooks/useAuth'; +import { useLoading } from '../../hooks/useLoading'; + +export default function RetroAdd({ openAdd, setOpenAdd }) { + + const { http } = useAuth(); + const { setLoading } = useLoading(); const [group, setGroup] = useState(''); + const handleRetroAdd = async (data) => { + try { + setLoading(true); + const response = await http.post('/retro/add', data); + localStorage.removeItem('retroData'); + setOpenAdd(null); + toast.success(response?.data?.message ?? "Retro created successfully!"); + } catch (error) { + toast.error(error.response?.data?.message || "An error occurred. Please try again later."); + console.error(error); + } finally { + setLoading(false); + } + } + return ( diff --git a/client/src/pages/retro/Retro.jsx b/client/src/pages/retro/Retro.jsx index 8a5ab5e..7793a62 100644 --- a/client/src/pages/retro/Retro.jsx +++ b/client/src/pages/retro/Retro.jsx @@ -1,70 +1,70 @@ import { useEffect } from 'react'; -import Grid from '@mui/material/Grid2'; import { - Typography, Container, Divider, Card, Accordion, AccordionSummary, - AccordionDetails, Button + Typography, Container, Divider, Card, Accordion, AccordionSummary, AccordionDetails, Button } from '@mui/material'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { useNavigate } from 'react-router-dom'; import './Retro.css'; - // Retro page component export default function Retro() { - document.title = "Retro | Boards"; + const navigate = useNavigate(); - const locaData = JSON.parse(localStorage.getItem('retroData')) ?? null; + const locaData = JSON.parse(localStorage.getItem('retroData') || '{}'); useEffect(() => { - - if (!locaData) return; - + if (!locaData?.retros?.length) return; }, [locaData]); - return ( - - - - Active Retro-Boards | List - - - + + + Looking for a retro board? 🤔 + - - - Current ongoing sprints are listed below - - {locaData?.retros?.length > 0 && locaData?.retros?.map((retro) => ( - - }> - - - Retro Name: {retro.name} - - [{retro?.status}] - - - - - Belongs to Group - - {locaData?.groups?.find(group => group._id === retro?.group)?.name} - - - Created On - {new Date(retro?.createdAt).toLocaleString()} - - - navigate(`/retro/${retro._id}`)}> - Open {retro.name} - - - - ))} + + {locaData?.retros?.length > 0 ? ( + locaData?.retros?.slice() + .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + .map((retro) => ( + retro.status !== 'completed' && ( + + }> + + + Retro Name: {retro.name} + + + [{retro?.status}] + + + + + + Belongs to Group -{' '} + {locaData?.groups?.find(group => group._id === retro?.group)?.name || 'Unknown'} + + + Created On - {new Date(retro?.createdAt).toLocaleString()} + + + navigate(`/retro/${retro._id}`)}> + Open {retro.name} + + + + ) + )) + ) : ( + + No Retro-Board available, please create a new one. + + )} ); diff --git a/client/src/pages/retro/RetroBoard.jsx b/client/src/pages/retro/RetroBoard.jsx index 3ceacf5..4f7be79 100755 --- a/client/src/pages/retro/RetroBoard.jsx +++ b/client/src/pages/retro/RetroBoard.jsx @@ -1,10 +1,11 @@ /* eslint-disable react-hooks/exhaustive-deps */ import Grid from '@mui/material/Grid2'; +import { toast } from 'react-toastify'; import Typewriter from "typewriter-effect"; -import { Typography, Container, Divider, Card, Button } from '@mui/material'; -import { useNavigate } from 'react-router-dom'; import { useEffect, useState } from 'react'; -import { toast } from 'react-toastify'; +import { useNavigate } from 'react-router-dom'; +import { Typography, Container, Divider, Card, Button } from '@mui/material'; +import CloudDownloadIcon from '@mui/icons-material/CloudDownload'; import './Retro.css'; import RetroMood from './RetroMood'; @@ -19,8 +20,9 @@ export default function RetroBoard() { const navigate = useNavigate(); const { setLoading } = useLoading(); - const [rData, setRData] = useState(null); const { userData, http } = useAuth(); + const [rData, setRData] = useState(null); + const [isCompleted, setIsCompleted] = useState(false); document.title = `Retro | ${rData?.retro?.name}`; const retroId = window.location.pathname.split('/').pop() ?? null; @@ -35,6 +37,11 @@ export default function RetroBoard() { setLoading(true); const response = await http.get(`/retro/${retroId}`); setRData(response?.data); + if (response?.data?.retro?.status === 'completed') { + setIsCompleted(true); + localStorage.removeItem('retroData'); + toast.info('Retro has been completed, you can view the data but cannot edit.'); + } } catch (error) { console.error(error); if (error?.response?.data?.message === 'Invalid Path') { @@ -48,6 +55,7 @@ export default function RetroBoard() { } }; fetchIntialData(); + }, [http.defaults.headers.common.Authorization, retroId]); @@ -59,6 +67,7 @@ export default function RetroBoard() { setLoading(true); await http.patch(`/retro/status/${retroId}`); toast.success('Retro completed successfully'); + localStorage.removeItem('retroData'); setTimeout(() => { navigate('/home', { replace: true }); }, 100); @@ -71,6 +80,11 @@ export default function RetroBoard() { }; + const exportRetroData = () => { + toast.info("Feature coming soon!"); + } + + return ( @@ -97,7 +111,7 @@ export default function RetroBoard() { ], autoStart: true, loop: true, - deleteSpeed: 50, + deleteSpeed: 30, }} /> @@ -111,17 +125,24 @@ export default function RetroBoard() { justifyContent: { xs: "center", md: "flex-end" }, }} > - + - - + + {isCompleted && + {rData?.retro?.name} Retrospective has been completed. + } addReview('startDoing', text)} updateReview={(text, index) => updateReview('startDoing', text, index)} @@ -129,6 +150,7 @@ export default function RetroBoard() { addReview('stopDoing', text)} updateReview={(text, index) => updateReview('stopDoing', text, index)} @@ -136,6 +158,7 @@ export default function RetroBoard() { addReview('continueDoing', text)} updateReview={(text, index) => updateReview('continueDoing', text, index)} @@ -143,15 +166,25 @@ export default function RetroBoard() { addReview('appreciation', text)} updateReview={(text, index) => updateReview('appreciation', text, index)} /> - completeRetro()}> - Complete Retro {rData?.retro?.name} - + + {!isCompleted && + completeRetro()}> + Complete Retro {rData?.retro?.name} + + } + {isCompleted && + + Download Retro Report + + } ); diff --git a/client/src/pages/retro/RetroMood.jsx b/client/src/pages/retro/RetroMood.jsx index d5eede1..161fcad 100755 --- a/client/src/pages/retro/RetroMood.jsx +++ b/client/src/pages/retro/RetroMood.jsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import Grid from '@mui/material/Grid2'; -import { Button } from '@mui/material'; +import { Button, Tooltip } from '@mui/material'; import { styled } from '@mui/material/styles'; import Badge, { badgeClasses } from '@mui/material/Badge'; @@ -17,12 +17,13 @@ const CartBadge = styled(Badge)` // RetroMood component -export default function RetroMood({ moods, updateMood }) { +export default function RetroMood({ moods, isCompleted, updateMood }) { const { userData } = useAuth(); const [curEmoji, setCurEmoji] = useState(null); const onMoodUpdate = (emoji) => { + if (isCompleted) return; setCurEmoji(emoji); updateMood(emoji); }; @@ -41,19 +42,22 @@ export default function RetroMood({ moods, updateMood }) { return ( {moods.map((data) => ( - - { onMoodUpdate(data?.emoji) }} - style={{ fontSize: '2rem' }} - > - - {data?.emoji} - + + + { onMoodUpdate(data?.emoji) }} + style={{ fontSize: '2rem' }} + disabled={isCompleted} + > + + {data?.emoji} + + ))} @@ -62,5 +66,6 @@ export default function RetroMood({ moods, updateMood }) { RetroMood.propTypes = { moods: PropTypes.array.isRequired, + isCompleted: PropTypes.bool.isRequired, updateMood: PropTypes.func.isRequired, }; diff --git a/client/src/pages/retro/RetroReview.jsx b/client/src/pages/retro/RetroReview.jsx index 3a379ae..ee1bc6c 100755 --- a/client/src/pages/retro/RetroReview.jsx +++ b/client/src/pages/retro/RetroReview.jsx @@ -6,15 +6,19 @@ import { } from '@mui/material'; import CloseIcon from '@mui/icons-material/Close'; import CheckIcon from '@mui/icons-material/Check'; +import { useAuth } from '../../hooks/useAuth'; +import { toast } from 'react-toastify'; -export default function RetroColumn({ title, data, addReview, updateReview }) { +export default function RetroColumn({ title, isCompleted, data, addReview, updateReview }) { + const { userData } = useAuth(); const [isAddNew, setIsAddNew] = useState(false); const [editedValue, setEditedValue] = useState(''); const [editingIndex, setEditingIndex] = useState(null); const handleEdit = (index, item) => { + if (isCompleted) return; setEditedValue(item.comment); setEditingIndex(index); setIsAddNew(false); @@ -27,6 +31,7 @@ export default function RetroColumn({ title, data, addReview, updateReview }) { }; const handleSave = () => { + if (isCompleted) return; addReview(editedValue); setEditingIndex(null); setEditedValue(''); @@ -34,6 +39,9 @@ export default function RetroColumn({ title, data, addReview, updateReview }) { }; const handleUpdate = (item, index) => { + if (isCompleted) return; + if (item.email !== userData?.email) + toast.error('You can only edit your own comments.'); updateReview(editedValue, index); setEditingIndex(null); setEditedValue(''); @@ -48,7 +56,7 @@ export default function RetroColumn({ title, data, addReview, updateReview }) { {data.map((item, index) => ( - + {editingIndex === index ? ( - {isAddNew ? ( + {isAddNew && !isCompleted ? ( ) : ( { setIsAddNew(true); setEditedValue(''); @@ -121,6 +130,7 @@ export default function RetroColumn({ title, data, addReview, updateReview }) { RetroColumn.propTypes = { title: PropTypes.string.isRequired, + isCompleted: PropTypes.bool.isRequired, data: PropTypes.array.isRequired, addReview: PropTypes.func.isRequired, updateReview: PropTypes.func.isRequired diff --git a/server/src/controller/group.controller.mjs b/server/src/controller/group.controller.mjs index 569dbc1..0532ffc 100644 --- a/server/src/controller/group.controller.mjs +++ b/server/src/controller/group.controller.mjs @@ -2,6 +2,7 @@ import UserModel from "../models/user.model.mjs"; import GroupModel from "../models/group.model.mjs"; import RetroModel from "../models/retro.model.mjs"; import MemberModel from "../models/member.model.mjs"; +import RetroBoardModel from "../models/board.model.mjs"; import { validateFields } from "../utils/validate.mjs"; @@ -66,7 +67,7 @@ const createGroup = async (req, res, next) => { // fetch my groups -const fetchMyGroups = async (req, res, next) => { +const fetchGroupRetros = async (req, res, next) => { try { const groups = await MemberModel.find({ user: req.currentUser }) .populate("group") @@ -196,7 +197,13 @@ const deleteGroup = async (req, res, next) => { return res.status(401).json({ message: "You are not authorized to delete this group" }); } + const retros = await RetroModel.find({ group: groupId }); + for (const retro of retros) { + await RetroBoardModel.deleteOne({ retroId: retro._id }); + } + await MemberModel.deleteMany({ group: groupId }); + await RetroModel.deleteMany({ group: groupId }); await GroupModel.deleteOne({ _id: groupId }); return res.status(204).send(); @@ -207,4 +214,4 @@ const deleteGroup = async (req, res, next) => { // exporting functions -export { createGroup, fetchMyGroups, fetchGroupMembers, addMember, deleteMember, deleteGroup }; +export { createGroup, fetchGroupRetros, fetchGroupMembers, addMember, deleteMember, deleteGroup }; diff --git a/server/src/controller/retro.controller.mjs b/server/src/controller/retro.controller.mjs index 521546b..d7dfc38 100644 --- a/server/src/controller/retro.controller.mjs +++ b/server/src/controller/retro.controller.mjs @@ -1,6 +1,7 @@ import GroupModel from "../models/group.model.mjs"; import RetroModel from "../models/retro.model.mjs"; import MemberModel from "../models/member.model.mjs"; +import RetroBoardModel from "../models/board.model.mjs"; import { validateFields, santizeId } from "../utils/validate.mjs"; @@ -70,15 +71,15 @@ const deleteRetro = async (req, res, next) => { if (!fieldValidation.isValid) return res.status(400).json({ message: fieldValidation.message }); - const retro = await RetroModel.findById(retroId); if (!retro) return res.status(404).json({ message: "Retro not found" }); const group = await GroupModel.findById(retro.group); - if (group.createdBy.toString() !== req.currentUser) - return res.status(401).json({ message: "You are not authorized to delete this retro" }); + if (group.createdBy.toString() !== req.currentUser.toString()) + return res.status(403).json({ message: "You are not authorized to delete this retro" }); + await RetroBoardModel.deleteMany({ retroId: retroId }); await RetroModel.findByIdAndDelete(retroId); return res.status(204).send(); } catch (error) { diff --git a/server/src/models/board.model.mjs b/server/src/models/board.model.mjs index afbc069..eadfc81 100644 --- a/server/src/models/board.model.mjs +++ b/server/src/models/board.model.mjs @@ -8,11 +8,6 @@ const ReviewSchema = new mongoose.Schema({ const RetroBoardModel = new mongoose.Schema({ - // retroId: { - // type: mongo.Schema.Types.ObjectId, - // ref: 'RetroModel', - // required: true - // }, retroId: { type: String, required: true, diff --git a/server/src/routes/group.route.mjs b/server/src/routes/group.route.mjs index fc7c44e..b7e68bb 100644 --- a/server/src/routes/group.route.mjs +++ b/server/src/routes/group.route.mjs @@ -1,5 +1,5 @@ import express from "express"; -import { addMember, createGroup, deleteGroup, deleteMember, fetchGroupMembers, fetchMyGroups } from "../controller/group.controller.mjs"; +import { addMember, createGroup, deleteGroup, deleteMember, fetchGroupMembers, fetchGroupRetros } from "../controller/group.controller.mjs"; const groupRoute = express.Router(); @@ -9,7 +9,7 @@ const groupRoute = express.Router(); groupRoute.post("/add", createGroup); // fetch my groups route -groupRoute.get("/fetch", fetchMyGroups); +groupRoute.get("/fetch", fetchGroupRetros); // fetch group members route groupRoute.get("/fetch/:groupId", fetchGroupMembers); diff --git a/server/src/routes/retro.route.mjs b/server/src/routes/retro.route.mjs index eff2f1b..a635d52 100644 --- a/server/src/routes/retro.route.mjs +++ b/server/src/routes/retro.route.mjs @@ -17,5 +17,6 @@ retroRoute.delete("/delete/:retroId", deleteRetro); // mark as completed route retroRoute.patch("/status/:retroId", completeRetro); + // exporting the retroRoute export default retroRoute; diff --git a/server/src/websocket/addReview.mjs b/server/src/websocket/addReview.mjs index 240fcec..1aae339 100644 --- a/server/src/websocket/addReview.mjs +++ b/server/src/websocket/addReview.mjs @@ -17,6 +17,5 @@ export async function addReview(retroId, data) { } const retroData = await RetroBoardModel.findOne({ retroId }); - console.log("Add Review:", retroData.reviews); return retroData.reviews; } diff --git a/server/src/websocket/updateMood.mjs b/server/src/websocket/updateMood.mjs index d300560..68e4782 100644 --- a/server/src/websocket/updateMood.mjs +++ b/server/src/websocket/updateMood.mjs @@ -44,6 +44,5 @@ export async function updateMood(retroId, data) { ); const retroData = await RetroBoardModel.findOne({ retroId }); - console.log("Updated Emoji:", retroData.moods); return retroData.moods; } diff --git a/server/src/websocket/updateReview.mjs b/server/src/websocket/updateReview.mjs index 7b9109c..1020d16 100644 --- a/server/src/websocket/updateReview.mjs +++ b/server/src/websocket/updateReview.mjs @@ -24,6 +24,5 @@ export async function updateReview(retroId, data) { } const postData = await RetroBoardModel.findOne({ retroId }); - console.log("Updated Review:", postData.reviews); return postData.reviews; }
Belongs to Group - - {locaData?.groups?.find(group => group._id === retro?.group)?.name} -
- Created On - {new Date(retro?.createdAt).toLocaleString()} -