diff --git a/backend/module/course/instructor/get.js b/backend/module/course/instructor/get.js new file mode 100644 index 00000000..3739d1ba --- /dev/null +++ b/backend/module/course/instructor/get.js @@ -0,0 +1,28 @@ +const express = require("express"); +const router = express.Router(); +const client = require("../../../setup/db"); +const helpers = require("../../../utilities/helpers"); + +router.get("/", (req, res) => { + console.log(`[+] RESPONSE Locals: ${res.locals["course_id"]}`); + if (res.locals["course_id"] === "") { + res.status(400).json({ message: "The course id is missing or invalid." }); + } + let sqlCourse = "SELECT * FROM course WHERE course_id = ($1)"; + client.query(sqlCourse, [res.locals["course_id"]], (err, pgRes) => { + if (err) { + res.status(404).json({ message: "Unknown error." }); + } else if (pgRes.rowCount === 1) { + res + .status(200) + .json({ + message: "Course details are returned.", + course: pgRes.rows[0], + }); + } else { + res.status(400).json({ message: "The course id is invalid." }); + } + }); +}); + +module.exports = router; diff --git a/backend/route/instructor.js b/backend/route/instructor.js index 0117733e..1763c01e 100644 --- a/backend/route/instructor.js +++ b/backend/route/instructor.js @@ -45,6 +45,7 @@ const manual_collect_submission = require("../module/submission/staff/collect_ma const download_submissions = require("../module/submission/staff/download"); const check_submission = require("../module/submission/staff/check"); const impersonate = require("../module/impersonate/instructor/impersonate"); +const get_course = require("../module/course/instructor/get"); router.use("/", function (req, res, next) { next(); @@ -53,6 +54,9 @@ router.use("/", function (req, res, next) { // Middleware router.use("/course/", middleware); +// Course Content +router.use("/course/:course_id/get", get_course); + // Role router.use("/course/:course_id/role/get", get_role); diff --git a/frontend/src/api/staff_api.js b/frontend/src/api/staff_api.js index f0bb2297..a97ccffe 100644 --- a/frontend/src/api/staff_api.js +++ b/frontend/src/api/staff_api.js @@ -143,6 +143,37 @@ let getAllMarks = async (courseId) => { } }; +/** + * Get current course's details for Admins and Instructors' use only. + * @param courseId string + * @returns {Promise|*|null>} + */ +const getCourseContent = async (courseId) => { + let token = sessionStorage.getItem('token'); + + const role = findRoleInCourse(courseId); + + let config = { + headers: { Authorization: `Bearer ${token}` } + }; + + let url = ''; + if (role === 'admin') { + url = `${process.env.REACT_APP_API_URL}/admin/course/get?course_id=${courseId}`; + } else if (role === 'instructor') { + url = `${process.env.REACT_APP_API_URL}/instructor/course/${courseId}/get`; + } else { + // insufficient access + return null; + } + + try { + return await axios.get(url, config); + } catch (err) { + return err.response; + } +}; + /** * GET All task group (Instructors + Admins) * @param courseId string @@ -275,7 +306,7 @@ let deleteTaskGroup = async (courseId, taskGroupId) => { return err.response; } }; - + const StaffApi = { get_students_in_course, getAllMarks, @@ -286,7 +317,8 @@ const StaffApi = { deleteTaskGroup, getCriteriaForTask, - all_tasks + all_tasks, + getCourseContent }; export default StaffApi; diff --git a/frontend/src/components/General/AggregatedGradesTable/AggregatedGradesTable.jsx b/frontend/src/components/General/AggregatedGradesTable/AggregatedGradesTable.jsx index 1baa3dbc..2a7dca3e 100644 --- a/frontend/src/components/General/AggregatedGradesTable/AggregatedGradesTable.jsx +++ b/frontend/src/components/General/AggregatedGradesTable/AggregatedGradesTable.jsx @@ -27,6 +27,7 @@ import FeatherIcon from 'feather-icons-react'; import CustomCheckbox from '../../FlexyMainComponents/forms/custom-elements/CustomCheckbox'; import CustomSwitch from '../../FlexyMainComponents/forms/custom-elements/CustomSwitch'; import GetMarksCsvButton from './GetMarksCsvButton'; +import TableSearchbar from './TableSearchbar'; function descendingComparator(a, b, orderBy) { if (b[orderBy] < a[orderBy]) { @@ -78,20 +79,28 @@ function AggregatedGradesTableHead(props) { padding={headCell.disablePadding ? 'none' : 'normal'} sortDirection={orderBy === headCell.id ? order : false} > - + {headCell.id !== 'finalGrade' ? ( + + + {headCell.label} + + {orderBy === headCell.id ? ( + + {order === 'desc' + ? 'sorted descending' + : 'sorted ascending'} + + ) : null} + + ) : ( {headCell.label} - {orderBy === headCell.id ? ( - - {order === 'desc' ? 'sorted descending' : 'sorted ascending'} - - ) : null} - + )} ))} @@ -110,53 +119,32 @@ AggregatedGradesTableHead.propTypes = { }; const AggregatedGradesTableToolbar = (props) => { - const { numSelected } = props; + const { originalRows, setCurrRows } = props; return ( 0 && { - bgcolor: (theme) => - alpha(theme.palette.primary.main, theme.palette.action.activatedOpacity) - }) + pr: { xs: 1, sm: 1 } }} > - {numSelected > 0 ? ( - - {numSelected} selected - - ) : ( - - Filter - - )} - - {numSelected > 0 ? ( - - - - - - ) : ( - - - - - + {originalRows.length > 0 && ( + )} ); }; AggregatedGradesTableToolbar.propTypes = { - numSelected: PropTypes.number.isRequired + // Original rows that are fetched from backend API call + originalRows: PropTypes.arrayOf(PropTypes.object).isRequired, + // To set the current state of rows + setCurrRows: PropTypes.func.isRequired }; const AggregatedGradesTable = ({ headCells, rows, tableWidth, courseId }) => { @@ -166,6 +154,12 @@ const AggregatedGradesTable = ({ headCells, rows, tableWidth, courseId }) => { const [page, setPage] = React.useState(0); const [dense, setDense] = React.useState(false); const [rowsPerPage, setRowsPerPage] = React.useState(5); + const [currRows, setCurrRows] = React.useState(rows === undefined ? [] : rows); + + React.useEffect(() => { + setCurrRows(rows); + console.log(rows); + }, [rows]); const handleRequestSort = (event, property) => { const isAsc = orderBy === property && order === 'asc'; @@ -175,7 +169,7 @@ const AggregatedGradesTable = ({ headCells, rows, tableWidth, courseId }) => { const handleSelectAllClick = (event) => { if (event.target.checked) { - const newSelecteds = rows.map((n) => n.student); + const newSelecteds = currRows.map((n) => n.student); setSelected(newSelecteds); return; } @@ -218,15 +212,18 @@ const AggregatedGradesTable = ({ headCells, rows, tableWidth, courseId }) => { const isSelected = (name) => selected.indexOf(name) !== -1; // Avoid a layout jump when reaching the last page with empty rows. - const emptyRows = page > 0 ? Math.max(0, (1 + page) * rowsPerPage - rows.length) : 0; + const emptyRows = page > 0 ? Math.max(0, (1 + page) * rowsPerPage - currRows.length) : 0; return ( - {/**/} + { orderBy={orderBy} onSelectAllClick={handleSelectAllClick} onRequestSort={handleRequestSort} - rowCount={rows.length} + rowCount={currRows.length} headCells={headCells} /> - {stableSort(rows, getComparator(order, orderBy)) + {stableSort(currRows, getComparator(order, orderBy)) .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) .map((row, index) => { const isItemSelected = isSelected(row.student); @@ -317,7 +314,7 @@ const AggregatedGradesTable = ({ headCells, rows, tableWidth, courseId }) => { : string } rows: PropTypes.array.isRequired, // Adjust width of table - tableWidth: PropTypes.number.isRequired + tableWidth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired }; export default AggregatedGradesTable; diff --git a/frontend/src/components/General/AggregatedGradesTable/GetMarksCsvButton.jsx b/frontend/src/components/General/AggregatedGradesTable/GetMarksCsvButton.jsx index 31370516..58dd91d6 100644 --- a/frontend/src/components/General/AggregatedGradesTable/GetMarksCsvButton.jsx +++ b/frontend/src/components/General/AggregatedGradesTable/GetMarksCsvButton.jsx @@ -60,7 +60,7 @@ const getMarksCsvButton = ({ courseId }) => { }; return ( - ); diff --git a/frontend/src/components/General/AggregatedGradesTable/TableSearchbar.jsx b/frontend/src/components/General/AggregatedGradesTable/TableSearchbar.jsx new file mode 100644 index 00000000..d09d3f75 --- /dev/null +++ b/frontend/src/components/General/AggregatedGradesTable/TableSearchbar.jsx @@ -0,0 +1,60 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Autocomplete, IconButton, InputAdornment, Stack, TextField } from '@mui/material'; + +const TableSearchbar = (props) => { + const { originalRows, setCurrRows, placeholder, width } = props; + + return ( + <> + option.student)} + onChange={(event, newValue, reason) => { + setCurrRows((prevState) => { + // We use original rows because prevState could've been changed from previous + // autocomplete + const newState = []; + if (Array.isArray(originalRows)) { + for (const studentObj of originalRows) { + if (studentObj.student === newValue) { + newState.push(studentObj); + } + } + } + console.log(newState); + return newState; + }); + if (reason === 'clear') { + setCurrRows(originalRows); + } + }} + renderInput={(params) => ( + + )} + sx={{ + width: width + }} + /> + + ); +}; + +TableSearchbar.propTypes = { + // Original rows which is fetched from backend (displays all possible rows) + originalRows: PropTypes.arrayOf(PropTypes.object).isRequired, + // setCurrRows useState callback function to set rows to be displayed on table + setCurrRows: PropTypes.func.isRequired, + // Placeholder text for the searchbar + placeholder: PropTypes.string.isRequired, + // Width of searchbar. Either string like '100%' or a numeric integer value. + width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired +}; + +export default TableSearchbar; diff --git a/frontend/src/components/Page/Instructor/AggregatedGrades.jsx b/frontend/src/components/Page/Instructor/AggregatedGrades.jsx index e3880894..3b2951bc 100644 --- a/frontend/src/components/Page/Instructor/AggregatedGrades.jsx +++ b/frontend/src/components/Page/Instructor/AggregatedGrades.jsx @@ -68,11 +68,30 @@ const AggregatedGrades = (props) => { ]); const { courseId } = useParams(); + const [courseName, setCourseName] = useState(''); const { data, isLoading, error } = useSWR('/mark/all', () => StaffApi.getAllMarks(courseId).then((res) => res.data) ); + const calculateFinalGrade = (tasks) => { + let finalGrade = 0; + for (const taskName in tasks) { + const taskData = tasks[taskName]; + const weight = taskData['weight']; + const taskScore = (taskData.mark / taskData.out_of) * weight; + finalGrade += taskScore; + } + return finalGrade; + }; + + useEffect(() => { + StaffApi.getCourseContent(courseId).then((res) => { + if (role === 'admin') setCourseName(res.data.course[0]['course_code']); + else setCourseName(res.data.course['course_code']); + }); + }, [courseId]); + useEffect(() => { if (isLoading || error) return; // StaffApi.getAllMarks(courseId).then((res) => { @@ -84,7 +103,8 @@ const AggregatedGrades = (props) => { // "student1": { // "task1": { // "mark": 12, - // "out_of": 57 + // "out_of": 57, + // "weight": 81 // } // } // } @@ -92,13 +112,17 @@ const AggregatedGrades = (props) => { const studentsArr = data.marks; // console.log(studentsArr); for (const student in studentsArr) { + let finalGrade = calculateFinalGrade(data.marks[student]); + for (const taskName in data.marks[student]) { setHeadCells((prevState) => { + const taskDataObj = data.marks[student][taskName]; + const weight = taskDataObj['weight']; const newState = { id: taskName, numeric: false, disablePadding: false, - label: taskName + label: `${taskName} (${weight}%)` }; for (const col of prevState) { if (col.id === taskName) { @@ -126,8 +150,29 @@ const AggregatedGrades = (props) => { idCounter++; return [...prevState, newRow]; }); + } // taskName iteration end + + // Add final grade for current student to rows state. + setRows((prevState) => { + return prevState.map((row) => { + if (row.student === student) { + return { ...row, finalGrade: finalGrade.toFixed(2) + '%' }; + } + return row; + }); + }); + } // student iteration end + + // Add final grades column only once + setHeadCells((prevState) => [ + ...prevState, + { + id: 'finalGrade', + numeric: false, + disablePadding: false, + label: 'Current Grade' } - } + ]); }, [courseId, navigate, data, isLoading, error]); const viewersRole = findRoleInCourse(courseId); @@ -150,7 +195,7 @@ const AggregatedGrades = (props) => { fontWeight="600" sx={{ ml: 3 }} > - All Grades for Course ID: {courseId} + All Grades of {courseName}