From cffc8c0d4d21d2cba2536f9479089b088a8ef208 Mon Sep 17 00:00:00 2001 From: Aron Buzogany Date: Sat, 4 May 2024 15:34:59 +0200 Subject: [PATCH 1/2] added clipboard to copy the join code and redirecting to course when user already in course --- .../Courses/CourseDetailTeacher.tsx | 423 ++++++++++++------ .../src/components/Courses/CourseUtils.tsx | 1 - frontend/src/loaders/join-code.ts | 13 +- 3 files changed, 305 insertions(+), 132 deletions(-) diff --git a/frontend/src/components/Courses/CourseDetailTeacher.tsx b/frontend/src/components/Courses/CourseDetailTeacher.tsx index 60564260..2cbf6d8a 100644 --- a/frontend/src/components/Courses/CourseDetailTeacher.tsx +++ b/frontend/src/components/Courses/CourseDetailTeacher.tsx @@ -14,19 +14,34 @@ import { Menu, MenuItem, Paper, - Typography + Tooltip, + Typography, } from "@mui/material"; import { ChangeEvent, useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Course, apiHost, getIdFromLink, getNearestFutureDate, getUserName, appHost, ProjectDetail } from "./CourseUtils"; -import { Link, useNavigate, NavigateFunction, useLoaderData } from "react-router-dom"; +import { + Course, + apiHost, + getIdFromLink, + getNearestFutureDate, + getUserName, + ProjectDetail, +} from "./CourseUtils"; +import { + Link, + useNavigate, + NavigateFunction, + useLoaderData, +} from "react-router-dom"; import { Title } from "../Header/Title"; -import ClearIcon from '@mui/icons-material/Clear'; +import ClearIcon from "@mui/icons-material/Clear"; import { timeDifference } from "../../utils/date-utils"; import { authenticatedFetch } from "../../utils/authenticated-fetch"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import i18next from "i18next"; -interface UserUid{ - uid: string +interface UserUid { + uid: string; } /** @@ -35,16 +50,19 @@ interface UserUid{ * @param courseId - The ID of the course. * @param uid - The UID of the admin. */ -function handleDeleteAdmin(navigate: NavigateFunction, courseId: string, uid: string): void { +function handleDeleteAdmin( + navigate: NavigateFunction, + courseId: string, + uid: string +): void { authenticatedFetch(`${apiHost}/courses/${courseId}/admins`, { - method: 'DELETE', + method: "DELETE", body: JSON.stringify({ - "admin_uid": uid - }) - }) - .then(() => { - navigate(0); - }); + admin_uid: uid, + }), + }).then(() => { + navigate(0); + }); } /** @@ -53,19 +71,22 @@ function handleDeleteAdmin(navigate: NavigateFunction, courseId: string, uid: st * @param courseId - The ID of the course. * @param uid - The UID of the admin. */ -function handleDeleteStudent(navigate: NavigateFunction, courseId: string, uids: string[]): void { +function handleDeleteStudent( + navigate: NavigateFunction, + courseId: string, + uids: string[] +): void { authenticatedFetch(`${apiHost}/courses/${courseId}/students`, { - method: 'DELETE', + method: "DELETE", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ - "students": uids - }) - }) - .then(() => { - navigate(0); - }); + students: uids, + }), + }).then(() => { + navigate(0); + }); } /** @@ -73,21 +94,23 @@ function handleDeleteStudent(navigate: NavigateFunction, courseId: string, uids: * @param navigate - The navigate function from react-router-dom. * @param courseId - The ID of the course. */ -function handleDeleteCourse(navigate: NavigateFunction, courseId: string): void { +function handleDeleteCourse( + navigate: NavigateFunction, + courseId: string +): void { authenticatedFetch(`${apiHost}/courses/${courseId}`, { - method: 'DELETE', + method: "DELETE", }).then((response) => { - if(response.ok){ + if (response.ok) { navigate(-1); - } - else if(response.status === 404){ + } else if (response.status === 404) { navigate(-1); } }); } /** - * + * * @returns A jsx component representing the course detail page for a teacher */ export function CourseDetailTeacher(): JSX.Element { @@ -102,19 +125,24 @@ export function CourseDetailTeacher(): JSX.Element { }; const courseDetail = useLoaderData() as { - course: Course , - projects:ProjectDetail[] , - admins: UserUid[], - students: UserUid[] + course: Course; + projects: ProjectDetail[]; + admins: UserUid[]; + students: UserUid[]; }; const { course, projects, admins, students } = courseDetail; - const { t } = useTranslation('translation', { keyPrefix: 'courseDetailTeacher' }); + const { t } = useTranslation("translation", { + keyPrefix: "courseDetailTeacher", + }); const { i18n } = useTranslation(); const lang = i18n.language; const navigate = useNavigate(); - const handleCheckboxChange = (event: ChangeEvent, uid: string) => { + const handleCheckboxChange = ( + event: ChangeEvent, + uid: string + ) => { if (event.target.checked) { setSelectedStudents((prevSelected) => [...prevSelected, uid]); } else { @@ -123,51 +151,96 @@ export function CourseDetailTeacher(): JSX.Element { ); } }; - + return ( <> - - {t('projects')}: - + + {t("projects")}: + - + + + - - {t('admins')}: + + {t("admins")}: - {admins.map((admin) => ( - + {admins.map((admin) => ( + - {getUserName(admin.uid)} + + {getUserName(admin.uid)} + - + ))} - {t('students')}: - - + {t("students")}: + + - handleDeleteStudent(navigate, course.course_id, selectedStudents)}> + + handleDeleteStudent( + navigate, + course.course_id, + selectedStudents + ) + } + > - {t('deleteSelected')} + {t("deleteSelected")} - - + + - + @@ -180,14 +253,24 @@ export function CourseDetailTeacher(): JSX.Element { * @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: ProjectDetail[]}): JSX.Element { - const { t } = useTranslation('translation', { keyPrefix: 'courseDetailTeacher' }); - if(projects === undefined || projects.length === 0){ +function EmptyOrNotProjects({ + projects, +}: { + projects: ProjectDetail[]; +}): JSX.Element { + const { t } = useTranslation("translation", { + keyPrefix: "courseDetailTeacher", + }); + if (projects === undefined || projects.length === 0) { return ( - {t('noProjects')} + + {t("noProjects")} + ); - } - else{ + } else { return ( {projects?.map((project) => ( @@ -197,16 +280,15 @@ function EmptyOrNotProjects({projects}: {projects: ProjectDetail[]}): JSX.Elemen - {getNearestFutureDate(project.deadlines) && - ( + {getNearestFutureDate(project.deadlines) && ( - {`${t('deadline')}: ${getNearestFutureDate(project.deadlines)?.date.toLocaleDateString()}`} + {`${t("deadline")}: ${getNearestFutureDate(project.deadlines)?.date.toLocaleDateString()}`} )} - + @@ -223,14 +305,29 @@ function EmptyOrNotProjects({projects}: {projects: ProjectDetail[]}): JSX.Elemen * @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)){ +function EitherDeleteIconOrNothing({ + admin, + course, + navigate, +}: { + admin: UserUid; + course: Course; + navigate: NavigateFunction; +}): JSX.Element { + if (course.teacher === getIdFromLink(admin.uid)) { return <>; - } - else{ + } else { return ( - handleDeleteAdmin(navigate,course.course_id,getIdFromLink(admin.uid))}> + + handleDeleteAdmin( + navigate, + course.course_id, + getIdFromLink(admin.uid) + ) + } + > @@ -244,25 +341,50 @@ function EitherDeleteIconOrNothing({admin, course, navigate} : {admin:UserUid, c * @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){ +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 + + No students found + ); - } - else{ + } else { return ( {students.map((student) => ( - + handleCheckboxChange(event, getIdFromLink(student.uid))} + onChange={(event) => + handleCheckboxChange(event, getIdFromLink(student.uid)) + } /> - {getUserName(student.uid)} + + {getUserName(student.uid)} + ))} @@ -271,10 +393,10 @@ function EmptyOrNotStudents({students, selectedStudents, handleCheckboxChange}: } } -interface JoinCode{ - join_code: string, - expiry_time: string, - for_admins: boolean +interface JoinCode { + join_code: string; + expiry_time: string; + for_admins: boolean; } /** @@ -286,104 +408,143 @@ interface JoinCode{ * @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' }); +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 host = window.location.host; + navigator.clipboard.writeText(`${host}/${i18next.language}/courses/join?code=${join_code}`); }; const getCodes = useCallback(() => { authenticatedFetch(`${apiHost}/courses/${courseId}/join_codes`, { - method: 'GET', + method: "GET", }) - .then(response => response.json()) - .then(data => { + .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){ + 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 }; + const bodyContent: { for_admins: boolean; expiry_time?: string } = { + for_admins: for_admins, + }; if (expiry_time !== null) { bodyContent.expiry_time = expiry_time.toISOString(); } authenticatedFetch(`${apiHost}/courses/${courseId}/join_codes`, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, - body: JSON.stringify(bodyContent) - }) - .then(() => getCodes()) - } + body: JSON.stringify(bodyContent), + }).then(() => getCodes()); + }; const handleDeleteCode = (joinCode: string) => { - authenticatedFetch(`${apiHost}/courses/${courseId}/join_codes/${joinCode}`, + authenticatedFetch( + `${apiHost}/courses/${courseId}/join_codes/${joinCode}`, { - method: 'DELETE', + method: "DELETE", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ - "join_code": joinCode - }) - }) - .then(() => getCodes()); - } + join_code: joinCode, + }), + } + ).then(() => getCodes()); + }; useEffect(() => { getCodes(); - }, [t, getCodes ]); + }, [t, getCodes]); return ( - - {t('joinCodes')} + {t("joinCodes")} - - {codes.map((code:JoinCode) => ( - handleCopyToClipboard(code.join_code)} key={code.join_code}> + + {codes.map((code: JoinCode) => ( + - - {code.expiry_time ? timeDifference(code.expiry_time) : t('noExpiryDate')} - - - {code.for_admins ? t('forAdmins') : t('forStudents')} + + + + {code.expiry_time + ? timeDifference(code.expiry_time) + : t("noExpiryDate")} + + + {code.for_admins ? t("forAdmins") : t("forStudents")} + + + handleCopyToClipboard(code.join_code)} + > + + + + handleDeleteCode(code.join_code)}> @@ -394,17 +555,21 @@ function JoinCodeMenu({courseId,open,handleClose, anchorEl}: {courseId:string, o ))} - - {t('expiryDate')}: + + + {t("expiryDate")}:{" "} + } /> - + ); -} \ No newline at end of file +} diff --git a/frontend/src/components/Courses/CourseUtils.tsx b/frontend/src/components/Courses/CourseUtils.tsx index 92c55aab..36fb7dd8 100644 --- a/frontend/src/components/Courses/CourseUtils.tsx +++ b/frontend/src/components/Courses/CourseUtils.tsx @@ -26,7 +26,6 @@ interface Deadline { } 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 */ diff --git a/frontend/src/loaders/join-code.ts b/frontend/src/loaders/join-code.ts index 3e693d8c..f559723b 100644 --- a/frontend/src/loaders/join-code.ts +++ b/frontend/src/loaders/join-code.ts @@ -13,13 +13,22 @@ export async function synchronizeJoinCode() { const joinCode = queryParams.get("code"); if (joinCode) { - const response = await authenticatedFetch(new URL("/courses/join", API_URL)); + const response = await authenticatedFetch( + new URL("/courses/join", API_URL), + { + method: "POST", + body: JSON.stringify({ join_code: joinCode }), + headers: { "Content-Type": "application/json" }, + } + ); - if (response.ok) { + if (response.ok || response.status === 409) { const responseData = await response.json(); return redirect( `/${i18next.language}/courses/${responseData.data.course_id}` ); + } else { + throw new Error("Invalid join code"); } } else { throw new Error("No join code provided"); From d9f788afaa467df188148e0024bb37eb13a4e3e8 Mon Sep 17 00:00:00 2001 From: Aron Buzogany Date: Sun, 5 May 2024 10:32:34 +0200 Subject: [PATCH 2/2] styling --- .../Courses/CourseDetailTeacher.tsx | 63 ++++++++++--------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/frontend/src/components/Courses/CourseDetailTeacher.tsx b/frontend/src/components/Courses/CourseDetailTeacher.tsx index aacea516..24d816ee 100644 --- a/frontend/src/components/Courses/CourseDetailTeacher.tsx +++ b/frontend/src/components/Courses/CourseDetailTeacher.tsx @@ -180,9 +180,17 @@ export function CourseDetailTeacher(): JSX.Element { return ( <> - - - + + +
{t("projects")}: @@ -192,19 +200,12 @@ export function CourseDetailTeacher(): JSX.Element { - - + + - - - handleDeleteStudent( - navigate, - course.course_id, - selectedStudents - ) - } - > - - {t("deleteSelected")} - - - @@ -292,6 +277,20 @@ export function CourseDetailTeacher(): JSX.Element { + + + handleDeleteStudent( + navigate, + course.course_id, + selectedStudents + ) + } + > + + {t("deleteSelected")} + @@ -488,7 +487,9 @@ function JoinCodeMenu({ const handleCopyToClipboard = (join_code: string) => { const host = window.location.host; - navigator.clipboard.writeText(`${host}/${i18next.language}/courses/join?code=${join_code}`); + navigator.clipboard.writeText( + `${host}/${i18next.language}/courses/join?code=${join_code}` + ); }; const getCodes = useCallback(() => {