diff --git a/backend/project/endpoints/authentication/auth.py b/backend/project/endpoints/authentication/auth.py index ab59ef5b..86bf96dc 100644 --- a/backend/project/endpoints/authentication/auth.py +++ b/backend/project/endpoints/authentication/auth.py @@ -45,11 +45,6 @@ def microsoft_authentication(): res = requests.post(f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token", data=data, timeout=5) - if res.status_code != 200: - abort(make_response(( - {"message": - "An error occured while trying to authenticate your authorization code"}, - 500))) token = res.json()["access_token"] profile_res = requests.get("https://graph.microsoft.com/v1.0/me", headers={"Authorization":f"Bearer {token}"}, diff --git a/backend/project/endpoints/authentication/me.py b/backend/project/endpoints/authentication/me.py index c4f19a70..bd43d8aa 100644 --- a/backend/project/endpoints/authentication/me.py +++ b/backend/project/endpoints/authentication/me.py @@ -23,7 +23,7 @@ def get(self): """ Will return all user data associated with the access token in the request """ - uid = get_jwt_identity + uid = get_jwt_identity() return query_by_id_from_model(User, "uid", diff --git a/backend/project/endpoints/courses/join_codes/course_join_codes.py b/backend/project/endpoints/courses/join_codes/course_join_codes.py index a2401783..1eb7e00c 100644 --- a/backend/project/endpoints/courses/join_codes/course_join_codes.py +++ b/backend/project/endpoints/courses/join_codes/course_join_codes.py @@ -34,7 +34,7 @@ def get(self, course_id): return query_selected_from_model( CourseShareCode, urljoin(f"{RESPONSE_URL}/", f"{str(course_id)}/", "join_codes"), - select_values=["join_code", "expiry_time"], + select_values=["join_code", "expiry_time", "for_admins"], filters={"course_id": course_id} ) diff --git a/backend/project/models/user.py b/backend/project/models/user.py index 7bc9ed30..4f03874e 100644 --- a/backend/project/models/user.py +++ b/backend/project/models/user.py @@ -30,6 +30,6 @@ def to_dict(self): """ return { 'uid': self.uid, - 'role': self.role.name, # Convert the enum to a string - 'display_name': self.display_name + 'display_name': self.display_name, + 'role': self.role.name # Convert the enum to a string } diff --git a/backend/project/utils/models/user_utils.py b/backend/project/utils/models/user_utils.py index 37cd263c..21568c6d 100644 --- a/backend/project/utils/models/user_utils.py +++ b/backend/project/utils/models/user_utils.py @@ -21,7 +21,7 @@ def get_user(user_id): db.session.rollback() abort(make_response(({"message": "An error occurred while fetching the user"} , 500))) - if not user: + if user is None: abort(make_response(({"message":f"User with id: {user_id} not found"}, 404))) return user diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 96e25045..94ace471 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -193,7 +193,7 @@ def query_by_id_from_model(model: DeclarativeMeta, if not result: return {"message": "Resource not found", "url": base_url}, 404 return { - "data": result, + "data": result.to_dict() if hasattr(result, "to_dict") else result, "message": "Resource fetched correctly", "url": urljoin(f"{base_url}/", str(column_id))}, 200 except SQLAlchemyError: diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf36..d7de12f3 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -12,6 +12,8 @@ dist dist-ssr *.local +.env + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1674097e..8fc1e44f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,16 +8,17 @@ "name": "ugent-3", "version": "0.0.0", "dependencies": { - "@emotion/react": "^11.11.3", - "@emotion/styled": "^11.11.0", - "@mui/icons-material": "^5.15.10", + "@emotion/react": "^11.11.4", + "@emotion/styled": "^11.11.5", + "@mui/icons-material": "^5.15.15", "@mui/lab": "^5.0.0-alpha.170", - "@mui/material": "^5.15.10", + "@mui/material": "^5.15.15", "@mui/styled-engine-sc": "^6.0.0-alpha.16", "@mui/x-data-grid": "^7.1.1", "@mui/x-date-pickers": "^7.1.1", "axios": "^1.6.8", "dayjs": "^1.11.10", + "debounce": "^2.0.0", "downloadjs": "^1.4.7", "i18next-browser-languagedetector": "^7.2.0", "i18next-http-backend": "^2.5.0", @@ -31,8 +32,8 @@ "styled-components": "^6.1.8" }, "devDependencies": { - "@types/history": "^4.7.11", "@types/downloadjs": "^1.4.6", + "@types/history": "^4.7.11", "@types/react": "^18.2.55", "@types/react-dom": "^18.2.19", "@types/react-router-dom": "^5.3.3", @@ -3478,6 +3479,17 @@ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" }, + "node_modules/debounce": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-2.0.0.tgz", + "integrity": "sha512-xRetU6gL1VJbs85Mc4FoEGSjQxzpdxRyFhe3lmWFyy2EzydIcD4xzUvRJMD+NPDfMwKNhxa3PvsIOU32luIWeA==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index f866c3cf..b1903b29 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,16 +12,17 @@ "test": "npm run cypress:test" }, "dependencies": { - "@emotion/react": "^11.11.3", - "@emotion/styled": "^11.11.0", - "@mui/icons-material": "^5.15.10", + "@emotion/react": "^11.11.4", + "@emotion/styled": "^11.11.5", + "@mui/icons-material": "^5.15.15", + "@mui/material": "^5.15.15", "@mui/lab": "^5.0.0-alpha.170", - "@mui/material": "^5.15.10", "@mui/styled-engine-sc": "^6.0.0-alpha.16", "@mui/x-data-grid": "^7.1.1", "@mui/x-date-pickers": "^7.1.1", "axios": "^1.6.8", "dayjs": "^1.11.10", + "debounce": "^2.0.0", "downloadjs": "^1.4.7", "i18next-browser-languagedetector": "^7.2.0", "i18next-http-backend": "^2.5.0", diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 1a438508..212bd4cc 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -16,6 +16,43 @@ "welcomeDescription": "Welcome to PeristerĂ³nas, the online submission platform of UGent", "login": "Login" }, + "courseDetailTeacher": { + "title": "Course Details", + "deleteCourse": "Delete Course", + "unauthorizedDelete": "You are unauthorized to delete this course", + "noCoursesFound": "No courses found", + "noProjects": "No projects", + "noStudents": "No students in this course", + "joinCodes": "Join Codes", + "forAdmins": "For Admins", + "forStudents": "For Students", + "noExpiryDate": "No expiry date", + "expiryDate": "Expiry Date", + "newJoinCode": "New Join Code", + "deleteSelected": "Delete Selected Students", + "projects": "Projects", + "newProject": "New Project", + "assistantList": "List of co-teachers/assistants", + "newTeacher": "New teacher", + "studentList": "List of students", + "newStudent": "New student(s)", + "deadline": "Deadline", + "teacher": "Teacher", + "view": "View", + "admins": "Admins", + "students": "Students" + }, + "allCoursesTeacher": { + "title": "All Courses", + "courseForm": "Course Form", + "courseName": "Course Name", + "submit": "Submit", + "emptyCourseNameError": "Course name should not be empty", + "cancel": "Cancel", + "create": "Create", + "activeCourses": "Active Courses", + "archivedCourses":"Archived Courses" + }, "courseForm": { "courseName": "Course Name", "submit": "Submit", @@ -42,7 +79,12 @@ "daysAgo": "days ago", "hoursAgo": "hours ago", "minutesAgo": "minutes ago", - "justNow": "just now" + "justNow": "just now", + "yearsLater": "years later", + "monthsLater": "months later", + "daysLater": "days later", + "hoursLater": "hours later", + "minutesLater": "minutes later" }, "error": { "pageNotFound": "Page Not Found", diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json index cbccc0dc..7addc7e6 100644 --- a/frontend/public/locales/nl/translation.json +++ b/frontend/public/locales/nl/translation.json @@ -15,6 +15,43 @@ "welcomeDescription": "Welkom bij PeristerĂ³nas, het online indieningsplatform van UGent", "login": "Aanmelden" }, + "courseDetailTeacher": { + "title": "Vak Details", + "noCoursesFound": "Geen vakken gevonden", + "unauthorizedDelete": "U heeft niet de juiste rechten om dit vak te verwijderen", + "noProjects": "Geen projecten", + "noStudents": "Geen studenten voor dit vak", + "deleteCourse": "Verwijder vak", + "joinCodes": "Join Codes", + "forAdmins": "Voor Admins", + "forStudents": "Voor Studenten", + "noExpiryDate": "Geen vervaldatum", + "expiryDate": "Vervaldatum", + "newJoinCode": "Nieuwe Join Code", + "deleteSelected": "Verwijder geselecteerde studenten", + "projects": "Projecten", + "newProject": "Nieuw Project", + "assistantList": "Lijst co-leerkrachten/assistenten", + "newTeacher": "Nieuwe leerkracht", + "studentList": "Lijst studenten", + "newStudent": "Nieuwe student(en)", + "deadline": "Deadline", + "teacher": "Leerkracht", + "view": "Bekijk", + "admins": "Admins", + "students": "Studenten" + }, + "allCoursesTeacher": { + "title": "Alle Vakken", + "courseForm": "Vak Form", + "courseName": "Vak Naam", + "submit": "Opslaan", + "emptyCourseNameError": "Vak naam mag niet leeg zijn", + "cancel": "Annuleer", + "create": "Nieuw Vak", + "activeCourses": "Actieve Vakken", + "archivedCourses":"Gearchiveerde Vakken" + }, "courseForm": { "courseName": "Vak Naam", "submit": "Opslaan", @@ -74,7 +111,12 @@ "daysAgo": "dagen geleden", "hoursAgo": "uur geleden", "minutesAgo": "minuten geleden", - "justNow": "Zonet" + "justNow": "Zonet", + "yearsLater": "jaren later", + "monthsLater": "maanden later", + "daysLater": "dagen later", + "hoursLater": "uur later", + "minutesLater": "minuten later" }, "projectsOverview": { "past_deadline": "Verlopen Projecten", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8f4f2e28..efe17395 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,8 @@ import { Route, RouterProvider, createBrowserRouter, createRoutesFromElements } from "react-router-dom"; import Layout from "./components/Header/Layout"; +import { AllCoursesTeacher } from "./components/Courses/AllCoursesTeacher"; +import { CourseDetailTeacher } from "./components/Courses/CourseDetailTeacher"; +import { dataLoaderCourseDetail, dataLoaderCourses } from "./components/Courses/CourseUtils"; import LanguagePath from "./components/LanguagePath"; import ProjectView from "./pages/project/projectView/ProjectView"; import { ErrorBoundary } from "./pages/error/ErrorBoundary.tsx"; @@ -20,6 +23,10 @@ const router = createBrowserRouter( }> + + } loader={dataLoaderCourses}/> + } loader={dataLoaderCourseDetail} /> + } loader={fetchProjectPage}/> } /> diff --git a/frontend/src/components/Courses/AllCoursesTeacher.tsx b/frontend/src/components/Courses/AllCoursesTeacher.tsx new file mode 100644 index 00000000..facfa9c2 --- /dev/null +++ b/frontend/src/components/Courses/AllCoursesTeacher.tsx @@ -0,0 +1,79 @@ +import { Button, Dialog, DialogActions, DialogTitle, FormControl, FormHelperText, Grid, Input, InputLabel } from "@mui/material"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { SideScrollableCourses } from "./CourseUtilComponents"; +import { Course, callToApiToCreateCourse } from "./CourseUtils"; +import { Title } from "../Header/Title"; +import { useLoaderData } from "react-router-dom"; + +/** + * @returns A jsx component representing all courses for a teacher + */ +export function AllCoursesTeacher(): JSX.Element { + const [open, setOpen] = useState(false); + const courses = (useLoaderData() as Course[]); + + const [courseName, setCourseName] = useState(''); + const [error, setError] = useState(''); + + const navigate = useNavigate(); + + const { t } = useTranslation('translation', { keyPrefix: 'allCoursesTeacher' }); + + const handleClickOpen = () => { + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + + const handleInputChange = (event: React.ChangeEvent) => { + setCourseName(event.target.value); + setError(''); // Clearing error message when user starts typing + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); // Prevents the default form submission behaviour + + if (!courseName.trim()) { + setError(t('emptyCourseNameError')); + return; + } + + const data = { name: courseName }; + callToApiToCreateCourse(JSON.stringify(data), navigate); + }; + return ( + <> + + + + + {t('courseForm')} +
+ + {t('courseName')} + + {error && {error}} + + + + + +
+
+ + + +
+ + ); +} \ No newline at end of file diff --git a/frontend/src/components/Courses/CourseDetailTeacher.tsx b/frontend/src/components/Courses/CourseDetailTeacher.tsx new file mode 100644 index 00000000..e1cf0ff3 --- /dev/null +++ b/frontend/src/components/Courses/CourseDetailTeacher.tsx @@ -0,0 +1,411 @@ +import { Box, Button, Card, CardActions, CardContent, CardHeader, Checkbox, FormControlLabel, Grid, IconButton, Input, Menu, MenuItem, Paper, Typography } from "@mui/material"; +import { ChangeEvent, useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Course, Project, apiHost, getIdFromLink, getNearestFutureDate, getUserName, appHost } from "./CourseUtils"; +import { Link, useNavigate, NavigateFunction, useLoaderData } from "react-router-dom"; +import { Title } from "../Header/Title"; +import ClearIcon from '@mui/icons-material/Clear'; +import { timeDifference } from "../../utils/date-utils"; + +interface UserUid{ + uid: string +} + +/** + * Handles the deletion of an admin. + * @param navigate - The navigate function from react-router-dom. + * @param courseId - The ID of the course. + * @param uid - The UID of the admin. + */ +function handleDeleteAdmin(navigate: NavigateFunction, courseId: string, uid: string): void { + fetch(`${apiHost}/courses/${courseId}/admins`, { + method: 'DELETE', + credentials: 'include', + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + "admin_uid": uid + }) + }) + .then(() => { + navigate(0); + }); +} + +/** + * Handles the deletion of a student. + * @param navigate - The navigate function from react-router-dom. + * @param courseId - The ID of the course. + * @param uid - The UID of the admin. + */ +function handleDeleteStudent(navigate: NavigateFunction, courseId: string, uids: string[]): void { + fetch(`${apiHost}/courses/${courseId}/students`, { + method: 'DELETE', + credentials: 'include', + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + "students": uids + }) + }) + .then(() => { + navigate(0); + }); +} + +/** + * Handles the deletion of a course. + * @param navigate - The navigate function from react-router-dom. + * @param courseId - The ID of the course. + */ +function handleDeleteCourse(navigate: NavigateFunction, courseId: string): void { + fetch(`${apiHost}/courses/${courseId}`, { + method: 'DELETE', + credentials: 'include', + }).then((response) => { + if(response.ok){ + navigate(-1); + } + else if(response.status === 404){ + navigate(-1); + } + }); +} + +/** + * + * @returns A jsx component representing the course detail page for a teacher + */ +export function CourseDetailTeacher(): JSX.Element { + const [selectedStudents, setSelectedStudents] = useState([]); + const [anchorEl, setAnchorElStudent] = useState(null); + const openCodes = Boolean(anchorEl); + const handleClickCodes = (event: React.MouseEvent) => { + setAnchorElStudent(event.currentTarget); + }; + const handleCloseCodes = () => { + setAnchorElStudent(null); + }; + + const courseDetail = useLoaderData() as { //TODO CATCH ERROR + course: Course , + projects:Project[] , + admins: UserUid[], + students: UserUid[] + }; + const { course, projects, admins, students } = courseDetail; + const { t } = useTranslation('translation', { keyPrefix: 'courseDetailTeacher' }); + const { i18n } = useTranslation(); + const lang = i18n.language; + const navigate = useNavigate(); + + const handleCheckboxChange = (event: ChangeEvent, uid: string) => { + if (event.target.checked) { + setSelectedStudents((prevSelected) => [...prevSelected, uid]); + } else { + setSelectedStudents((prevSelected) => + prevSelected.filter((student) => student !== uid) + ); + } + }; + + return ( + <> + + + + + {t('projects')}: + + + + + + + + + {t('admins')}: + + {admins.map((admin) => ( + + + {getUserName(admin.uid)} + + + + ))} + + + + + {t('students')}: + + + + handleDeleteStudent(navigate, course.course_id, selectedStudents)}> + + {t('deleteSelected')} + + + + + + + + + + + + + + ); +} + +/** + * @param projects - The array of projects. + * @returns Either a place holder for no projects or a grid of cards describing the projects. + */ +function EmptyOrNotProjects({projects}: {projects: Project[]}): JSX.Element { + const { t } = useTranslation('translation', { keyPrefix: 'courseDetailTeacher' }); + if(projects === undefined || projects.length === 0){ + return ( + {t('noProjects')} + ); + } + else{ + return ( + + {projects?.map((project) => ( + + + + + + + {getNearestFutureDate(project.deadlines) && + ( + + {`${t('deadline')}: ${getNearestFutureDate(project.deadlines)?.toLocaleDateString()}`} + + )} + + + + + + + + + ))} + + ); + } +} + +/** + * @param navigate - The navigate function from react-router-dom. + * @param course - The course against which we will check if the uid is of the teacher. + * @param admin - The admin in question. + * @returns Either nothing, if the admin uid is of teacher or a delete button. + */ +function EitherDeleteIconOrNothing({admin, course, navigate} : {admin:UserUid, course:Course, navigate: NavigateFunction}) : JSX.Element{ + if(course.teacher === getIdFromLink(admin.uid)){ + return <>; + } + else{ + return ( + + handleDeleteAdmin(navigate,course.course_id,getIdFromLink(admin.uid))}> + + + + ); + } +} + +/** + * @param students - The array of students. + * @param selectedStudents - The array of selected students. + * @param handleCheckboxChange - The function to handle the checkbox change. + * @returns Either a place holder for no students or a grid of checkboxes for the students. + */ +function EmptyOrNotStudents({students, selectedStudents, handleCheckboxChange}: {students: UserUid[], selectedStudents: string[], handleCheckboxChange: (event: React.ChangeEvent, studentId: string) => void}): JSX.Element { + if(students.length === 0){ + return ( + No students found + ); + } + else{ + return ( + + {students.map((student) => ( + + + handleCheckboxChange(event, getIdFromLink(student.uid))} + /> + + + {getUserName(student.uid)} + + + ))} + + ); + } +} + +interface JoinCode{ + join_code: string, + expiry_time: string, + for_admins: boolean +} + +/** + * Renders the JoinCodeMenu component. + * @param open - Whether the dialog is open or not. + * @param handleClose - Function to handle the dialog close event. + * @param handleNewCode - Function to handle the creation of a new join code. + * @param handleDeleteCode - Function to handle the deletion of a join code. + * @param getCodes - Function to get the list of join codes. + * @returns The rendered JoinCodeDialog component. + */ +function JoinCodeMenu({courseId,open,handleClose, anchorEl}: {courseId:string, open: boolean, handleClose : () => void, anchorEl: HTMLElement | null}) { + const { t } = useTranslation('translation', { keyPrefix: 'courseDetailTeacher' }); + + const [codes, setCodes] = useState([]); + const [expiry_time, setExpiryTime] = useState(null); + const [for_admins, setForAdmins] = useState(false); + + const handleInputChange = (event: React.ChangeEvent) => { + setExpiryTime(new Date(event.target.value)); + }; + + const handleCopyToClipboard = (join_code: string) => { + navigator.clipboard.writeText(`${appHost}/join-course?code=${join_code}`) + }; + + const getCodes = useCallback(() => { + fetch(`${apiHost}/courses/${courseId}/join_codes`, { + method: 'GET', + credentials: 'include', + }) + .then(response => response.json()) + .then(data => { + const filteredData = data.data.filter((code: JoinCode) => { + // Filter out expired codes + let expired = false; + if(code.expiry_time !== null){ + const expiryTime = new Date(code.expiry_time); + const now = new Date(); + expired = expiryTime < now; + } + + return !expired; + }); + setCodes(filteredData); + }) + }, [courseId]); + + const handleNewCode = () => { + + const bodyContent: { for_admins: boolean, expiry_time?: string } = { "for_admins": for_admins }; + if (expiry_time !== null) { + bodyContent.expiry_time = expiry_time.toISOString(); + } + + fetch(`${apiHost}/courses/${courseId}/join_codes`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(bodyContent) + }) + .then(() => getCodes()) + } + + const handleDeleteCode = (joinCode: string) => { + fetch(`${apiHost}/courses/${courseId}/join_codes/${joinCode}`, + { + method: 'DELETE', + credentials: 'include', + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + "join_code": joinCode + }) + }) + .then(() => getCodes()); + } + + useEffect(() => { + getCodes(); + }, [t, getCodes ]); + + return ( + + + + {t('joinCodes')} + + + {codes.map((code:JoinCode) => ( + handleCopyToClipboard(code.join_code)} key={code.join_code}> + + + {code.expiry_time ? timeDifference(code.expiry_time) : t('noExpiryDate')} + + + {code.for_admins ? t('forAdmins') : t('forStudents')} + + + handleDeleteCode(code.join_code)}> + + + + + + ))} + + + + setForAdmins(event.target.checked)} + name="forAdmins" + color="primary" + /> + } + /> + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/Courses/CourseUtilComponents.tsx b/frontend/src/components/Courses/CourseUtilComponents.tsx new file mode 100644 index 00000000..6a9e4804 --- /dev/null +++ b/frontend/src/components/Courses/CourseUtilComponents.tsx @@ -0,0 +1,220 @@ +import { Box, Button, Card, CardActions, CardContent, CardHeader, Grid, Paper, TextField, Typography } from "@mui/material"; +import { Course, Project, apiHost, getIdFromLink, getNearestFutureDate } from "./CourseUtils"; +import { Link, useNavigate, useLocation } from "react-router-dom"; +import { useState, useEffect, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import debounce from 'debounce'; + +/** + * @param text - The text to be displayed + * @returns Typography that overflow into ... when text is too long + */ +export function EpsilonTypography({text} : {text: string}): JSX.Element { + return ( + {text} + ); +} + +/** + * @param label - The label of the search box + * @param searchTerm - The current search term + * @param handleSearchChange - The function to handle search term changes + * @returns a Grid item containing a TextField, used for searching/filtering + */ +export function SearchBox({label,searchTerm,handleSearchChange}: {label: string, searchTerm: string, handleSearchChange: (event: React.ChangeEvent) => void}): JSX.Element { + return ( + + + + + + ); +} + +/** + * We should reuse this in the student course view since it will be mainly the same except the create button. + * @param props - The component props requiring the courses that will be displayed in the scroller. + * @returns A component to display courses in horizontal scroller where each course is a card containing its name. + */ +export function SideScrollableCourses({courses}: {courses: Course[]}): JSX.Element { + //const navigate = useNavigate(); + const location = useLocation(); + const navigate = useNavigate(); + + // Get initial state from URL + const urlParams = useMemo(() => new URLSearchParams(location.search), [location.search]); //useMemo so only recompute when location.search changes + const initialSearchTerm = urlParams.get('name') || ''; + const initialUforaIdFilter = urlParams.get('ufora_id') || ''; + const initialTeacherNameFilter = urlParams.get('teacher') || ''; + + const [searchTerm, setSearchTerm] = useState(initialSearchTerm); + const [uforaIdFilter, setUforaIdFilter] = useState(initialUforaIdFilter); + const [teacherNameFilter, setTeacherNameFilter] = useState(initialTeacherNameFilter); + const [projects, setProjects] = useState<{ [courseId: string]: Project[] }>({}); + + const debouncedHandleSearchChange = useMemo(() => + debounce((key: string, value: string) => { + if (value === '') { + urlParams.delete(key); + } else { + urlParams.set(key, value); + } + const newUrl = `${location.pathname}?${urlParams.toString()}`; + navigate(newUrl, { replace: true }); + }, 500), [urlParams, navigate, location.pathname]); + + useEffect(() => { + debouncedHandleSearchChange('name', searchTerm); + }, [searchTerm, debouncedHandleSearchChange]); + + useEffect(() => { + debouncedHandleSearchChange('ufora_id', uforaIdFilter); + }, [uforaIdFilter, debouncedHandleSearchChange]); + + useEffect(() => { + debouncedHandleSearchChange('teacher', teacherNameFilter); + }, [teacherNameFilter, debouncedHandleSearchChange]); + + const handleSearchChange = (event: React.ChangeEvent) => { + const newSearchTerm = event.target.value; + setSearchTerm(newSearchTerm); + }; + + const handleUforaIdFilterChange = (event: React.ChangeEvent) => { + const newUforaIdFilter = event.target.value; + setUforaIdFilter(newUforaIdFilter); + }; + + const handleTeacherNameFilterChange = (event: React.ChangeEvent) => { + const newTeacherNameFilter = event.target.value; + setTeacherNameFilter(newTeacherNameFilter); + }; + + useEffect(() => { + // Fetch projects for each course + const fetchProjects = async () => { + const projectPromises = courses.map(course => + fetch(`${apiHost}/projects?course_id=${getIdFromLink(course.course_id)}`, + { credentials: 'include' } + ) + .then(response => response.json()) + ); + + const projectResults = await Promise.all(projectPromises); + const projectsMap: { [courseId: string]: Project[] } = {}; + + projectResults.forEach((result, index) => { + projectsMap[getIdFromLink(courses[index].course_id)] = result.data; + }); + + setProjects(projectsMap); + }; + + fetchProjects(); + }, [courses]); + + const filteredCourses = courses.filter(course => + course.name.toLowerCase().includes(searchTerm.toLowerCase()) && + (course.ufora_id ? course.ufora_id.toLowerCase().includes(uforaIdFilter.toLowerCase()) : !uforaIdFilter) && + course.teacher.toLowerCase().includes(teacherNameFilter.toLowerCase()) + ); + + return ( + + + + + + + + + ); +} + +/** + * Empty or not. + * @returns either a place holder or the actual content. + */ +function EmptyOrNotFilteredCourses({filteredCourses, projects}: {filteredCourses: Course[], projects: { [courseId: string]: Project[] }}): JSX.Element { + const { t } = useTranslation('translation', { keyPrefix: 'courseDetailTeacher' }); + if(filteredCourses.length === 0){ + return ( + {t('noCoursesFound')} + ); + } + + return ( + + + {filteredCourses.map((course, index) => ( + + + }/> + + {course.ufora_id && ( + <> + Ufora_id: {course.ufora_id}
+ + )} + Teacher: {course.teacher} + + }/> + + {t('projects')}: + + + + + +
+
+ ))} +
+
+ ); +} +/** + * @param projects - The projects to be displayed if not empty + * @returns either a place holder with text for no projects or the projects + */ +function EmptyOrNotProjects({projects, noProjectsText}: {projects: Project[], noProjectsText:string}): JSX.Element { + if(projects === undefined || projects.length === 0){ + return ( + {noProjectsText} + ); + } + else{ + const now = new Date(); + return ( + <> + {projects.slice(0, 3).map((project) => { + let timeLeft = ''; + if (project.deadlines != undefined) { + const deadlineDate = getNearestFutureDate(project.deadlines); + if(deadlineDate == null){ + return <> + } + const diffTime = Math.abs(deadlineDate.getTime() - now.getTime()); + const diffHours = Math.ceil(diffTime / (1000 * 60 * 60)); + const diffDays = Math.ceil(diffHours * 24); + + timeLeft = diffDays > 1 ? `${diffDays} days` : `${diffHours} hours`; + } + return ( + + + + + + ); + })} + + ); + } +} \ No newline at end of file diff --git a/frontend/src/components/Courses/CourseUtils.tsx b/frontend/src/components/Courses/CourseUtils.tsx new file mode 100644 index 00000000..51a462a2 --- /dev/null +++ b/frontend/src/components/Courses/CourseUtils.tsx @@ -0,0 +1,146 @@ +import { NavigateFunction, Params } from 'react-router-dom'; + +export interface Course{ + course_id: string, + name: string, + teacher:string, + ufora_id:string, + url:string +} + +export interface Project{ + title: string, + project_id: string, + deadlines: string[][] +} + +export const apiHost = import.meta.env.VITE_APP_API_HOST; +export const appHost = import.meta.env.VITE_APP_HOST; +/** + * @returns The uid of the acces token of the logged in user + */ +export function loggedInToken(){ + return "teacher1"; +} + +/** + * Get the username based on the provided uid. + * @param uid - The uid of the user. + * @returns The username. + */ +export function getUserName(uid: string): string { + return getIdFromLink(uid); +} + +/** + * @returns The Uid of the logged in user + */ +export function loggedInUid(){ + return "Gunnar"; +} + +/** + * On a succesfull post the function will redirect to the data.url of the response, this should point to the detail page + * @param data - course data to send to the api + * @param navigate - function that allows the app to redirect + */ +export function callToApiToCreateCourse(data: string, navigate: NavigateFunction){ + fetch(`${apiHost}/courses`, { + credentials: 'include', // include, *same-origin, omit + headers: { + "Content-Type": "application/json" + }, + method: 'POST', + body: data, + }) + .then(response => response.json()) + .then(data => { + //But first also make sure that teacher is in the course admins list + fetch(`${apiHost}/courses/${getIdFromLink(data.url)}/admins`, { + credentials: 'include', + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({admin_uid: loggedInUid()}) + }); + navigate(getIdFromLink(data.url)); // navigate to data.url + }) +} + +/** + * @param link - the link to the api endpoint + * @returns the Id at the end of the link + */ +export function getIdFromLink(link: string): string { + const parts = link.split('/'); + return parts[parts.length - 1]; +} + +/** + * Function to find the nearest future date from a list of dates + * @param dates - Array of dates + * @returns The nearest future date + */ +export function getNearestFutureDate(dates: string[][]): Date | null { + const now = new Date(); + const futureDates = dates.map(date => new Date(date[1])).filter(date => date > now); + if (futureDates.length === 0) return null; + return futureDates.reduce((nearest, current) => current < nearest ? current : nearest); +} + +/** + * Load courses for courses teacher page, this filters courses on logged in teacher uid + * @returns A Promise that resolves to the loaded courses data. + */ + +const fetchData = async (url: string, params?: URLSearchParams) => { + let uri = `${apiHost}/${url}`; + if(params){ + uri += `?${params}` + } + const res = await fetch(uri, { + credentials: 'include' + }); + if(res.status !== 200){ + throw new Response("Failed to fetch data", {status: res.status}); + } + const jsonResult = await res.json(); + + return jsonResult.data; +}; + +export const dataLoaderCourses = async () => { + //const params = new URLSearchParams({ 'teacher': loggedInUid() }); + return fetchData(`courses`); +}; + +const dataLoaderCourse = async (courseId: string) => { + return fetchData(`courses/${courseId}`); +}; + +const dataLoaderProjects = async (courseId: string) => { + const params = new URLSearchParams({ course_id: courseId }); + return fetchData(`projects`, params); +}; + +const dataLoaderAdmins = async (courseId: string) => { + return fetchData(`courses/${courseId}/admins`); +}; + +const dataLoaderStudents = async (courseId: string) => { + return fetchData(`courses/${courseId}/students`); +}; + +export const dataLoaderCourseDetail = async ({ params } : { params:Params}) => { + const { courseId } = params; + if (!courseId) { + throw new Error("Course ID is undefined."); + } + const course = await dataLoaderCourse(courseId); + const projects = await dataLoaderProjects(courseId); + const admins = await dataLoaderAdmins(courseId); + const students = await dataLoaderStudents(courseId); + + return { course, projects, admins, students }; +}; \ No newline at end of file diff --git a/frontend/src/components/ProjectForm/ProjectForm.tsx b/frontend/src/components/ProjectForm/ProjectForm.tsx index 3b23815a..e47f5354 100644 --- a/frontend/src/components/ProjectForm/ProjectForm.tsx +++ b/frontend/src/components/ProjectForm/ProjectForm.tsx @@ -137,9 +137,7 @@ export default function ProjectForm() { const fetchCourses = async () => { const response = await fetch(`${apiUrl}/courses?teacher=${user}`, { - headers: { - "Authorization": user - }, + credentials: 'include' }) const jsonData = await response.json(); if (jsonData.data) { @@ -206,9 +204,7 @@ export default function ProjectForm() { const response = await fetch(`${apiUrl}/projects`, { method: "post", - headers: { - "Authorization": user - }, + credentials: 'include', body: formData }) diff --git a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx index eae8b416..f0937995 100644 --- a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx +++ b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx @@ -5,7 +5,6 @@ import ProjectSubmissionsOverviewDatagrid from "./ProjectSubmissionOverviewDatag import download from 'downloadjs'; import {useTranslation} from "react-i18next"; const apiUrl = import.meta.env.VITE_API_HOST -const user = "teacher" /** * @returns Overview page for submissions @@ -20,9 +19,7 @@ export default function ProjectSubmissionOverview() { const fetchProject = async () => { const response = await fetch(`${apiUrl}/projects/${projectId}`, { - headers: { - "Authorization": user - }, + credentials: 'include' }) const jsonData = await response.json(); setProjectTitle(jsonData["data"].title); @@ -31,9 +28,7 @@ export default function ProjectSubmissionOverview() { const downloadProjectSubmissions = async () => { await fetch(`${apiUrl}/projects/${projectId}/submissions-download`, { - headers: { - "Authorization": user - }, + credentials: 'include', }) .then(res => { return res.blob(); diff --git a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx index a3d02d8f..0c5fb167 100644 --- a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx +++ b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx @@ -9,7 +9,6 @@ import DownloadIcon from '@mui/icons-material/Download'; import download from "downloadjs"; const apiUrl = import.meta.env.VITE_API_HOST -const user = "teacher" interface Submission { grading: string; @@ -30,9 +29,7 @@ function getRowId(row: Submission) { const fetchSubmissionsFromUser = async (submission_id: string) => { await fetch(`${apiUrl}/submissions/${submission_id}/download`, { - headers: { - "Authorization": user - }, + credentials: 'include', }) .then(res => { return res.blob(); @@ -87,9 +84,7 @@ export default function ProjectSubmissionsOverviewDatagrid() { const fetchLastSubmissionsByUser = async () => { const response = await fetch(`${apiUrl}/projects/${projectId}/latest-per-user`, { - headers: { - "Authorization": user - }, + credentials: 'include', }) const jsonData = await response.json(); setSubmissions(jsonData.data); diff --git a/frontend/src/pages/project/projectView/ProjectView.tsx b/frontend/src/pages/project/projectView/ProjectView.tsx index 8ce28644..65053f4d 100644 --- a/frontend/src/pages/project/projectView/ProjectView.tsx +++ b/frontend/src/pages/project/projectView/ProjectView.tsx @@ -35,14 +35,14 @@ export default function ProjectView() { useEffect(() => { fetch(`${API_URL}/projects/${projectId}`, { - headers: { Authorization: "teacher" }, + credentials: 'include', }).then((response) => { if (response.ok) { response.json().then((data) => { const projectData = data["data"]; setProjectData(projectData); fetch(`${API_URL}/courses/${projectData.course_id}`, { - headers: { Authorization: "teacher" }, + credentials: 'include', }).then((response) => { if (response.ok) { response.json().then((data) => { @@ -55,7 +55,7 @@ export default function ProjectView() { }); fetch(`${API_URL}/projects/${projectId}/assignment`, { - headers: { Authorization: "teacher" }, + credentials: 'include', }).then((response) => { if (response.ok) { response.text().then((data) => setAssignmentRawText(data)); diff --git a/frontend/src/pages/project/projectView/SubmissionCard.tsx b/frontend/src/pages/project/projectView/SubmissionCard.tsx index c223aa27..84016e54 100644 --- a/frontend/src/pages/project/projectView/SubmissionCard.tsx +++ b/frontend/src/pages/project/projectView/SubmissionCard.tsx @@ -48,7 +48,7 @@ export default function SubmissionCard({ useEffect(() => { fetch(`${submissionUrl}?project_id=${projectId}`, { - headers: { Authorization: "teacher" } + credentials: 'include' }).then((response) => { if (response.ok) { response.json().then((data) => { diff --git a/frontend/src/pages/project/projectView/SubmissionsGrid.tsx b/frontend/src/pages/project/projectView/SubmissionsGrid.tsx index 46a56f73..93bf14ed 100644 --- a/frontend/src/pages/project/projectView/SubmissionsGrid.tsx +++ b/frontend/src/pages/project/projectView/SubmissionsGrid.tsx @@ -42,7 +42,7 @@ export default function SubmissionsGrid({ headerName: t("submitTime"), type: "string", flex: 1, - valueFormatter: (value) => timeDifference(value), + valueFormatter: (value) => timeDifference(value, true), }, { field: "submission_status", diff --git a/frontend/src/utils/date-utils.ts b/frontend/src/utils/date-utils.ts index 59fe2db9..e1330607 100644 --- a/frontend/src/utils/date-utils.ts +++ b/frontend/src/utils/date-utils.ts @@ -5,23 +5,37 @@ import i18next from "i18next"; * @param date - date string to be converted to time difference * @returns - time difference between the current date and the given date */ -export function timeDifference(date: string) { +export function timeDifference(date: string, past: boolean = false) { const t = (key: string) => { return i18next.t(`time.${key}`); }; const current = new Date(); const previous = new Date(date); - const diff = current.getTime() - previous.getTime(); + let diff = 0; + if (past) { + diff = current.getTime() - previous.getTime(); + } else { + diff = previous.getTime() - current.getTime(); + } const minutes = Math.floor(diff / 60000); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); const months = Math.floor(days / 30); const years = Math.floor(months / 12); - if (years > 0) return `${years} ${t("yearsAgo")}`; - if (months > 0) return `${months} ${t("monthsAgo")}`; - if (days > 0) return `${days} ${t("daysAgo")}`; - if (hours > 0) return `${hours} ${t("hoursAgo")}`; - if (minutes > 0) return `${minutes} ${t("minutesAgo")}`; + if (past) { + if (years > 0) return `${years} ${t("yearsAgo")}`; + if (months > 0) return `${months} ${t("monthsAgo")}`; + if (days > 0) return `${days} ${t("daysAgo")}`; + if (hours > 0) return `${hours} ${t("hoursAgo")}`; + if (minutes > 0) return `${minutes} ${t("minutesAgo")}`; + } + else{ + if (years > 0) return `${years} ${t("yearsLater")}`; + if (months > 0) return `${months} ${t("monthsLater")}`; + if (days > 0) return `${days} ${t("daysLater")}`; + if (hours > 0) return `${hours} ${t("hoursLater")}`; + if (minutes > 0) return `${minutes} ${t("minutesLater")}`; + } return t("justNow"); }