diff --git a/backend/project/endpoints/projects/endpoint_parser.py b/backend/project/endpoints/projects/endpoint_parser.py index 48ef4874..895658be 100644 --- a/backend/project/endpoints/projects/endpoint_parser.py +++ b/backend/project/endpoints/projects/endpoint_parser.py @@ -28,7 +28,7 @@ help='Projects visibility for students', location="form" ) -parser.add_argument("archived", type=bool, help='Projects', location="form") +parser.add_argument("archived", type=str, help='Projects', location="form") parser.add_argument( "regex_expressions", type=str, @@ -61,6 +61,8 @@ def parse_project_params(): ) ) result_dict[key] = new_deadlines + elif "archived" == key: + result_dict[key] = value == "true" else: result_dict[key] = value diff --git a/backend/requirements.txt b/backend/requirements.txt index 529099ca..b9d38d15 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,7 +9,7 @@ psycopg2-binary docker pytest~=8.0.1 SQLAlchemy~=2.0.27 -requests>=2.31.0 +requests<=2.31.0 waitress flask_swagger_ui -flask_executor \ No newline at end of file +flask_executor diff --git a/documentation/docusaurus.config.ts b/documentation/docusaurus.config.ts index bf28738f..1396432a 100644 --- a/documentation/docusaurus.config.ts +++ b/documentation/docusaurus.config.ts @@ -35,10 +35,6 @@ const config: Config = { { docs: { sidebarPath: './sidebars.ts', - // Please change this to your repo. - // Remove this to remove the "edit this page" links. - editUrl: - 'https://github.com/SELab-2/UGent-3', }, theme: { customCss: './src/css/custom.css', diff --git a/frontend/public/locales/en/projectformTranslation.json b/frontend/public/locales/en/projectformTranslation.json index bdaa2dcf..fd48043a 100644 --- a/frontend/public/locales/en/projectformTranslation.json +++ b/frontend/public/locales/en/projectformTranslation.json @@ -17,7 +17,7 @@ "runnerComponent": { "testWarning": "No appropriate test file found, can't upload project", "clearSelected": "Clear Selection", - "tooltipRunner": "If you're having trouble figuring out the runner please refer to the docs", + "tooltipRunner": "If you're having trouble figuring out the runner please refer to the ", "userDocs": "runner user docs" }, "dragAndDrop": { diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 86ed53b3..e6b5c6e5 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -52,7 +52,7 @@ "cancel": "Cancel", "create": "Create", "activeCourses": "Active Courses", - "archivedCourses":"Archived Courses" + "archivedCourses":"Archived Courses" }, "courseForm": { "courseName": "Course Name", @@ -72,7 +72,9 @@ "running": "Running", "submitTime": "Time submitted", "status": "Status" - } + }, + "projectOverview": "Overview", + "archive": "Archive" }, "time": { "yearsAgo": "years ago", @@ -149,4 +151,4 @@ "no_projects": "There are no projects here.", "new_project": "New Project" } -} +} \ No newline at end of file diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json index 0b693055..7715aecd 100644 --- a/frontend/public/locales/nl/translation.json +++ b/frontend/public/locales/nl/translation.json @@ -109,7 +109,9 @@ "running": "Aan het uitvoeren", "submitTime": "Indientijd", "status": "Status" - } + }, + "projectOverview": "Overzicht", + "archive": "Archiveer" }, "time": { "yearsAgo": "jaren geleden", diff --git a/frontend/src/components/Courses/AllCoursesTeacher.tsx b/frontend/src/components/Courses/AllCoursesTeacher.tsx index a0e15145..fb3a3b62 100644 --- a/frontend/src/components/Courses/AllCoursesTeacher.tsx +++ b/frontend/src/components/Courses/AllCoursesTeacher.tsx @@ -1,30 +1,42 @@ -import { Button, Dialog, DialogActions, DialogTitle, FormControl, FormHelperText, Grid, Input, InputLabel } from "@mui/material"; +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, ProjectDetail} from "./CourseUtils"; +import { Course, callToApiToCreateCourse, ProjectDetail } from "./CourseUtils"; import { Title } from "../Header/Title"; import { useLoaderData } from "react-router-dom"; +import { Me } from "../../types/me"; /** * @returns A jsx component representing all courses for a teacher */ export function AllCoursesTeacher(): JSX.Element { const [open, setOpen] = useState(false); - const loader = useLoaderData() as { + const { courses, projects, me } = useLoaderData() as { courses: Course[]; + me: Me; projects: { [courseId: string]: ProjectDetail[] }; }; - const courses = loader.courses; - const projects = loader.projects; - const [courseName, setCourseName] = useState(''); - const [error, setError] = useState(''); + const [courseName, setCourseName] = useState(""); + const [error, setError] = useState(""); const navigate = useNavigate(); - const { t } = useTranslation('translation', { keyPrefix: 'allCoursesTeacher' }); + const { t } = useTranslation("translation", { + keyPrefix: "allCoursesTeacher", + }); const handleClickOpen = () => { setOpen(true); @@ -36,14 +48,14 @@ export function AllCoursesTeacher(): JSX.Element { const handleInputChange = (event: React.ChangeEvent) => { setCourseName(event.target.value); - setError(''); // Clearing error message when user starts typing + 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')); + setError(t("emptyCourseNameError")); return; } @@ -52,14 +64,21 @@ export function AllCoursesTeacher(): JSX.Element { }; return ( <> - - - + + + - {t('courseForm')} + {t("courseForm")}
- {t('courseName')} + {t("courseName")} - {error && {error}} + {error && ( + {error} + )} - - + +
- - - + {me && me.role === "TEACHER" && ( + + + + )}
); -} \ No newline at end of file +} diff --git a/frontend/src/components/Courses/CourseUtils.tsx b/frontend/src/components/Courses/CourseUtils.tsx index 68c81383..118f8b88 100644 --- a/frontend/src/components/Courses/CourseUtils.tsx +++ b/frontend/src/components/Courses/CourseUtils.tsx @@ -1,6 +1,7 @@ import { NavigateFunction, Params } from "react-router-dom"; import { authenticatedFetch } from "../../utils/authenticated-fetch"; import { Me } from "../../types/me"; +import { fetchMe } from "../../utils/fetches/FetchMe"; export interface Course { course_id: string; @@ -125,11 +126,12 @@ export const dataLoaderCourses = async () => { const courses = await fetchData(`courses`); const projects = await fetchProjectsCourse(courses); + const me = await fetchMe(); for( const c of courses){ const teacher = await fetchData(`users/${c.teacher}`) c.teacher = teacher.display_name } - return {courses, projects} + return {courses, projects, me} }; /** diff --git a/frontend/src/components/DeadlineView/DeadlineGrid.tsx b/frontend/src/components/DeadlineView/DeadlineGrid.tsx new file mode 100644 index 00000000..154442da --- /dev/null +++ b/frontend/src/components/DeadlineView/DeadlineGrid.tsx @@ -0,0 +1,43 @@ +import {Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow} from "@mui/material"; +import {Deadline} from "../../types/deadline.ts"; +import {useTranslation} from "react-i18next"; + +interface Props { + deadlines: Deadline[]; + minWidth: number +} + +/** + * @returns grid that displays deadlines in a grid + */ +export default function DeadlineGrid({deadlines, minWidth}: Props) { + + const { t } = useTranslation('translation', { keyPrefix: 'projectForm' }); + + return ( + + + + + {t("deadline")} + {t("description")} + + + + {deadlines.length === 0 ? ( // Check if deadlines is empty + + {t("noDeadlinesPlaceholder")} + + ) : ( + deadlines.map((deadline, index) => ( + + {deadline.deadline} + {deadline.description} + + )) + )} + +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/ProjectForm/ProjectForm.tsx b/frontend/src/components/ProjectForm/ProjectForm.tsx index 39f81dc0..46f8863a 100644 --- a/frontend/src/components/ProjectForm/ProjectForm.tsx +++ b/frontend/src/components/ProjectForm/ProjectForm.tsx @@ -31,6 +31,7 @@ import AdvancedRegex from "./AdvancedRegex.tsx"; import RunnerSelecter from "./RunnerSelecter.tsx"; import { authenticatedFetch } from "../../utils/authenticated-fetch.ts"; import i18next from "i18next"; +import DeadlineGrid from "../DeadlineView/DeadlineGrid.tsx"; interface Course { course_id: string; @@ -332,30 +333,7 @@ export default function ProjectForm() { deadlines={[]} onChange={(deadlines: Deadline[]) => handleDeadlineChange(deadlines)} editable={true} /> - - - - - {t("deadline")} - {t("description")} - - - - {deadlines.length === 0 ? ( // Check if deadlines is empty - - {t("noDeadlinesPlaceholder")} - - ) : ( - deadlines.map((deadline, index) => ( - - {deadline.deadline} - {deadline.description} - - )) - )} - -
-
+
@@ -372,7 +350,7 @@ export default function ProjectForm() { handleFileUpload2(file)} regexRequirements={[]} /> - {t("fileInfo")}: {t("userDocs")}}> + {t("fileInfo")}: {t("userDocs")}}> diff --git a/frontend/src/components/ProjectForm/RunnerSelecter.tsx b/frontend/src/components/ProjectForm/RunnerSelecter.tsx index d91dc2f1..73804945 100644 --- a/frontend/src/components/ProjectForm/RunnerSelecter.tsx +++ b/frontend/src/components/ProjectForm/RunnerSelecter.tsx @@ -66,7 +66,7 @@ export default function RunnerSelecter({ handleSubmit, runner, containsDocker, c {t("clearSelected")} - {t("tooltipRunner")}: {t("userDocs")}}> + {t("tooltipRunner")} {t("userDocs")}}> diff --git a/frontend/src/pages/project/projectView/ProjectView.tsx b/frontend/src/pages/project/projectView/ProjectView.tsx index 209279de..8b1f8dd8 100644 --- a/frontend/src/pages/project/projectView/ProjectView.tsx +++ b/frontend/src/pages/project/projectView/ProjectView.tsx @@ -1,21 +1,32 @@ import { + Box, + Button, Card, CardContent, CardHeader, Container, - Grid, - Link, + Fade, + Grid, IconButton, Stack, + TextField, Typography, } from "@mui/material"; -import { useEffect, useState } from "react"; +import {useCallback, useEffect, useState} from "react"; import Markdown from "react-markdown"; -import { useParams } from "react-router-dom"; +import {useLocation, useNavigate, useParams} from "react-router-dom"; import SubmissionCard from "./SubmissionCard"; import { Course } from "../../../types/course"; import { Title } from "../../../components/Header/Title"; import { authenticatedFetch } from "../../../utils/authenticated-fetch"; import i18next from "i18next"; +import {useTranslation} from "react-i18next"; +import {Me} from "../../../types/me.ts"; +import {fetchMe} from "../../../utils/fetches/FetchMe.ts"; +import DeadlineGrid from "../../../components/DeadlineView/DeadlineGrid.tsx"; +import {Deadline} from "../../../types/deadline.ts"; +import EditIcon from '@mui/icons-material/Edit'; +import CheckIcon from '@mui/icons-material/Check'; +import CloseIcon from '@mui/icons-material/Close'; const API_URL = import.meta.env.VITE_APP_API_HOST; @@ -23,6 +34,7 @@ interface Project { title: string; description: string; regex_expressions: string[]; + archived: string; } /** @@ -31,17 +43,75 @@ interface Project { * and submissions of the current user for that project */ export default function ProjectView() { + + const location = useLocation(); + const [me, setMe] = useState(null); + + const { t } = useTranslation('translation', { keyPrefix: 'projectView' }); + const { projectId } = useParams<{ projectId: string }>(); const [projectData, setProjectData] = useState(null); const [courseData, setCourseData] = useState(null); const [assignmentRawText, setAssignmentRawText] = useState(""); + const [deadlines, setDeadlines] = useState([]); + const [alertVisibility, setAlertVisibility] = useState(false) + const [edit, setEdit] = useState(false); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); - useEffect(() => { + const navigate = useNavigate() + const deleteProject = () => { + authenticatedFetch(`${API_URL}/projects/${projectId}`, { + method: "DELETE" + }); + navigate('/projects'); + } + + const patchTitleAndDescription = async () => { + setEdit(false); + const formData = new FormData(); + formData.append('title', title); + formData.append('description', description); + + const response = await authenticatedFetch(`${API_URL}/projects/${projectId}`, { + method: "PATCH", + body: formData + }); + + // Check if the response is ok (status code 2xx) + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + updateProject(); + } + + const discardEditTitle = () => { + const title = projectData?.title; + setEdit(false); + if (title) + setTitle(title); + + if (projectData?.description) + setDescription(projectData?.description); + } + + const updateProject = useCallback(async () => { authenticatedFetch(`${API_URL}/projects/${projectId}`).then((response) => { if (response.ok) { response.json().then((data) => { const projectData = data["data"]; setProjectData(projectData); + setTitle(projectData.title); + setDescription(projectData.description); + + const transformedDeadlines = projectData.deadlines.map((deadlineArray: string[]): Deadline => ({ + description: deadlineArray[0], + deadline: deadlineArray[1] + })); + + setDeadlines(transformedDeadlines); + authenticatedFetch( `${API_URL}/courses/${projectData.course_id}` ).then((response) => { @@ -54,6 +124,23 @@ export default function ProjectView() { }); } }); + }, [projectId]); + + const archiveProject = async () => { + const newArchived = !projectData?.archived; + const formData = new FormData(); + formData.append('archived', newArchived.toString()); + + await authenticatedFetch(`${API_URL}/projects/${projectId}`, { + method: "PATCH", + body: formData + }) + + await updateProject(); + } + + useEffect(() => { + updateProject(); authenticatedFetch( `${API_URL}/projects/${projectId}/assignment?lang=${i18next.resolvedLanguage}` @@ -62,56 +149,181 @@ export default function ProjectView() { response.text().then((data) => setAssignmentRawText(data)); } }); - }, [projectId]); + + fetchMe().then((data) => { + setMe(data); + }); + + }, [projectId, updateProject]); if (!projectId) return null; return ( - - - + + + {projectData && ( <CardHeader color="secondary" - title={projectData.title} + title={ + <Box + display="flex" + justifyContent="space-between" + alignItems="center" + > + { + !edit && <>{projectData.title}</> + } + { + edit && <><TextField id="edit-title" label="title" variant="outlined" size="small" defaultValue={title} onChange={(event) => setTitle(event.target.value)}/></> + } + {courseData && ( + <Button variant="outlined" type="link" href={`/${i18next.resolvedLanguage}/courses/${courseData.course_id}`}> + {courseData.name} + </Button> + )} + </Box> + } subheader={ - <> + <Box position="relative" height="100%" sx={{marginTop: "10px"}}> <Stack direction="row" spacing={2}> - <Typography>{projectData.description}</Typography> + { + !edit && <><Typography>{projectData.description}</Typography></> + } + { + edit && <><TextField id="edit-description" label="description" variant="outlined" size="small" defaultValue={description} onChange={(event) => setDescription(event.target.value)}/></> + } <Typography flex="1" /> - {courseData && ( - <Link href={`/${i18next.resolvedLanguage}/courses/${courseData.course_id}`}> - <Typography>{courseData.name}</Typography> - </Link> - )} </Stack> - </> + </Box> } /> <CardContent> <Markdown>{assignmentRawText}</Markdown> + <Box + display="flex" + alignItems="flex-end" + justifyContent="end" + > + { + edit && ( + <> + <IconButton onClick={patchTitleAndDescription}> + <CheckIcon /> + </IconButton> + <IconButton onClick={discardEditTitle}> + <CloseIcon /> + </IconButton> + </> + ) + } + { + !edit && ( + <IconButton onClick={() => setEdit(true)}> + <EditIcon /> + </IconButton> + ) + } + </Box> </CardContent> </Card> )} - </Container> - </Grid> - <Grid item sm={12}> - <Container> - <SubmissionCard - regexRequirements={projectData ? projectData.regex_expressions : []} - submissionUrl={`${API_URL}/submissions`} - projectId={projectId} - /> - </Container> + <Box marginTop="2rem"> + <SubmissionCard + regexRequirements={projectData ? projectData.regex_expressions : []} + submissionUrl={`${API_URL}/submissions`} + projectId={projectId} + /> + </Box> + {me && me.role == "TEACHER" && ( + <Box + width="100%"> + <Box display="flex" + flexDirection="row" + sx={{ + justifyContent: "space-around" + }} + pt={2} + width="100%" + > + <Box + display="flex" + flexDirection="row" + pt={2} + width="100%" + > + <Button + type="link" + variant="contained" + href={location.pathname + "/overview"} + sx={{marginRight: 1}} + > + {t("projectOverview")} + </Button> + <Button + variant="contained" + onClick={archiveProject} + > + {t("archive")} + </Button> + </Box> + <Box + display="flex" + flexDirection="row-reverse" + pt={2} + width="100%"> + <Button variant="contained" color="error" onClick={() => setAlertVisibility(true)}> + Delete + </Button> + </Box> + </Box> + <Box display="flex" style={{width: "100%" }}> + <div style={{flexGrow: 1}} /> + <Fade + style={{width: "fit-content"}} + in={alertVisibility} + timeout={{ enter: 1000, exit: 1000 }} + addEndListener={() => { + setTimeout(() => { + setAlertVisibility(false); + }, 4000); + }} + > + <Box sx={{ border: 1, p: 1, bgcolor: 'background.paper' }}> + <Typography>Are you sure you want to delete this project</Typography> + <Box display="flex" + flexDirection="row" + sx={{ + justifyContent: "center" + }} + pt={2} + width="100%" + > + <Button variant="contained" onClick={deleteProject}> + Yes I'm Sure + </Button> + </Box> + </Box> + </Fade> + </Box> + + </Box> + )} + </Grid> + <Grid item sm={4}> + <Box marginTop="2rem"> + <DeadlineGrid deadlines={deadlines} minWidth={0} /> + </Box> + </Grid> </Grid> - </Grid> + </Container> ); + }