diff --git a/backend/app/src/main/java/com/ugent/pidgeon/controllers/SubmissionController.java b/backend/app/src/main/java/com/ugent/pidgeon/controllers/SubmissionController.java index d27f4fb7..00d2f997 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/controllers/SubmissionController.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/controllers/SubmissionController.java @@ -140,7 +140,6 @@ private Map> getLatestSubmissionsForProject(lon @GetMapping(ApiRoutes.PROJECT_BASE_PATH + "/{projectid}/submissions") //Route to get all submissions for a project @Roles({UserRole.teacher, UserRole.student}) public ResponseEntity getSubmissions(@PathVariable("projectid") long projectid, Auth auth) { - try { CheckResult checkResult = projectUtil.isProjectAdmin(projectid, auth.getUserEntity()); if (!checkResult.getStatus().equals(HttpStatus.OK)) { return ResponseEntity.status(checkResult.getStatus()).body(checkResult.getMessage()); @@ -170,9 +169,6 @@ public ResponseEntity getSubmissions(@PathVariable("projectid") long projecti } return ResponseEntity.ok(res); - } catch (Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage()); - } } /** diff --git a/backend/app/src/main/java/com/ugent/pidgeon/json/GroupFeedbackJson.java b/backend/app/src/main/java/com/ugent/pidgeon/json/GroupFeedbackJson.java index 208e24d8..f0d8a797 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/json/GroupFeedbackJson.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/json/GroupFeedbackJson.java @@ -2,7 +2,7 @@ public class GroupFeedbackJson { - private float score; + private Float score; private String feedback; private long groupId; @@ -11,7 +11,7 @@ public class GroupFeedbackJson { public GroupFeedbackJson() { } - public GroupFeedbackJson(float score, String feedback, long groupId, long projectId) { + public GroupFeedbackJson(Float score, String feedback, long groupId, long projectId) { this.score = score; this.feedback = feedback; this.groupId = groupId; @@ -19,7 +19,7 @@ public GroupFeedbackJson(float score, String feedback, long groupId, long projec } - public float getScore() { + public Float getScore() { return score; } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/json/GroupFeedbackJsonWithProject.java b/backend/app/src/main/java/com/ugent/pidgeon/json/GroupFeedbackJsonWithProject.java index 00f609d3..006f0e00 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/json/GroupFeedbackJsonWithProject.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/json/GroupFeedbackJsonWithProject.java @@ -6,12 +6,12 @@ public class GroupFeedbackJsonWithProject { private String projectUrl; private GroupFeedbackJson groupFeedback; - private float maxScore; + private Integer maxScore; private Long projectId; public GroupFeedbackJsonWithProject(String projectName, String projectUrl, Long projectId, - GroupFeedbackJson groupFeedback, float maxScore) { + GroupFeedbackJson groupFeedback, Integer maxScore) { this.projectName = projectName; this.projectUrl = projectUrl; this.groupFeedback = groupFeedback; @@ -43,11 +43,11 @@ public void setGroupFeedback(GroupFeedbackJson groupFeedback) { this.groupFeedback = groupFeedback; } - public float getMaxScore() { + public Integer getMaxScore() { return maxScore; } - public void setMaxScore(float maxScore) { + public void setMaxScore(Integer maxScore) { this.maxScore = maxScore; } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerTemplateTestOutput.java b/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerTemplateTestOutput.java index dd912486..04465cdf 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerTemplateTestOutput.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerTemplateTestOutput.java @@ -1,6 +1,8 @@ package com.ugent.pidgeon.model.submissionTesting; import java.util.List; +import java.util.logging.Logger; +import org.hibernate.usertype.LoggableUserType; public class DockerTemplateTestOutput implements DockerOutput{ private final List subtestResults; @@ -24,9 +26,12 @@ public String getFeedbackAsString(){ //json representation of the tests StringBuilder feedback = new StringBuilder("{\"subtests\": ["); for (DockerSubtestResult subtestResult : subtestResults) { - feedback.append(subtestResult.getFeedbackAsString()).append(","); + feedback.append(subtestResult.getFeedbackAsString()) + .append(","); } - feedback.append("]"); + feedback.deleteCharAt(feedback.length() - 1); // remove last comma , + feedback.append("]}"); + Logger.getGlobal().info(feedback.toString()); return feedback.toString(); } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/SubmissionTemplateModel.java b/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/SubmissionTemplateModel.java index f8d71808..c33db35c 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/SubmissionTemplateModel.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/SubmissionTemplateModel.java @@ -185,16 +185,16 @@ public SubmissionResult checkSubmission(ZipFile file) throws IOException { boolean passed = (filesMissing.size() + filesUnrequested.size() + filesDenied.size()) == 0; String feedback = passed ? "File structure is correct" : "File structure failed to pass the template, because: \n "; if (!filesMissing.isEmpty()) { - feedback += " -The following files are required from the template and are not found in the project: \n -"; - feedback += String.join("\n -", filesMissing); + feedback += "- The following files are required from the template and are not found in the project: \n - "; + feedback += String.join("\n - ", filesMissing); } if (!filesUnrequested.isEmpty()) { - feedback += "\n -The following files are not requested in the template: \n -"; - feedback += String.join("\n -", filesUnrequested); + feedback += "\n - The following files are not requested in the template: \n - "; + feedback += String.join("\n - ", filesUnrequested); } if (!filesDenied.isEmpty()) { - feedback += "\n -The following files are not allowed in the project: \n -"; - feedback += String.join("\n -", filesDenied); + feedback += "\n - The following files are not allowed in the project: \n - "; + feedback += String.join("\n - ", filesDenied); } return new SubmissionResult(passed, feedback); diff --git a/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/GroupFeedbackEntity.java b/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/GroupFeedbackEntity.java index 88a2b697..ce770054 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/GroupFeedbackEntity.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/GroupFeedbackEntity.java @@ -56,11 +56,11 @@ public String getFeedback() { } - public float getScore() { + public Float getScore() { return grade; } - public void setScore(float score) { + public void setScore(Float score) { this.grade = score; } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/util/SubmissionUtil.java b/backend/app/src/main/java/com/ugent/pidgeon/util/SubmissionUtil.java index 13b0922f..c46f0c28 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/util/SubmissionUtil.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/util/SubmissionUtil.java @@ -104,7 +104,7 @@ public CheckResult checkOnSubmit(long projectId, UserEntity user) { OffsetDateTime time = OffsetDateTime.now(); Logger.getGlobal().info("Time: " + time + " Deadline: " + project.getDeadline()); - if (time.isAfter(project.getDeadline())) { + if (time.isAfter(project.getDeadline()) && groupId != null) { return new CheckResult<>(HttpStatus.FORBIDDEN, "Project deadline has passed", null); } return new CheckResult<>(HttpStatus.OK, "", groupId); diff --git a/backend/app/src/main/java/com/ugent/pidgeon/util/TestRunner.java b/backend/app/src/main/java/com/ugent/pidgeon/util/TestRunner.java index 25ca7c64..74578600 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/util/TestRunner.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/util/TestRunner.java @@ -6,6 +6,7 @@ import com.ugent.pidgeon.postgre.models.TestEntity; import java.io.File; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.zip.ZipFile; @@ -40,8 +41,12 @@ public DockerOutput runDockerTest(ZipFile file, TestEntity testEntity, Path outp // Init container and add input files try { + model.addZipInputFiles(file); - model.addUtilFiles(Filehandler.getTestExtraFilesPath(projectId).resolve(Filehandler.EXTRA_TESTFILES_FILENAME)); + Path path = Filehandler.getTestExtraFilesPath(projectId).resolve(Filehandler.EXTRA_TESTFILES_FILENAME); + if (Files.exists(path)) { + model.addUtilFiles(path); + } DockerOutput output; if (testTemplate == null) { diff --git a/backend/app/src/test/java/com/ugent/pidgeon/controllers/GroupFeedbackControllerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/controllers/GroupFeedbackControllerTest.java index c376bb15..42b5731d 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/controllers/GroupFeedbackControllerTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/controllers/GroupFeedbackControllerTest.java @@ -277,7 +277,7 @@ public void testAddGroupScore() throws Exception { when(groupFeedbackUtil.checkGroupFeedbackUpdate(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), getMockUser(), HttpMethod.POST)) .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); when(groupFeedbackUtil.checkGroupFeedbackUpdateJson(argThat( - json -> json.getScore() == groupFeedbackEntity.getScore() && json.getFeedback().equals(groupFeedbackEntity.getFeedback())), eq(groupFeedbackEntity.getProjectId()))) + json -> Objects.equals(json.getScore(), groupFeedbackEntity.getScore()) && json.getFeedback().equals(groupFeedbackEntity.getFeedback())), eq(groupFeedbackEntity.getProjectId()))) .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); when(groupFeedbackRepository.save(any())).thenReturn(groupFeedbackEntity); when(entityToJsonConverter.groupFeedbackEntityToJson(groupFeedbackEntity)).thenReturn(groupFeedbackJson); @@ -288,7 +288,7 @@ public void testAddGroupScore() throws Exception { .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(content().json(objectMapper.writeValueAsString(groupFeedbackJson))); verify(groupFeedbackRepository, times(1)).save(argThat( - groupFeedback -> groupFeedback.getScore() == groupFeedbackEntity.getScore() && + groupFeedback -> Objects.equals(groupFeedback.getScore(), groupFeedbackEntity.getScore()) && groupFeedback.getFeedback().equals(groupFeedbackEntity.getFeedback()) && groupFeedback.getGroupId() == groupFeedbackEntity.getGroupId() && groupFeedback.getProjectId() == groupFeedbackEntity.getProjectId())); diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/SubmissionUtilTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/SubmissionUtilTest.java index 6cbe28d0..8667740c 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/util/SubmissionUtilTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/SubmissionUtilTest.java @@ -154,6 +154,11 @@ public void testCheckOnSubmit() { result = submissionUtil.checkOnSubmit(projectEntity.getId(), userEntity); assertEquals(HttpStatus.OK, result.getStatus()); assertNull(result.getData()); + + /* Deadline passed when user is admin, should still be allowed */ + projectEntity.setDeadline(OffsetDateTime.now().minusDays(1)); + result = submissionUtil.checkOnSubmit(projectEntity.getId(), userEntity); + assertEquals(HttpStatus.OK, result.getStatus()); /* User not part of group and not admin */ when(projectUtil.isProjectAdmin(projectEntity.getId(), userEntity)) @@ -168,6 +173,7 @@ public void testCheckOnSubmit() { result = submissionUtil.checkOnSubmit(projectEntity.getId(), userEntity); assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + /* GroupCluster in archived course */ when(groupClusterRepository.inArchivedCourse(groupEntity.getClusterId())).thenReturn(true); result = submissionUtil.checkOnSubmit(projectEntity.getId(), userEntity); diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/TestRunnerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/TestRunnerTest.java index 04a4a42f..c71c0bb5 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/util/TestRunnerTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/TestRunnerTest.java @@ -119,7 +119,6 @@ public void testRunDockerTest() throws IOException { verify(dockerModel, times(1)).addZipInputFiles(file); verify(dockerModel, times(1)).cleanUp(); - verify(dockerModel, times(1)).addUtilFiles(extraFilesPathResolved); assertEquals(1, filehandlerCalled.get()); /* artifacts are empty */ @@ -128,7 +127,6 @@ public void testRunDockerTest() throws IOException { assertEquals(dockerTemplateTestOutput, result); verify(dockerModel, times(2)).addZipInputFiles(file); verify(dockerModel, times(2)).cleanUp(); - verify(dockerModel, times(2)).addUtilFiles(extraFilesPathResolved); assertEquals(1, filehandlerCalled.get()); /* aritifacts are null */ @@ -137,7 +135,6 @@ public void testRunDockerTest() throws IOException { assertEquals(dockerTemplateTestOutput, result); verify(dockerModel, times(3)).addZipInputFiles(file); verify(dockerModel, times(3)).cleanUp(); - verify(dockerModel, times(3)).addUtilFiles(extraFilesPathResolved); assertEquals(1, filehandlerCalled.get()); /* No template */ @@ -147,7 +144,6 @@ public void testRunDockerTest() throws IOException { assertEquals(dockerTestOutput, result); verify(dockerModel, times(4)).addZipInputFiles(file); verify(dockerModel, times(4)).cleanUp(); - verify(dockerModel, times(4)).addUtilFiles(extraFilesPathResolved); /* Error gets thrown */ when(dockerModel.runSubmission(testEntity.getDockerTestScript())).thenThrow(new RuntimeException("Error")); diff --git a/backend/app/src/test/test-cases/DockerSubmissionTestTest/d__test.zip b/backend/app/src/test/test-cases/DockerSubmissionTestTest/d__test.zip index ac148ebf..b95a2782 100644 Binary files a/backend/app/src/test/test-cases/DockerSubmissionTestTest/d__test.zip and b/backend/app/src/test/test-cases/DockerSubmissionTestTest/d__test.zip differ diff --git a/frontend/README.md b/frontend/README.md deleted file mode 100644 index cc946687..00000000 --- a/frontend/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# Getting Started with React - -## Available Scripts - -In the project directory, you can run: - -### `npm start` - -Runs the app in the development mode.\ -Open [http://localhost:3000](http://localhost:3000) to view it in the browser. - -The page will reload if you make edits.\ -You will also see any lint errors in the console. - -### `npm test` - -Launches the test runner in the interactive watch mode.\ -See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. - -### `npm run build` - -Builds the app for production to the `build` folder.\ -It correctly bundles React in production mode and optimizes the build for the best performance. - -The build is minified and the filenames include the hashes.\ -Your app is ready to be deployed! - -See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. - -### `npm run eject` - -**Note: this is a one-way operation. Once you `eject`, you can’t go back!** - -If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. - -Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. - -You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. - -## Learn More - -You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). - -To learn React, check out the [React documentation](https://reactjs.org/). diff --git a/frontend/index.html b/frontend/index.html index 72c262f1..d0505398 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,7 +7,7 @@ diff --git a/frontend/public/docker_langauges/bash.svg b/frontend/public/docker_langauges/bash.svg new file mode 100644 index 00000000..890b5d92 --- /dev/null +++ b/frontend/public/docker_langauges/bash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/docker_langauges/custom.svg b/frontend/public/docker_langauges/custom.svg new file mode 100644 index 00000000..c3416414 --- /dev/null +++ b/frontend/public/docker_langauges/custom.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/docker_langauges/haskell.svg b/frontend/public/docker_langauges/haskell.svg new file mode 100644 index 00000000..0c627ebd --- /dev/null +++ b/frontend/public/docker_langauges/haskell.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/docker_langauges/node-js.svg b/frontend/public/docker_langauges/node-js.svg new file mode 100644 index 00000000..3e77c253 --- /dev/null +++ b/frontend/public/docker_langauges/node-js.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/docker_langauges/python.svg b/frontend/public/docker_langauges/python.svg new file mode 100644 index 00000000..bf2a1601 --- /dev/null +++ b/frontend/public/docker_langauges/python.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/@types/requests.d.ts b/frontend/src/@types/requests.d.ts index 67dad1cc..f0718c14 100644 --- a/frontend/src/@types/requests.d.ts +++ b/frontend/src/@types/requests.d.ts @@ -10,7 +10,7 @@ export enum ApiRoutes { USER_COURSES = "/web/api/courses", COURSES = "/web/api/courses", - + COURSE = "/web/api/courses/:courseId", COURSE_MEMBERS = "/web/api/courses/:courseId/members", @@ -19,11 +19,11 @@ export enum ApiRoutes { COURSE_CLUSTERS = "/web/api/courses/:id/clusters", COURSE_GRADES = "/web/api/courses/:id/grades", COURSE_LEAVE = "/web/api/courses/:courseId/leave", - COURSE_COPY = "/web/api/courses/:courseId/copy", + COURSE_COPY = "/web/api/courses/:courseId/copy", COURSE_JOIN = "/web/api/courses/:courseId/join/:courseKey", COURSE_JOIN_WITHOUT_KEY = "/web/api/courses/:courseId/join", COURSE_JOIN_LINK = "/web/api/courses/:courseId/joinKey", - + PROJECTS = "/web/api/projects", PROJECT = "/web/api/projects/:id", PROJECT_CREATE = "/web/api/courses/:courseId/projects", @@ -36,8 +36,8 @@ export enum ApiRoutes { PROJECT_TEST_SUBMISSIONS = "/web/api/projects/:projectId/adminsubmissions", PROJECT_TESTS_UPLOAD = "/web/api/projects/:id/tests/extrafiles", PROJECT_SUBMIT = "/web/api/projects/:id/submit", - PROJECT_DOWNLOAD_ALL_SUBMISSIONS = "/web/api/projects/:id/submissions/files", - + PROJECT_DOWNLOAD_ALL_SUBMISSIONS = "/web/api/projects/:id/submissions/files", + SUBMISSION = "/web/api/submissions/:id", SUBMISSION_FILE = "/web/api/submissions/:id/file", @@ -49,7 +49,8 @@ export enum ApiRoutes { CLUSTER = "/web/api/clusters/:id", CLUSTER_FILL = "/web/api/clusters/:id/fill", - + CLUSTER_GROUPS = "/web/api/clusters/:id/groups", + GROUP = "/web/api/groups/:id", GROUP_MEMBERS = "/web/api/groups/:id/members", GROUP_MEMBER = "/web/api/groups/:id/members/:userId", @@ -98,6 +99,7 @@ export type POST_Requests = { [ApiRoutes.COURSE_JOIN]: undefined [ApiRoutes.COURSE_JOIN_WITHOUT_KEY]: undefined [ApiRoutes.PROJECT_SCORE]: Omit + [ApiRoutes.CLUSTER_GROUPS]: {name: string} } /** @@ -129,6 +131,7 @@ export type DELETE_Requests = { [ApiRoutes.PROJECT_TESTS]: undefined [ApiRoutes.COURSE_JOIN_LINK]: undefined [ApiRoutes.PROJECT_TESTS_UPLOAD]: undefined + [ApiRoutes.CLUSTER]: undefined } @@ -236,7 +239,7 @@ export type GET_Responses = { projectUrl: ApiRoutes.PROJECT groupUrl: ApiRoutes.GROUP | null fileUrl: ApiRoutes.SUBMISSION_FILE - structureFeedback: ApiRoutes.SUBMISSION_STRUCTURE_FEEDBACK + structureFeedback: string dockerFeedback: DockerFeedback, artifactUrl: ApiRoutes.SUBMISSION_ARTIFACT | null } diff --git a/frontend/src/auth/MsGraphApiCall.ts b/frontend/src/auth/MsGraphApiCall.ts deleted file mode 100644 index 3a2cc7d5..00000000 --- a/frontend/src/auth/MsGraphApiCall.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { loginRequest, graphConfig } from "./AuthConfig"; -import { msalInstance } from "../index"; - -export async function callMsGraph() { - const account = msalInstance.getActiveAccount(); - if (!account) { - throw Error("No active account! Verify a user has been signed in and setActiveAccount has been called."); - } - - - const response = await msalInstance.acquireTokenSilent({ - ...loginRequest, - account: account - }); - - const headers = new Headers(); - const bearer = `Bearer ${response.accessToken}`; - - headers.append("Authorization", bearer); - - const options = { - method: "GET", - headers: headers - }; - - return fetch(graphConfig.graphMeEndpoint, options) - .then(response => response.json()) - .catch(error => console.log(error)); -} \ No newline at end of file diff --git a/frontend/src/components/LanguageDropdown.tsx b/frontend/src/components/LanguageDropdown.tsx index 7375a33a..ca9a3b8a 100644 --- a/frontend/src/components/LanguageDropdown.tsx +++ b/frontend/src/components/LanguageDropdown.tsx @@ -25,8 +25,8 @@ const LanguageDropdown = () => { - return - + return + {app.language} diff --git a/frontend/src/components/common/MarkdownTooltip.tsx b/frontend/src/components/common/MarkdownTooltip.tsx new file mode 100644 index 00000000..89ca83ed --- /dev/null +++ b/frontend/src/components/common/MarkdownTooltip.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Space, Tooltip } from 'antd'; +import { QuestionCircleOutlined } from '@ant-design/icons'; +import MarkdownTextfield from '../input/MarkdownTextfield'; + +interface CustomTooltipProps { + label: string; + tooltipContent: string; + placement?: 'top' | 'left' | 'right' | 'bottom' | 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight' | 'leftTop' | 'leftBottom' | 'rightTop' | 'rightBottom'; +} + +const CustomTooltip: React.FC = ({ label, tooltipContent, placement = 'bottom' }) => { + + const contentLength = tooltipContent.length; + const calculatedWidth = contentLength > 100 ? "500px" : "auto"; + + const overlayInnerStyle = { width: calculatedWidth, maxWidth: "75vw", paddingLeft:"12px"}; + + return ( + + {label} + + } overlayInnerStyle={overlayInnerStyle} className='tooltip-markdown'> + + + + ); +}; + +export default CustomTooltip; diff --git a/frontend/src/components/common/saveDockerForm.tsx b/frontend/src/components/common/saveDockerForm.tsx index 58062470..e31c0392 100644 --- a/frontend/src/components/common/saveDockerForm.tsx +++ b/frontend/src/components/common/saveDockerForm.tsx @@ -1,45 +1,55 @@ -import { FormInstance, GetProp, UploadProps } from "antd" +import { FormInstance} from "antd" import { ApiRoutes, POST_Requests } from "../../@types/requests.d" import { UseApiType } from "../../hooks/useApi" -import apiCall from "../../util/apiFetch" import { RcFile } from "antd/es/upload" export type DockerFormData = POST_Requests[ApiRoutes.PROJECT_TESTS] -type FileType = RcFile//Parameters>[0] - -const saveDockerForm = async (form: FormInstance, initialDockerValues: DockerFormData | null, API: UseApiType, projectId: string) => { - if (!form.isFieldsTouched(["dockerImage", "dockerScript", "dockerTemplate", "structureTest"])) return null - - let data: DockerFormData = form.getFieldsValue(["dockerImage", "dockerScript", "dockerTemplate", "structureTest"]) - - if (!initialDockerValues) { - // We do a POST request - console.log("POST", data) - await API.POST(ApiRoutes.PROJECT_TESTS, { body: data, pathValues: { id: projectId } }) - } else if (data.dockerImage == null || data.dockerImage.length === 0) { - // We do a delete - console.log("DELETE", data) - await API.DELETE(ApiRoutes.PROJECT_TESTS, { pathValues: { id: projectId } }) - } else { - // We do a PUT - console.log("PUT", data) - await API.PUT(ApiRoutes.PROJECT_TESTS, { body: data, pathValues: { id: projectId }, headers: {} }) +type FileType = RcFile //Parameters>[0] + +const saveDockerForm = async (form: FormInstance, initialDockerValues: (DockerFormData & {dockerMode:boolean}) | null, API: UseApiType, projectId: string) => { + const dockerImage = form.getFieldValue("dockerImage") + const dockerScript = form.getFieldValue("dockerScript") + const dockerTemplate = form.getFieldValue("dockerTemplate") + const structureTest = form.getFieldValue("structureTest") + const dockerMode = form.getFieldValue("dockerMode") + + let success = true + + if (form.isFieldsTouched(["dockerImage", "dockerScript", "dockerTemplate", "structureTest", "dockerMode"]) && (!initialDockerValues || initialDockerValues.dockerImage !== dockerImage || initialDockerValues.dockerScript !== dockerScript || initialDockerValues.dockerTemplate !== dockerTemplate || initialDockerValues.structureTest !== structureTest || initialDockerValues.dockerMode !== dockerMode)) { + let data: DockerFormData = form.getFieldsValue(["dockerImage", "dockerScript", "dockerTemplate", "structureTest"]) + if (!data.dockerImage?.length) { + data.dockerScript = null + data.dockerTemplate = null + } + if (!dockerMode) data.dockerTemplate = null + + if (!initialDockerValues) { + // We do a POST request + const r = await API.POST(ApiRoutes.PROJECT_TESTS, { body: data, pathValues: { id: projectId } }, "message") + success &&= r.success + } else if ((data.dockerImage == null || data.dockerImage.length === 0) && !data.structureTest?.length) { + // We do a delete + const r = await API.DELETE(ApiRoutes.PROJECT_TESTS, { pathValues: { id: projectId } }, "message") + success &&= r.success + } else { + // We do a PUT + const r = await API.PUT(ApiRoutes.PROJECT_TESTS, { body: data, pathValues: { id: projectId }, headers: {} }, "message") + success &&= r.success + } } if (form.isFieldTouched("dockerTestDir")) { const val: FileType | undefined = form.getFieldValue("dockerTestDir")?.[0]?.originFileObj - + if (val === undefined) { // DELETE - await API.DELETE(ApiRoutes.PROJECT_TESTS_UPLOAD, { pathValues: { id: projectId } }, "message") + const r = await API.DELETE(ApiRoutes.PROJECT_TESTS_UPLOAD, { pathValues: { id: projectId } }, "message") + success &&= r.success } else { const formData = new FormData() formData.append("file", val, val.name) - try { - await apiCall.put(ApiRoutes.PROJECT_TESTS_UPLOAD, formData, {id: projectId}) - } catch(err){ - console.error(err); - } + const r = await API.PUT(ApiRoutes.PROJECT_TESTS_UPLOAD, { body: formData, pathValues: { id: projectId } }, "message") + success &&= r.success // await API.PUT( // ApiRoutes.PROJECT_TESTS_UPLOAD, @@ -53,8 +63,8 @@ const saveDockerForm = async (form: FormInstance, initialDockerValues: DockerFor // "message" // ) } - console.log(val) } + return success } export default saveDockerForm diff --git a/frontend/src/components/forms/ProjectForm.tsx b/frontend/src/components/forms/ProjectForm.tsx index 47a41c49..d7728ff2 100644 --- a/frontend/src/components/forms/ProjectForm.tsx +++ b/frontend/src/components/forms/ProjectForm.tsx @@ -17,6 +17,7 @@ const ProjectForm: FC = ({ form, fieldName, disabled }) => { - const handleFileUpload = (file: File) => { - const reader = new FileReader() - reader.onload = (e) => { - const contents = e.target?.result as string - console.log(contents) - form.setFieldValue(fieldName, contents) - } - reader.readAsText(file) - // Prevent default upload action - return false - } +import BashIcon from "../../../../public/docker_langauges/bash.svg" +import PythonIcon from "../../../../public/docker_langauges/python.svg" +import NodeIcon from "../../../../public/docker_langauges/node-js.svg" +import HaskellIcon from "../../../../public/docker_langauges/haskell.svg" +import Custom from "../../../../public/docker_langauges/custom.svg" - return ( - <> -
- - - -
- - ) + +type DockerLanguage = "bash" | "python" | "node" | "haskell" | "custom" +const languageOptions: Record = { + bash: "fedora", + python: "python", + node: "node", + haskell: "haskell", + custom: "" } -function isValidTemplate(template: string): string { - if (!template?.length) return "" // Template is optional - let atLeastOne = false // Template should not be empty - const lines = template.split("\n") - if (lines[0].charAt(0) !== "@") { - return 'Error: The first character of the first line should be "@"' - } - let isConfigurationLine = false - for (const line of lines) { - if (line.length === 0) { - // skip line if empty - continue - } - if (line.charAt(0) === "@") { - atLeastOne = true - isConfigurationLine = true - continue - } - if (isConfigurationLine) { - if (line.charAt(0) === ">") { - const isDescription = line.length >= 13 && line.substring(0, 13).toLowerCase() === ">description=" - // option lines - if (line.toLowerCase() !== ">required" && line.toLowerCase() !== ">optional" && !isDescription) { - return 'Error: Option lines should be either ">Required", ">Optional" or start with ">Description="' - } - } else { - isConfigurationLine = false - } - } - } - if (!atLeastOne) { - return "Error: Template should not be empty" - } - return "" +const imageToLanguage: Record = { + fedora: "bash", + python: "python", + node: "node", + haskell: "haskell", } + +const languagesSelectorItems:SelectProps["options"] = [ + { + label: <>Bash, + value: "bash", + },{ + label: <>Python, + value: "python", + }, { + label: <>NodeJS, + value: "node", + }, { + label: <>Haskell, + value: "haskell", + }, { + label: <>Custom, + value: "custom", + } +] + + + const DockerFormTab: FC<{ form: FormInstance }> = ({ form }) => { const { t } = useTranslation() - const {message} = useAppApi() + const { message } = useAppApi() const dockerImage = Form.useWatch("dockerImage", form) + const dockerTemplate = Form.useWatch("dockerTemplate", form) + const dockerMode = Form.useWatch("dockerMode", form) const dockerDisabled = !dockerImage?.length + const withTemplate = (dockerMode === null && !!dockerTemplate?.length) || !!dockerMode + + + useEffect(() => { + + form.validateFields(["dockerScript", "dockerTemplate"]) + }, [dockerDisabled]) + + + const dockerImageSelect= useMemo(()=> imageToLanguage[dockerImage] || "custom",[dockerImage]) + + function isValidTemplate(template: string): string { + if (template.length === 0) { + return t("project.tests.dockerTemplateValidation.emptyTemplate") + } + let atLeastOne = false // Template should not be empty + const lines = template.split("\n") + if (lines[0].charAt(0) !== "@") { + return t("project.tests.dockerTemplateValidation.inValidFirstLine") + } + let isConfigurationLine = false + let lineNumber = 0 + for (const line of lines) { + lineNumber++ + if (line.length === 0) { + // skip line if empty + continue + } + if (line.charAt(0) === "@") { + atLeastOne = true + isConfigurationLine = true + continue + } + if (isConfigurationLine) { + if (line.charAt(0) === ">") { + const isDescription = line.length >= 13 && line.substring(0, 13).toLowerCase() === ">description=" + // option lines + if (line.toLowerCase() !== ">required" && line.toLowerCase() !== ">optional" && !isDescription) { + return t("project.tests.dockerTemplateValidation.inValidOptions", { line: lineNumber.toString() }) + } + } else { + isConfigurationLine = false + } + } + } + if (!atLeastOne) { + return t("project.tests.dockerTemplateValidation.emptyTemplate") + } + return "" + } + const normFile = (e: any) => { - console.log('Upload event:', e); if (Array.isArray(e)) { - return e; + return e } - return e?.fileList; - }; + return e?.fileList + } + + let switchClassName = "template-switch" + let scriptPlaceholder + + if (withTemplate) { + switchClassName += " template-switch-active" + scriptPlaceholder = 'bash /shared/input/helloworld.sh > "/shared/output/helloWorldTest"\n' + 'bash /shared/input/helloug.sh > "/shared/output/helloUGent"\n' + } else { + switchClassName += " template-switch-inactive" + scriptPlaceholder = "output=$(bash /shared/input/helloworld.sh)\n" + 'if [[ "$output" == "Hello World" ]]; then \n' + " echo 'Test one is successful\n" + " echo 'PUSH ALLOWED' > /shared/output/testOutput\n" + "else\n" + " echo 'Test one failed: script failed to print \"Hello World\"'\n" + "fi" + } + return ( <> + } name="dockerImage" - tooltip="TODO write docs for this" > form.setFieldValue("dockerImage", languageOptions[val])} + options={languagesSelectorItems} + />} placeholder={t("project.tests.dockerImagePlaceholder")} /> - <> + } name="dockerScript" - tooltip="TODO write docs for this" > - - - { - const errorMessage = isValidTemplate(value) - return errorMessage === "" ? Promise.resolve() : Promise.reject(new Error(errorMessage)) - }, - }, - ]} - > - - - - - - @@ -163,8 +194,8 @@ const DockerFormTab: FC<{ form: FormInstance }> = ({ form }) => { disabled={dockerDisabled} accept="application/zip, application/x-zip-compressed, application/octet-stream, application/x-zip, *.zip" beforeUpload={ (file) => { - const isPNG = file.type === 'application/zip' - if (!isPNG) { + const isZIP = file.type.includes('zip') || file.name.includes('.zip') + if (!isZIP) { message.error(`${file.name} is not a zip file`); return Upload.LIST_IGNORE } @@ -174,8 +205,65 @@ const DockerFormTab: FC<{ form: FormInstance }> = ({ form }) => { + {/* */} +
+ + + + +
+ + +
+ + + { + value ??= "" + if (dockerDisabled || !withTemplate) { + return Promise.resolve() + } + const errorMessage = isValidTemplate(value) + return errorMessage === "" ? Promise.resolve() : Promise.reject(new Error(errorMessage)) + }, required: true + } + ]} + > + + required\n>description=\"This is a test\"\nExpected output 1\n\n@helloUGent\n>optional\nExpected output 2\n"} + /> + {/**/} + +
+ + + ) } -export default DockerFormTab +export default DockerFormTab \ No newline at end of file diff --git a/frontend/src/components/forms/projectFormTabs/GeneralFormTab.tsx b/frontend/src/components/forms/projectFormTabs/GeneralFormTab.tsx index f81fd4c7..df5ca7c5 100644 --- a/frontend/src/components/forms/projectFormTabs/GeneralFormTab.tsx +++ b/frontend/src/components/forms/projectFormTabs/GeneralFormTab.tsx @@ -2,74 +2,88 @@ import { DatePicker, Form, FormInstance, Input, Switch, Typography } from "antd" import { useTranslation } from "react-i18next" import { FC } from "react" import MarkdownEditor from "../../input/MarkdownEditor" +import dayjs from "dayjs" const GeneralFormTab: FC<{ form: FormInstance }> = ({ form }) => { - const { t } = useTranslation() - const description = Form.useWatch("description", form) - const visible = Form.useWatch("visible", form) + const { t } = useTranslation() + const description = Form.useWatch("description", form) + const visible = Form.useWatch("visible", form) - return ( - <> - - - + return ( + <> + + + - - {t("project.change.description")} - - + {t("project.change.description")} + - - - + + + - {!visible && ( - - - - )} + {!visible && ( + + current && current.isBefore(dayjs().startOf("day"))} + /> + + )} - - - + + + - - - - - ) + + { + const hours = [] + for (let i = 0; i < dayjs().hour(); i++) { + hours.push(i) + } + return hours + }, + }} + format="YYYY-MM-DD HH:mm:ss" + disabledDate={(current) => current && current.isBefore(dayjs().startOf("day"))} + /> + + + ) } export default GeneralFormTab diff --git a/frontend/src/components/forms/projectFormTabs/GroupsFormTab.tsx b/frontend/src/components/forms/projectFormTabs/GroupsFormTab.tsx index 91efd873..a4d1881d 100644 --- a/frontend/src/components/forms/projectFormTabs/GroupsFormTab.tsx +++ b/frontend/src/components/forms/projectFormTabs/GroupsFormTab.tsx @@ -41,7 +41,6 @@ const GroupsFormTab: FC<{ form: FormInstance }> = ({ form }) => { allowClear courseId={courseId!} onClusterCreated={(clusterId) => { - console.log("Setting clusterId:", clusterId) form.setFieldValue("groupClusterId", clusterId) }} /> diff --git a/frontend/src/components/forms/projectFormTabs/StructureFormTab.tsx b/frontend/src/components/forms/projectFormTabs/StructureFormTab.tsx index 6cfe2af4..211aeed2 100644 --- a/frontend/src/components/forms/projectFormTabs/StructureFormTab.tsx +++ b/frontend/src/components/forms/projectFormTabs/StructureFormTab.tsx @@ -4,22 +4,36 @@ import SubmitStructure from "../../../pages/submit/components/SubmitStructure" import { useTranslation } from "react-i18next" import { FormInstance } from "antd/lib" import { useDebounceValue } from "usehooks-ts" +import MarkdownTooltip from "../../common/MarkdownTooltip" const StructureFormTab: FC<{ form: FormInstance }> = ({ form }) => { const { t } = useTranslation() const structure = Form.useWatch("structureTest", form) const [debouncedValue] = useDebounceValue(structure, 400) + return ( <> + } name="structureTest" - tooltip="TODO write docs for this" > { if (e.key === "Tab") { e.preventDefault() @@ -34,7 +48,7 @@ const StructureFormTab: FC<{ form: FormInstance }> = ({ form }) => { {t("project.change.fileStructurePreview")}: - + ) } diff --git a/frontend/src/components/input/MarkdownTextfield.tsx b/frontend/src/components/input/MarkdownTextfield.tsx index e88bc313..e69dca52 100644 --- a/frontend/src/components/input/MarkdownTextfield.tsx +++ b/frontend/src/components/input/MarkdownTextfield.tsx @@ -4,7 +4,7 @@ import { oneDark, oneLight } from "react-syntax-highlighter/dist/esm/styles/pris import useApp from "../../hooks/useApp" import { FC } from "react" -const MarkdownTextfield: FC<{ content: string }> = ({ content }) => { +const MarkdownTextfield: FC<{ content: string, inTooltip?: boolean}> = ({ content, inTooltip }) => { const app = useApp() const CodeBlock = { @@ -29,7 +29,12 @@ const MarkdownTextfield: FC<{ content: string }> = ({ content }) => { }, } - return {content} + let className = 'markdown-textfield' + if (inTooltip) { + className = 'markdown-textfield-intooltip' + } + + return {content} } export default MarkdownTextfield diff --git a/frontend/src/components/layout/breadcrumbs/ProjectBreadcrumbs.tsx b/frontend/src/components/layout/breadcrumbs/ProjectBreadcrumbs.tsx new file mode 100644 index 00000000..6961dea6 --- /dev/null +++ b/frontend/src/components/layout/breadcrumbs/ProjectBreadcrumbs.tsx @@ -0,0 +1,98 @@ +import { HomeFilled } from "@ant-design/icons" +import { Breadcrumb, BreadcrumbItemProps, BreadcrumbProps } from "antd" +import { FC, useContext, useMemo } from "react" +import { ProjectType } from "../../../pages/index/components/ProjectTableCourse" +import { Link, useLocation, useMatch } from "react-router-dom" +import useCourse from "../../../hooks/useCourse" +import { AppRoutes } from "../../../@types/routes" +import { useTranslation } from "react-i18next" +import { UserContext } from "../../../providers/UserProvider" + +const ProjectBreadcrumbs: FC<{ project: ProjectType | null }> = ({ project }) => { + const course = useCourse() + const { courses } = useContext(UserContext) + const { t } = useTranslation() + const matchProject = useMatch(AppRoutes.PROJECT) + const submitMatch = useMatch(AppRoutes.NEW_SUBMISSION) + const submissionMatch = useMatch(AppRoutes.SUBMISSION) + const editProjectMatch = useMatch(AppRoutes.EDIT_PROJECT) + + const location = useLocation() + const index = new URLSearchParams(location.search).get("index") + + + const items: BreadcrumbProps["items"] = useMemo(() => { + const menuItems: BreadcrumbItemProps["menu"] = { + items: + courses?.map((c) => ({ + key: c.courseId, + title: {c.name}, + })) ?? [], + } + + return [ + { + title: ( + + + + ), + }, + { + title: {course.name}, + menu: menuItems, + }, + ] + }, [courses, course]) + + let breadcrumbs = [...items] + if (breadcrumbs) { + if (matchProject && project) { + breadcrumbs.push({ + title: project.name, + }) + } else { + breadcrumbs.push({ + title: {project?.name || ""}, + }) + + if (submitMatch) { + breadcrumbs.push({ + title: t("project.breadcrumbs.submit"), + }) + } + + if (submissionMatch) { + breadcrumbs.push({ + title: t("project.breadcrumbs.submission") + (index ? ` #${index}` : ""), + }) + } + } + + if (editProjectMatch) { + breadcrumbs.push({ + title: t("project.breadcrumbs.editPage"), + }) + + return ( +
+
+ +
+
+ ) + } + } + + return ( + + ) +} + +export default ProjectBreadcrumbs diff --git a/frontend/src/components/layout/nav/AuthNav.tsx b/frontend/src/components/layout/nav/AuthNav.tsx index f44aba9e..68d307fb 100644 --- a/frontend/src/components/layout/nav/AuthNav.tsx +++ b/frontend/src/components/layout/nav/AuthNav.tsx @@ -1,5 +1,5 @@ -import { Dropdown, MenuProps, Typography } from "antd" +import {Breadcrumb, Dropdown, MenuProps, Typography } from "antd" import { useTranslation } from "react-i18next" import { UserOutlined, BgColorsOutlined, DownOutlined, LogoutOutlined, PlusOutlined } from "@ant-design/icons" diff --git a/frontend/src/components/other/GroupMembersTransfer.tsx b/frontend/src/components/other/GroupMembersTransfer.tsx index 3a81ea13..11f1ab26 100644 --- a/frontend/src/components/other/GroupMembersTransfer.tsx +++ b/frontend/src/components/other/GroupMembersTransfer.tsx @@ -64,7 +64,6 @@ const GroupMembersTransfer: FC<{ value?: GroupMembers,groups: GroupType[]; onCha const [selectedGroup, setSelectedGroup] = useState(null) const { t } = useTranslation() const API = useApi() - console.log(courseMembers, selectedGroup, groups, value); useEffect(()=> { if(courseMembers === null || !groups?.length) return @@ -132,10 +131,8 @@ const GroupMembersTransfer: FC<{ value?: GroupMembers,groups: GroupType[]; onCha for (let i = 0; i < groups.length; i++) { const group = groups[i] const groupMembers = members.splice(0, group.capacity) - // @ts-ignore //TODO: fix the types so i can remove the ts ignore randomGroups[group.name] = groupMembers.map((m) => m.user.userId) } - console.log(randomGroups); // setTargetKeys(randomGroups) if(onChange) onChange(randomGroups) } diff --git a/frontend/src/hooks/useApi.tsx b/frontend/src/hooks/useApi.tsx index da88570a..92602196 100644 --- a/frontend/src/hooks/useApi.tsx +++ b/frontend/src/hooks/useApi.tsx @@ -140,7 +140,6 @@ const useApi = ():UseApiType => { } else if (options.mode === "message") { message.error(errMessage) } else if (options.mode === "page") { - console.log("------"); setError({ status, message: errMessage, diff --git a/frontend/src/i18n/en/translation.json b/frontend/src/i18n/en/translation.json index 8e6a104e..292d63ff 100644 --- a/frontend/src/i18n/en/translation.json +++ b/frontend/src/i18n/en/translation.json @@ -33,7 +33,8 @@ "docs": "Documentation", "calendar": "Calendar", "academicYearRequired": "Academic year is required", - "noCourses": "You're not in any courses yet, you can join a course by opening a course join link.", + "noCoursesStudent": "You're not in any courses yet, you can join a course by opening a course join link", + "noCoursesTeacher": "You're not in any courses yet you can create a course by clicking the button above", "courseNameRequired": "Course name is required", "courseNameMaxLength": "Course name must be less than 50 characters", "courseNameMinLength": "Course name must be at least 3 characters", @@ -42,13 +43,14 @@ "projects": { "noProjects": "No projects", "name": "Name", - "description": "Description", + "description": "Info", "course": "Course", "deadline": "Deadline", "deadlineNotPassed": "Only show active projects", "showMore": "Show more", "submit": "Submit", "projectStatus": "Status", + "maxScore": "Maximum score", "visibility": "Visibility", "visibleStatus": { "visible": "Visible", @@ -64,8 +66,8 @@ }, "groupProgress": "Group progress", "completeProgress": "{{count}} / {{total}} completed", - "activeProjects": "{{count}} active project", - "activeProjects_plural": "{{count}} active projects", + "activeProjects": "{{count}} project", + "activeProjects_plural": "{{count}} projects", "userCourseCount": "{{count}} user is in this course", "userCourseCount_plural": "{{count}} users are in this course" }, @@ -84,8 +86,8 @@ "search": "Search", "emailError": "Please enter a valid email", "emailTooShort": "Email must be at least 3 characters long", - "nameError": "Name must be at least 3 characters long", - "surnameError": "Surname must be at least 3 characters long", + "nameError": "Name or surname must be at least 3 characters long", + "surnameError": "Name or surname must be at least 3 characters long", "searchTutorial": "Enter a name, surname, or email to find users.", "searchTooShort": "The search must be at least 3 characters long", "noUsersFound": "No users found", @@ -137,9 +139,12 @@ "groupMembers": "Group members", "newProject": "New project", "scoreTooHigh": "Score is higher than maximum score", + "scoreNegative": "Score cannot be negative", "successfullyDeleted": "Project deleted successfully", "deleteProject": "Delete project", "deleteProjectDescription": "Are you sure you want to delete this project? All submissions will be deleted, you cannot undo this action.", + "noStructure": "No specific file structure needed", + "saveFeedback": "Save feedback", "change": { "title": "Create project", "updateTitle": "Update {{name}}", @@ -150,7 +155,8 @@ "groupClusterId": "Groups", "groupClusterIdMessage": "Please enter the group cluster", "visible": "Make the project visible", - "visibleAfter": "Choose when the project will be made visible to students, leaving this empty will keep the project invisible", + "visibleAfter": "Make the project visible after", + "visibleAfterTooltip": "Choose when the project will be made visible to students, leaving this empty will keep the project invisible", "maxScore": "Maximum score", "maxScoreMessage": "Please enter the maximum score for the project", "maxScoreHelp": "What is the maximum achievable score for this project? Leaving it empty means the project won't be graded.", @@ -166,7 +172,7 @@ "groupClusterCreated": "Group cluster created", "fileStructure": "File structure", "fileStructurePreview": "File structure preview", - "update": "Update project", + "update": "Save", "newGroupCluster": "Add a new group cluster", "makeCluster": "Create group cluster", "clusterName": "Cluster name", @@ -200,15 +206,36 @@ "structureTemplateHeader": "Structure", "dockerImageHeader": "Docker image", "dockerScriptHeader": "Docker script", + "dockerTemplate": "Docker template", "modeHeader": "Template", "fileStructure": "File structure", - "fileStructurePreview": "File structure preview" + "fileStructurePreview": "File structure preview", + "simpleMode": "Without template", + "templateMode": "With template", + "fileStructureTooltip": "This templates specifies the file structure a submission has to follow.\nIt uses the following syntax:\n* Folders end on `'/'`\n* Use indents to specify files inside a folder\n* Regex can be used\n\t* `'.'` is still a normal `'.'`\n\t* `'\\.'` can be used as regex `'.'`\n* `'-'` at the start of a line specifies a file/folder that is not allowed", + "dockerImageTooltip": "Specify a valid Docker container from [Docker Hub](https://hub.docker.com/) on which the test script will be run. You can also choose a language with a preselected container.", + "dockerScriptTooltip": "Bash-script that is executed.\n* The files of the student's submission can be found in `'/shared/input'`\n* Extra files uploaded below can be found in `'/shared/extra'`\n\n More information about the required output depends on the mode and can be found below.", + "dockerTemplateTooltip": "To specify specific tests, you need to provide a template. First, enter the test name with '@{test}'. Below this, you can use '>' to provide options such as ('>required', '>optional', '>description'). Everything under these options until the next test or the end of the file is the expected output.", + "dockerTestDirTooltip": "Upload additional files needed for the Docker test. These files are available in the folder `'/shared/extra'`.\n\nOnce uploaded u can click the filename to download them again. Uploading a new file will replace the old one.", + "simpleModeInfo": "Without template, the student will see everything that the scripts prints/logs as feedback.\n\nIf the test is successful, `'Push allowed'` must be written to `'/shared/output/testOutput'`. If this does not happen, the test is considered failed.", + "templateModeInfo": "If you provide a template, the student will see a comparison between the expected output and the student's output for each test.\n\nThe template uses the following syntax:\n* `@testName`: the first line of a test starts with `'@'` followed by the name of the test\n* Optionally, a number of options can be provided:\n\t* `>[required|optional]`: indicates whether the test is mandatory or optional\n\t* `>description=\"...\"`: description of the test\n* The lines after the options are the expected output of the test. The last newline is not considered part of the output.\n\nThe output of the student should be written to `'/shared/output/{testName}'`.", + "dockerTemplateValidation": { + "inValidFirstLine": "The first line of a test must be '@' followed by the name of the test", + "inValidOptions": "Line {{line}}: Invalid option", + "emptyTemplate": "Template cannot be empty" + }, + "dockerScriptRequired": "Docker script is required if u specify a Docker image" }, "noScore": "No score available", "noFeedback": "No feedback provided", "noScoreLabel": "No score", "noFeedbackLabel": "No feedback", - "noSubmissionDownload": "No submission found" + "noSubmissionDownload": "No submission found", + "breadcrumbs": { + "editPage": "Edit project", + "submit": "Submit", + "submission": "Submission" + } }, "group": { "removeUserFromGroup": "Remove {{name}} from group" @@ -235,10 +262,15 @@ "failed": "failed", "expected": "Expected output:", "received": "Received output:", + "optional": "optional", + "structureTestSuccess": "Submission meets the structure requirements", + "structureTestFailed": "Submission does not meets the structure requirements", + "tests": "Automated tests", "status": { "accepted": "All tests succeeded.", "failed": "Some tests failed." - } + }, + "viewraw": "Escape special characters" }, "course": { @@ -263,6 +295,8 @@ "searchMember": "Search member", "inviteLink": "Invite link", "inviteLinkInfo": "With this link, students can join your course", + "copyLink": "Copy invite link", + "copyLinkSuccess": "Link copied to clipboard", "deleteCourse": "Delete course", "deleteCourseDescription": "Are you sure you want to delete this course? All projects and submissions will be deleted as well. This action cannot be undone.", "confirmDelete": "Confirm deletion", @@ -285,7 +319,11 @@ "invitePeople": "Invite people", "invitePeopleToCourse": "Invite people to this course", "allowedInviteText": "Only people with the invite link can join this course", - "regenerateKey": "Regenerate invite key" + "regenerateKey": "Regenerate invite key", + "addGroup": "Add new group", + "deleteGroup": "Delete group cluster", + "groupDeleteFailed": "Can't delete this cluster. Make sure it isn't being used in a project.", + "deleteConfirm": "Are you sure you want to delete this group cluster?" }, "cancel": "Cancel", "ok": "OK", @@ -300,6 +338,7 @@ "cannotJoinCourse": "You cannot join this course" }, "goBack": "Go back", + "delete": "Delete", "components": { "write": "Write", "preview": "Preview" diff --git a/frontend/src/i18n/nl/translation.json b/frontend/src/i18n/nl/translation.json index 81b4afa0..b5c963aa 100644 --- a/frontend/src/i18n/nl/translation.json +++ b/frontend/src/i18n/nl/translation.json @@ -10,7 +10,7 @@ }, "courses":{ "courses": "Vakken", - "searchCourse": "Zoek ak", + "searchCourse": "Zoek vak", "member": "Lid", "members": "Leden", "archived": "Gearchiveerd", @@ -33,7 +33,8 @@ "table": "Tabel", "docs": "Documentatie", "calendar": "Kalender", - "noCourses": "Je bent nog niet ingeschreven in een vak. Je kan je inschrijven door een vak join link te openen.", + "noCoursesStudent": "Je bent nog niet ingeschreven in een vak. Je kan je inschrijven door een vak join link te openen", + "noCoursesTeacher": "Je bent nog niet verbonden aan een vak. Je kan zelf een vak maken door op de knop hierboven te klikken", "courseNameMaxLength": "Vak naam mag maximaal 50 karakters bevatten", "courseNameRequired": "Vak naam is verplicht", "courseNameMinLength": "Vak naam moet minimaal 3 karakters bevatten", @@ -42,12 +43,13 @@ "projects": { "noProjects": "Geen projecten", "name": "Naam", - "description": "Beschrijving", + "description": "Info", "course": "Vak", "deadline": "Deadline", "deadlineNotPassed": "Toon enkel actieve projecten", "showMore": "Toon meer", "projectStatus": "Status", + "maxScore": "Maximum score", "visibility": "Zichtbaarheid", "visibleStatus": { "visible": "Zichtbaar", @@ -63,8 +65,8 @@ }, "groupProgress": "Voortgang groep", "completeProgress": "{{count}} / {{total}} voltooid", - "activeProjects": "{{count}} actief project", - "activeProjects_plural": "{{count}} actieve projecten", + "activeProjects": "{{count}} project", + "activeProjects_plural": "{{count}} projecten", "submit": "Indienen", "userCourseCount_plural": "{{count}} gebruikers in dit vak", "userCourseCount": "{{count}} gebruiker in dit vak" @@ -85,8 +87,8 @@ "search": "Zoeken", "emailError": "Vul een geldig email adres in", "emailTooShort": "Email moet minstens 3 karakters lang zijn", - "nameError": "Naam moet minstens 3 karakters lang zijn", - "surnameError": "Achternaam moet minstens 3 karakters lang zijn", + "nameError": "Naam of achternaam moet minstens 3 karakters lang zijn", + "surnameError": "Naam of achternaam moet minstens 3 karakters lang zijn", "searchTooShort": "Zoekopdracht moet minstens 3 karakters lang zijn", "searchTutorial": "Vul een email adres, naam of achternaam in om gebruikers op te zoeken.", "noUsersFound": "Geen gebruikers gevonden", @@ -139,9 +141,12 @@ "newProject": "Nieuw project", "fileTooLarge": "Bestand is te groot (max 50 MB)", "scoreTooHigh": "Score is hoger dan maximum score", + "scoreNegative": "Score mag niet lager dan 0 zijn", "successfullyDeleted": "Project succesvol verwijderd", "deleteProject": "Project verwijderen", + "saveFeedback": "Feedback opslaan", "deleteProjectDescription": "Bent u zeker dat u dit project wilt verwijderen? Alle indieningen zullen ook verwijderd worden. Deze actie kan niet ongedaan gemaakt worden.", + "noStructure": "Er is specifiek geen bestanden structuur nodig", "change": { "title": "Maak project aan", "name": "Naam", @@ -152,7 +157,8 @@ "groupClusterId": "Groepen", "groupClusterIdMessage": "Vul de Groep cluster in", "visible": "Project zichtbaar maken", - "visibleAfter": "Kies wanneer het project automatisch zichtbaar wordt voor studenten. Als je niets invult, blijft het project onzichtbaar", + "visibleAfter": "Zichtbaar maken na", + "visibleAfterTooltip": "Kies wanneer het project automatisch zichtbaar wordt voor studenten. Als je niets invult, blijft het project onzichtbaar", "maxScore": "Maximum score", "maxScoreMessage": "Vul de maximum score van het project in", "maxScoreHelp": "Wat is de maximale score die je kunt behalen voor dit project? Als je het leeg laat, wordt het project niet beoordeeld", @@ -168,7 +174,7 @@ "groupClusterCreated": "Groep cluster aangemaakt", "fileStructure": "Bestandsstructuur", "fileStructurePreview": "Voorbeeld van bestandsstructuur", - "update": "Project aanpassen", + "update": "Bewaren", "newGroupCluster": "Voeg een nieuwe groep cluster toe", "makeCluster": "Groep cluster aanmaken", "clusterName": "Cluster naam", @@ -202,15 +208,36 @@ "structureTemplateHeader": "Structuur", "dockerImageHeader": "Docker image", "dockerScriptHeader": "Docker script", + "dockerTemplate": "Docker sjabloon", "modeHeader": "Sjabloon", "fileStructure": "Bestandsstructuur", - "fileStructurePreview": "Voorbeeld van bestandsstructuur" + "simpleMode": "Zonder sjabloon", + "templateMode": "Met sjabloon", + "fileStructurePreview": "Voorbeeld van bestandsstructuur", + "fileStructureTooltip": "Dit sjabloon specificeert de bestandsstructuur die een indiening moet volgen.\nHet gebruikt de volgende syntax:\n* Mappen eindigen op `'/'`\n* Gebruik inspringing om bestanden binnen een map aan te geven\n* Regex kan worden gebruikt\n\t* `'.'` is nog steeds een normale `'.'`\n\t* `'\\.'` kan worden gebruikt als regex `'.'`\n* `'-'` aan het begin van een regel geeft aan dat een bestand/map niet is toegestaan", + "dockerImageTooltip": "Specificeer een geldige Docker-container van [Docker Hub](https://hub.docker.com/) waarop het testscript zal worden uitgevoerd. Je kan ook kiezen voor een voorgeconfigureerde programmeertaal met bijhorende container.", + "dockerScriptTooltip": "Bash-script dat wordt uitgevoerd.\n* De bestanden van de student zijn indieningen zijn te vinden in `'/shared/input'`\n* Extra bestanden die hieronder zijn geüpload, zijn te vinden in `'/shared/extra'`\n\nMeer informatie over de vereiste uitvoer is afhankelijk van de modus en is hieronder te vinden.", + "dockerTemplateTooltip": "Om specifieke tests te definiëren, moet je een sjabloon invoeren. Geef eerst de naam van de test in met '@{test}'. Hieronder kun je met een '>' opties geven zoals ('>required', '>optional', '>description'). Alles onder de opties tot de volgende test of het einde van het bestand is de verwachte output.", + "dockerTestDirTooltip": "Upload extra bestanden die nodig zijn voor de dockertest. Deze bestanden zijn beschikbaar in de map `'/shared/extra'`.\n\nAls je een file geüpload hebt kan je deze downloaden door op de bestandsnaam te klikken. Als je een nieuw bestand uploadt zal het oude bestand overschreven worden.", + "simpleModeInfo": "Zonder sjabloon zal de student alles zien wat de scripts print/logt als feedback.", + "templateModeInfo": "Als je een sjabloon meegeeft krijgt de student per test een vergelijking te zien tussen de verwachte output en de output van de student.\n\nHet sjabloon gebruikt volgende syntax:\n* `@testNaam`: eerste lijn van een test begint met `'@'` en de naam van de test\n* Optioneel kunnen een aantal opties meegegeven worden:\n\t* `>[required|optional]`: geeft aan of de test verplicht of optioneel is\n\t* `>description=\"...\"`: beschrijving van de test\n* De regels na de opties zijn de verwachte output van de test. De laatste newline wordt niet gezien als deel van de output.\n\nDe output van de student moet geschreven worden naar `'/shared/output/{testName}'`.", + "dockerTemplateValidation": { + "inValidFirstLine": "De eerste lijn van een regel moet beginnen met '@' gevolgd door de naam van de test", + "inValidOptions": "Lijn {{line}}: Ongeldige optie", + "emptyTemplate": "Template kan niet leeg zijn" + }, + "dockerScriptRequired": "Docker script is verplicht als je een Docker image hebt ingevuld" }, "noScore": "Nog geen score beschikbaar", "noFeedback": "Geen feedback gegeven", "noScoreLabel": "Geen score", "noFeedbackLabel": "Geen feedback", - "noSubmissionDownload": "Geen indiening gevonden" + "noSubmissionDownload": "Geen indiening gevonden", + "breadcrumbs": { + "editPage": "Project aanpassen", + "submit": "Indienen", + "submission": "Indiening" + } }, "group": { "removeUserFromGroup": "Verwijder {{name}} uit deze groep" @@ -223,7 +250,7 @@ "getStarted": "Aan de slag", "docs": "Documentatie" }, - + "submission": { "submission": "Indiening", "submittedFiles": "Ingediende bestanden:", @@ -237,10 +264,17 @@ "failed": "Niet geslaagd", "expected": "Vewachte output:", "received": "Ontvangen output:", + "optional": "optioneel", + "structureTestSuccess": "Indiening voldoet aan de structuur", + "structureTestFailed": "Indiening voldoet niet aan de structuur", + "submissionSuccess": "Indiening geslaagd", + "submissionFailed": "Indiening niet geslaagd", + "tests": "Automatische testen", "status": { "accepted": "Alle testen zijn geslaagd.", "failed": "Sommige testen zijn niet geslaagd." - } + }, + "viewraw": "Speciale karakters escapen" }, "course": { @@ -265,6 +299,8 @@ "searchMember": "Zoek lid", "inviteLink": "Uitnodigingslink", "inviteLinkInfo": "Met deze link kunnen studenten zich inschrijven voor dit vak.", + "copyLink": "Kopieer uitnodiginslink", + "copyLinkSuccess": "Link gekopieerd", "deleteCourse": "Vak verwijderen", "deleteCourseDescription": "Bent u zeker dat u dit vak wilt verwijderen? Alle projecten en indieningen zullen ook verwijderd worden. Deze actie kan niet ongedaan gemaakt worden.", "confirmDelete": "Bevestig verwijdering", @@ -288,7 +324,11 @@ "invitePeople": "Mensen uitnodigen", "invitePeopleToCourse": "Mensen uitnodigen voor dit vak", "allowedInviteText": "Enkel mensen met een uitnodiging kunnen inschrijven", - "regenerateKey": "Nieuwe uitnodigings sleutel genereren" + "regenerateKey": "Nieuwe uitnodigings sleutel genereren", + "addGroup": "Voeg nieuwe groep toe", + "deleteGroup": "Groep cluster verwijderen", + "groupDeleteFailed": "Deze cluster kan je niet verwijderen. Zorg ervoor dat deze niet meer gebruikt wordt in een project.", + "deleteConfirm": "Bent u zeker dat u deze groep cluster wilt verwijderen?" }, "cancel": "Annuleren", "ok": "Ok", @@ -303,6 +343,7 @@ "cannotJoinCourse": "Je kan je niet inschrijven voor dit vak" }, "goBack": "Keer terug", + "delete": "Verwijder", "components": { "write": "Aanpassen", "preview": "Voorbeeld" diff --git a/frontend/src/pages/course/components/gradesTab/GradesList.tsx b/frontend/src/pages/course/components/gradesTab/GradesList.tsx index 93b1a95e..c691d96d 100644 --- a/frontend/src/pages/course/components/gradesTab/GradesList.tsx +++ b/frontend/src/pages/course/components/gradesTab/GradesList.tsx @@ -47,6 +47,7 @@ const GradesList: FC<{ feedback: CourseGradesType[]; courseId: number }> = ({ fe } description={score.groupFeedback!.feedback} + style={{whiteSpace: "pre-wrap"}} /> )} diff --git a/frontend/src/pages/course/components/groupTab/GroupList.tsx b/frontend/src/pages/course/components/groupTab/GroupList.tsx index 0c26399e..e29c093a 100644 --- a/frontend/src/pages/course/components/groupTab/GroupList.tsx +++ b/frontend/src/pages/course/components/groupTab/GroupList.tsx @@ -1,4 +1,4 @@ -import { Button, List, Typography } from "antd" +import { Button, Input, List, Typography } from "antd" import { FC, useEffect, useMemo, useState } from "react" import { ApiRoutes, GET_Responses } from "../../../../@types/requests.d" import useUser from "../../../../hooks/useUser" @@ -10,6 +10,8 @@ import { useParams } from "react-router-dom" import useApi from "../../../../hooks/useApi" import useIsCourseAdmin from "../../../../hooks/useIsCourseAdmin" import { ClusterType } from "./GroupsCard" +import { PlusOutlined } from "@ant-design/icons" +import CourseAdminView from "../../../../hooks/CourseAdminView" export type GroupType = GET_Responses[ApiRoutes.GROUP] @@ -36,7 +38,7 @@ const Group: FC<{ group: GroupType; canJoin: boolean; canLeave: boolean; onClick ) : ( + } + = ({ co const { t } = useTranslation() const API = useApi() useEffect(() => { - // TODO: do the fetch (get all clusters from the course ) - fetchGroups().catch(console.error) - + fetchGroups().catch(console.error) }, [courseId]) const fetchGroups = async () => { if (!courseId) return // if course is null that means it hasn't been fetched yet by the parent component - const res = await API.GET(ApiRoutes.COURSE_CLUSTERS, { pathValues: { id: courseId } }) - if(!res.success) return - setGroups(res.response.data) - } + const res = await API.GET(ApiRoutes.COURSE_CLUSTERS, { pathValues: { id: courseId } }) + if (!res.success) return + let groups = res.response.data + // Sort based on name + groups = groups.sort((a, b) => a.name.localeCompare(b.name)) + setGroups(groups) + } + + const deleteGroupCluster = async (clusterId: number) => { + if (!groups) return - // if(!groups) return
- // - //
+ const res = await API.DELETE( + ApiRoutes.CLUSTER, + { pathValues: { id: clusterId } }, + { + errorMessage: t("course.groupDeleteFailed"), + mode: "message", + } + ) + if (!res.success) return + setGroups(groups.filter((c) => c.clusterId !== clusterId)) + } - const items: CollapseProps["items"] = useMemo(()=> groups?.map((cluster) => ({ - key: cluster.clusterId.toString(), - label: cluster.name, - children: ( - - ), - })), [groups]) + const items: CollapseProps["items"] = useMemo( + () => + groups?.map((cluster) => ({ + key: cluster.clusterId.toString(), + label: ( +
{cluster.name}
+ ), + children: ( + + ), + extra: ( + + {e?.stopPropagation();deleteGroupCluster(cluster.clusterId)}} + onCancel={(e) => {e?.stopPropagation()}} + description={t("course.deleteConfirm")} + okButtonProps={{ + danger: true, + }} + okText={t("delete")} + > + + diff --git a/frontend/src/pages/editRole/EditRole.tsx b/frontend/src/pages/editRole/EditRole.tsx index 3e215c5f..ce669205 100644 --- a/frontend/src/pages/editRole/EditRole.tsx +++ b/frontend/src/pages/editRole/EditRole.tsx @@ -1,34 +1,37 @@ -import { useEffect, useState, useRef } from "react" -import { Row, Col, Form, Input, Button, Spin, Select, Typography } from "antd" +import { useContext, useEffect, useState } from "react" +import { Form, Input, Spin, Select, Typography, Space } from "antd" import UserList from "./components/UserList" import { ApiRoutes, GET_Responses, UserRole } from "../../@types/requests.d" import apiCall from "../../util/apiFetch" import { useTranslation } from "react-i18next" import { UsersListItem } from "./components/UserList" import { useDebounceValue } from "usehooks-ts" -import { User } from "../../providers/UserProvider" +import { UserContext } from "../../providers/UserProvider" +import useUser from "../../hooks/useUser" export type UsersType = GET_Responses[ApiRoutes.USERS] -type SearchType = "name" | "surname" | "email" +type SearchType = "name" | "email" const ProfileContent = () => { const [users, setUsers] = useState(null) - + const myself = useUser() const [loading, setLoading] = useState(false) const [form] = Form.useForm() - const searchValue = Form.useWatch("search", form) + const firstSearchValue = Form.useWatch("first", form) + const secondSearchValue = Form.useWatch("second", form) + const searchValue = `${firstSearchValue || ''} ${secondSearchValue || ''}`.trim(); const [debouncedSearchValue] = useDebounceValue(searchValue, 250) const [searchType, setSearchType] = useState("name") const { t } = useTranslation() + const emailRegex = /^[\w-]+(\.[\w-]+)*@([\w-]+\.)+[a-zA-Z]{2,7}$/; + useEffect(() => { onSearch() }, [debouncedSearchValue]) - function updateRole(user: UsersListItem, role: UserRole) { - console.log(user, role) + const updateRole = (user: UsersListItem, role: UserRole) => { apiCall.patch(ApiRoutes.USER, { role: role }, { id: user.id }).then((res) => { - console.log(res.data) //onSearch(); //replace this user in the userlist with the updated one from res.data const updatedUsers = users?.map((u) => { @@ -38,19 +41,62 @@ const ProfileContent = () => { return u; }); setUsers(updatedUsers?updatedUsers:null); + if(user.id === myself.user?.id){ + myself.updateUser() + } }) } + const [isError, setIsError] = useState(false) + + const checkValidate = () => { + if (searchType === "email") { + if (!emailRegex.test(form.getFieldValue("first"))) { + return false + } else { + return true + } + } else { + const firstValue = form.getFieldValue("first") + const secondValue = form.getFieldValue("second") + const firstValueLength = firstValue ? firstValue.length : 0 + const secondValueLength = secondValue ? secondValue.length : 0 + if (firstValueLength < 3 && secondValueLength < 3) { + console.log("error") + return false + } else { + console.log("no error") + return true + } + } + } + + const validate = () => { + if (!checkValidate()) { + setIsError(true) + } else { + setIsError(false) + } + } + const onSearch = async () => { - const value = form.getFieldValue("search") - if (!value || value.length < 3) return + //validation + if (!checkValidate()) { + return + } + + const firstValue = form.getFieldValue("first") setLoading(true) const params = new URLSearchParams() - params.append(searchType, form.getFieldValue("search")) - console.log(ApiRoutes.USERS + "?" + params.toString()) + if (searchType === "email") { + params.append(searchType, form.getFieldValue("first")) + } else { + const secondValue = form.getFieldValue("second") + if (firstValue) params.append("name", firstValue) + if (secondValue) params.append("surname", secondValue) + } + console.log(params) apiCall.get((ApiRoutes.USERS + "?" + params.toString()) as ApiRoutes.USERS).then((res) => { - //FIXME: It's possible that request doesn't come in the same order as they're sent in. So it's possible that it would show the request of an old query - console.log(res.data) setUsers(res.data) setLoading(false) }) @@ -62,57 +108,68 @@ const ProfileContent = () => { form={form} name="search" onFinish={onSearch} + onChange={validate} + validateTrigger={[]} > - + + + setSearchType(value)} - style={{ width: 120 }} - options={[ - { label: t("editRole.email"), value: "email" }, - { label: t("editRole.name"), value: "name" }, - { label: t("editRole.surname"), value: "surname" }, - ]} - /> - } - /> + + {searchType === "name" && ( + + + + )} + + + {isError && {searchType === "email" ? t("editRole.emailError") : t("editRole.nameError")}} +
+ {users !== null ? ( <> {loading ? (
- +
) : ( (null) const [selectedRole, setSelectedRole] = useState(null) + const { user } = useUser() - const handleMenuClick = (user: UsersListItem, role: UserRole) => { - setSelectedUser(user) + const handleMenuClick = (listuser: UsersListItem, role: UserRole) => { + setSelectedUser(listuser) setSelectedRole(role) setVisible(true) } @@ -44,12 +46,13 @@ const UserList: React.FC<{ users: UsersType; updateRole: (user: UsersListItem, r return a.email.localeCompare(b.email); }); - const renderUserItem = (user: UsersListItem) => ( + const renderUserItem = (listuser: UsersListItem) => ( - + handleMenuClick(user, e.key as UserRole), + selectedKeys: [listuser.role], + onClick: (e) => handleMenuClick(listuser, e.key as UserRole), }} >
e.preventDefault()}> - {t("editRole." + user.role)} + {t("editRole." + listuser.role)} diff --git a/frontend/src/pages/index/Home.tsx b/frontend/src/pages/index/Home.tsx index 986922c0..f0c53eca 100644 --- a/frontend/src/pages/index/Home.tsx +++ b/frontend/src/pages/index/Home.tsx @@ -29,6 +29,41 @@ const Home = () => { API.GET(ApiRoutes.PROJECTS, {}).then((res) => { if(!res.success || ignore) return const projects: ProjectsType = [...res.response.data.adminProjects, ...res.response.data.enrolledProjects.map((p) => ({ ...p.project, status: p.status }))] + + projects.sort((a, b) => { + const today = new Date(); + const date1 = new Date(a.deadline); + const date2 = new Date(b.deadline); + + // Calculate the difference in time from today for each date + const diff1 = date1.getTime() - today.getTime(); + const diff2 = date2.getTime() - today.getTime(); + + // If both dates are in the future or both in the past, compare their absolute differences + if ((diff1 >= 0 && diff2 >= 0) || (diff1 < 0 && diff2 < 0)) { + const absDiff1 = Math.abs(diff1); + const absDiff2 = Math.abs(diff2); + + if (absDiff1 < absDiff2) { + return -1; + } else if (absDiff1 > absDiff2) { + return 1; + } else { + return 0; + } + } + + // If one date is in the future and the other is in the past, the future date has higher priority + if (diff1 >= 0 && diff2 < 0) { + return -1; + } else if (diff1 < 0 && diff2 >= 0) { + return 1; + } + + // This should not be reached because all cases are covered + return 0; + }) + setProjects(projects) }) diff --git a/frontend/src/pages/index/components/CourseCard.tsx b/frontend/src/pages/index/components/CourseCard.tsx index 3c509b16..23dfa9bd 100644 --- a/frontend/src/pages/index/components/CourseCard.tsx +++ b/frontend/src/pages/index/components/CourseCard.tsx @@ -7,6 +7,7 @@ import { useNavigate } from "react-router-dom" import { AppRoutes } from "../../../@types/routes" import GroupProgress from "./GroupProgress" import { CourseProjectsType } from "./CourseSection" +import { Link } from "react-router-dom" const CourseCard: FC<{ courseProjects: CourseProjectsType[string], adminView?:boolean }> = ({ courseProjects,adminView }) => { const { t } = useTranslation() @@ -32,7 +33,7 @@ const CourseCard: FC<{ courseProjects: CourseProjectsType[string], adminView?:bo onClick={() => navigate(AppRoutes.COURSE.replace(":courseId", courseProjects.course.courseId.toString()))} type="inner" title={courseProjects.course.name} - style={{ width: 300,height:"100%" }} + style={{ width: 300, height:"100%" }} actions={[ 1? "home.projects.userCourseCount_plural": "home.projects.userCourseCount", { count: courseProjects.course.memberCount })}> @@ -60,7 +61,13 @@ const CourseCard: FC<{ courseProjects: CourseProjectsType[string], adminView?:bo locale={{ emptyText: t("home.projects.noProjects") }} rowKey="projectId" renderItem={(project) => ( + event.stopPropagation()} + > , ]} > - {project.name}
} /> + event.stopPropagation()}> + {project.name} + }/> + )} - > + > + ) } diff --git a/frontend/src/pages/index/components/CourseSection.tsx b/frontend/src/pages/index/components/CourseSection.tsx index f503b892..b784e813 100644 --- a/frontend/src/pages/index/components/CourseSection.tsx +++ b/frontend/src/pages/index/components/CourseSection.tsx @@ -19,6 +19,7 @@ export type CourseProjectList = CourseProjectsType[string][] | null const CourseSection: FC<{ projects: ProjectsType | null; onOpenNew: () => void }> = ({ projects, onOpenNew }) => { const { courses } = useUser() + const user = useUser().user const [courseProjects, setCourseProjects] = useState(null) const [adminCourseProjects, setAdminCourseProjects] = useState(null) const [archivedCourses, setArchivedCourses] = useState(false) @@ -67,8 +68,6 @@ const CourseSection: FC<{ projects: ProjectsType | null; onOpenNew: () => void } return () => (ignore = true) }, [courses, projects]) - console.log(courseProjects); - const [filteredCourseProjects, filteredAdminCourseProjects, courseProjectsList, adminCourseProjectsList, yearOptions]: [CourseProjectList, CourseProjectList, CourseProjectList, CourseProjectList, number[] | null] = useMemo(() => { // Filter courses based on selected year if (courseProjects === null || adminCourseProjects === null) return [null, null, [], [], null] @@ -84,68 +83,82 @@ const CourseSection: FC<{ projects: ProjectsType | null; onOpenNew: () => void } }, [courseProjects, adminCourseProjects, selectedYear]) const YearDropdown = () => ( - <> - {yearOptions && yearOptions.length > 1 && ( -
- -
- )} - + <> + {yearOptions && yearOptions.length > 1 && ( +
+ +
+ )} + ) - const showYourCourses = !!filteredCourseProjects?.length || !filteredAdminCourseProjects?.length + + const showYourCourses = user?.role === "student" || (filteredCourseProjects?.length === undefined || filteredCourseProjects?.length > 0) + const showMyCourses = user?.role !== "student" || (filteredAdminCourseProjects?.length === undefined || filteredAdminCourseProjects?.length > 0) return ( - <> - {/* Dropdown for selecting year */} - - {!!showYourCourses && 2} - showPlus={!filteredAdminCourseProjects?.length} - extra={YearDropdown} - allOptions={showYourCourses} - type="enrolled" - />} - - - { !!filteredAdminCourseProjects?.length && 2} - extra={YearDropdown} - showPlus={!!filteredAdminCourseProjects?.length} - allOptions={!!filteredAdminCourseProjects?.length && !filteredCourseProjects?.length} - type="admin" - />} - - - - {filteredCourseProjects !== null && courseProjectsList.length === 0 && adminCourseProjectsList.length === 0 && ( - - {t("home.noCourses")} - - )} - + <> + {/* Dropdown for selecting year */} + + {showYourCourses && ( + 2} + showPlus={false} + extra={YearDropdown} + allOptions={showYourCourses} + type="enrolled" + /> + )} + + {/* No courses messages */} + {showYourCourses && (filteredCourseProjects?.length === undefined || filteredCourseProjects?.length === 0) && ( + + {t("home.noCoursesStudent")} + + )} + + {showMyCourses && 2} + extra={YearDropdown} + showPlus={true} + allOptions={!!filteredAdminCourseProjects?.length && !filteredCourseProjects?.length} + type="admin" + />} + + + {showMyCourses && ( (filteredAdminCourseProjects?.length === undefined || filteredAdminCourseProjects?.length === 0) ) && + user?.role !== "student" && ( + + {t("home.noCoursesTeacher")} + + )} + ) } -export default CourseSection +export default CourseSection \ No newline at end of file diff --git a/frontend/src/pages/index/components/CreateCourseModal.tsx b/frontend/src/pages/index/components/CreateCourseModal.tsx index f11aec97..d60d78cf 100644 --- a/frontend/src/pages/index/components/CreateCourseModal.tsx +++ b/frontend/src/pages/index/components/CreateCourseModal.tsx @@ -28,7 +28,6 @@ const createCourseModal = () => { setError(null) const values: { name: string; description: string } = form.getFieldsValue() - console.log(values) values.description ??= "" const res = await API.POST(ApiRoutes.COURSES, { body: values }, "message") if (!res.success) return reject() diff --git a/frontend/src/pages/index/components/HorizontalCourseScroll.tsx b/frontend/src/pages/index/components/HorizontalCourseScroll.tsx index b4f139ca..c0c22fe2 100644 --- a/frontend/src/pages/index/components/HorizontalCourseScroll.tsx +++ b/frontend/src/pages/index/components/HorizontalCourseScroll.tsx @@ -30,7 +30,7 @@ const HorizontalCourseScroll: FC<{ title: string; projects: CourseProjectList | + + + + + + - - - - - - + + + ) + } + > + - - - ) : ( - deadline ? t("project.deadlinePassed") : ""}> - - - - - ) - } - > - - - - ) -} -export default Project + + + )} + export default Project; diff --git a/frontend/src/pages/project/components/GroupTab.tsx b/frontend/src/pages/project/components/GroupTab.tsx index 37aa6ae2..eb88d5b7 100644 --- a/frontend/src/pages/project/components/GroupTab.tsx +++ b/frontend/src/pages/project/components/GroupTab.tsx @@ -23,7 +23,6 @@ const GroupTab: FC<{}> = () => { if (!projectId) return console.error("No projectId found") const res = await API.GET(ApiRoutes.PROJECT_GROUPS, { pathValues: { id: projectId } }) if (!res.success) return - console.log(res.response.data) setGroups(res.response.data) } @@ -41,6 +40,7 @@ const GroupTab: FC<{}> = () => { project={project} onGroupIdChange={handleGroupIdChange} locked={new Date().toLocaleDateString()} + clusterId={project?.clusterId??null} /> ) } diff --git a/frontend/src/pages/project/components/ScoreTab.tsx b/frontend/src/pages/project/components/ScoreTab.tsx index 593f4b9c..7a51eea0 100644 --- a/frontend/src/pages/project/components/ScoreTab.tsx +++ b/frontend/src/pages/project/components/ScoreTab.tsx @@ -61,7 +61,7 @@ const ScoreCard = () => { ), ]} > - {score.feedback?.length ? {score.feedback} : ({t("project.noFeedback")})} + {score.feedback?.length ? {score.feedback} : ({t("project.noFeedback")})} ) } diff --git a/frontend/src/pages/project/components/SubmissionList.tsx b/frontend/src/pages/project/components/SubmissionList.tsx index 836aa9ea..c601545c 100644 --- a/frontend/src/pages/project/components/SubmissionList.tsx +++ b/frontend/src/pages/project/components/SubmissionList.tsx @@ -1,5 +1,5 @@ import { Button, List, Table, TableProps, Typography } from "antd" -import { FC } from "react" +import { FC, useEffect } from "react" import { Link, useParams } from "react-router-dom" import SubmissionStatusTag, { createStatusBitVector } from "./SubmissionStatusTag" import { GroupSubmissionType } from "./SubmissionTab" @@ -9,21 +9,20 @@ import { AppRoutes } from "../../../@types/routes" -const SubmissionList: FC<{ submissions: GroupSubmissionType[] | null }> = ({ submissions }) => { +const SubmissionList: FC<{ submissions: GroupSubmissionType[] | null, indices: Map }> = ({ submissions, indices }) => { const {t} = useTranslation() const {courseId} = useParams() - const columns: TableProps['columns'] = [ { title: t("project.submission"), key: "submissionId", - sorter: (a: GroupSubmissionType, b: GroupSubmissionType) => { - return a.submissionId - b.submissionId - }, + //sorter: (a: GroupSubmissionType, b: GroupSubmissionType) => { + // return a.submissionId - b.submissionId + //}, render: (submission: GroupSubmissionType, _, index:number) => ( - - + + ), }, @@ -31,7 +30,7 @@ const SubmissionList: FC<{ submissions: GroupSubmissionType[] | null }> = ({ sub title: t("project.submissionTime"), dataIndex: "submissionTime", key: "submissionTime", - + sorter: (a: GroupSubmissionType, b: GroupSubmissionType) => new Date(a.submissionTime).getTime() - new Date(b.submissionTime).getTime(), render: (submission: GroupSubmissionType["submissionTime"]) => ( {new Date(submission).toLocaleString()} ), diff --git a/frontend/src/pages/project/components/SubmissionStatusTag.tsx b/frontend/src/pages/project/components/SubmissionStatusTag.tsx index 02ae8fff..a6f24fa7 100644 --- a/frontend/src/pages/project/components/SubmissionStatusTag.tsx +++ b/frontend/src/pages/project/components/SubmissionStatusTag.tsx @@ -14,7 +14,6 @@ export enum SubmissionStatus { } export function createStatusBitVector(submission: GET_Responses[ApiRoutes.SUBMISSION] | null) { - console.log(submission); if(submission === null) return SubmissionStatus.NOT_SUBMITTED let status = 0 if(submission.dockerStatus === "running") { diff --git a/frontend/src/pages/project/components/SubmissionTab.tsx b/frontend/src/pages/project/components/SubmissionTab.tsx index 1c8df176..5bd3e238 100644 --- a/frontend/src/pages/project/components/SubmissionTab.tsx +++ b/frontend/src/pages/project/components/SubmissionTab.tsx @@ -8,6 +8,7 @@ export type GroupSubmissionType = GET_Responses[ApiRoutes.PROJECT_GROUP_SUBMISSI const SubmissionTab: FC<{ projectId: number; courseId: number; testSubmissions?: boolean }> = ({ projectId, courseId, testSubmissions }) => { const [submissions, setSubmissions] = useState(null) + const [indices, setIndices] = useState>(new Map()) const project = useProject() const API = useApi() @@ -15,13 +16,18 @@ const SubmissionTab: FC<{ projectId: number; courseId: number; testSubmissions?: if (!project) return if (!project.submissionUrl) return setSubmissions([]) if (!project.groupId && !testSubmissions) return console.error("No groupId found") - console.log(project) let ignore = false - console.log("Sending request to: ", project.submissionUrl) API.GET(testSubmissions ? ApiRoutes.PROJECT_TEST_SUBMISSIONS : ApiRoutes.PROJECT_GROUP_SUBMISSIONS, { pathValues: { projectId: project.projectId, groupId: project.groupId ?? "" } }).then((res) => { - console.log(res) if (!res.success || ignore) return - setSubmissions(res.response.data.sort((a, b) => b.submissionId - a.submissionId)) + //this is sorts the submissions by submission time, with the oldest submission first + const ascending = res.response.data.sort((a, b) => new Date(a.submissionTime).getTime() - new Date(b.submissionTime).getTime()) + const tmp = new Map() + ascending.forEach((submission, index) => { + tmp.set(submission.submissionId, index+1) + }) + setIndices(tmp) + //we need descending order, so we reverse the array + setSubmissions(ascending.reverse()) }) return () => { @@ -33,7 +39,7 @@ const SubmissionTab: FC<{ projectId: number; courseId: number; testSubmissions?: <> - + ) } diff --git a/frontend/src/pages/project/components/SubmissionsTab.tsx b/frontend/src/pages/project/components/SubmissionsTab.tsx index d2729b3d..6a9613fa 100644 --- a/frontend/src/pages/project/components/SubmissionsTab.tsx +++ b/frontend/src/pages/project/components/SubmissionsTab.tsx @@ -4,7 +4,7 @@ import SubmissionsTable from "./SubmissionsTable" import { useParams } from "react-router-dom" import useApi from "../../../hooks/useApi" import { exportSubmissionStatusToCSV, exportToUfora } from "./createCsv" -import { Button, Space, Switch } from "antd" +import { Button, Space, Switch, theme } from "antd" import { DownloadOutlined, ExportOutlined } from "@ant-design/icons" import { useTranslation } from "react-i18next" import useProject from "../../../hooks/useProject" @@ -19,13 +19,13 @@ const SubmissionsTab = () => { const { t } = useTranslation() const project = useProject() const [withArtifacts, setWithArtifacts] = useState(true) + const {token} = theme.useToken() useEffect(() => { if (!projectId) return let ignore = false API.GET(ApiRoutes.PROJECT_SUBMISSIONS, { pathValues: { id: projectId } }).then((res) => { if (!res.success || ignore) return - console.log(res.response.data) setSubmissions(res.response.data) }) return () => { @@ -48,7 +48,6 @@ const SubmissionsTab = () => { "message" ) if (!response.success) return - console.log(response) const url = window.URL.createObjectURL(new Blob([response.response.data])) const link = document.createElement("a") link.href = url @@ -61,12 +60,12 @@ const SubmissionsTab = () => { const handleExportToUfora = () => { if (!submissions || !project) return - exportToUfora(submissions, project.maxScore ?? 0) + exportToUfora(submissions, project.maxScore ?? 0, `${project.name}-ufora-submissions.csv`) } const exportStatus = () => { - if (!submissions) return - exportSubmissionStatusToCSV(submissions) + if (!submissions || !project) return + exportSubmissionStatusToCSV(submissions, `${project.name}-submissions.csv`) } return ( diff --git a/frontend/src/pages/project/components/SubmissionsTable.tsx b/frontend/src/pages/project/components/SubmissionsTable.tsx index 5bca0673..ea6a8e8f 100644 --- a/frontend/src/pages/project/components/SubmissionsTable.tsx +++ b/frontend/src/pages/project/components/SubmissionsTable.tsx @@ -1,83 +1,104 @@ -import { Button, Input, List, Table, Tooltip, Typography } from "antd" -import { FC, useMemo } from "react" -import { ProjectSubmissionsType } from "./SubmissionsTab" -import { TableProps } from "antd/lib" -import { useTranslation } from "react-i18next" -import { DownloadOutlined } from "@ant-design/icons" -import useProject from "../../../hooks/useProject" -import SubmissionStatusTag, { createStatusBitVector } from "./SubmissionStatusTag" -import { Link, useParams } from "react-router-dom" -import { AppRoutes } from "../../../@types/routes" -import { ApiRoutes, PUT_Requests } from "../../../@types/requests.d" -import useAppApi from "../../../hooks/useAppApi" -import useApi from "../../../hooks/useApi" - -const GroupMember = ({ name }: ProjectSubmissionsType["group"]["members"][number]) => { - return {name} -} - -const SubmissionsTable: FC<{ submissions: ProjectSubmissionsType[] | null; onChange: (s: ProjectSubmissionsType[]) => void, withArtifacts?:boolean }> = ({ submissions, onChange,withArtifacts }) => { - const { t } = useTranslation() - const project = useProject() - const { courseId, projectId } = useParams() - const { message } = useAppApi() - const API = useApi() - const updateTable = async (groupId: number, feedback: Partial, usePost: boolean) => { - if (!projectId || submissions === null || !groupId) return console.error("No projectId or submissions or groupId found") - - let res +import { Button, Input, List, Space, Table, Tooltip, Typography } from "antd"; +import { FC, useMemo, useState } from "react"; +import { ProjectSubmissionsType } from "./SubmissionsTab"; +import { TableProps } from "antd/lib"; +import { useTranslation } from "react-i18next"; +import {DownloadOutlined, SaveOutlined} from "@ant-design/icons"; +import useProject from "../../../hooks/useProject"; +import SubmissionStatusTag, { createStatusBitVector } from "./SubmissionStatusTag"; +import { Link, useParams } from "react-router-dom"; +import { AppRoutes } from "../../../@types/routes"; +import { ApiRoutes, PUT_Requests } from "../../../@types/requests.d"; +import useAppApi from "../../../hooks/useAppApi"; +import useApi from "../../../hooks/useApi"; + +const GroupMember = ({ name, studentNumber }: ProjectSubmissionsType["group"]["members"][number]) => { + return ( + + + + ); +}; + +const SubmissionsTable: FC<{ submissions: ProjectSubmissionsType[] | null; onChange: (s: ProjectSubmissionsType[]) => void; withArtifacts?: boolean }> = ({ submissions, onChange, withArtifacts }) => { + const { t } = useTranslation(); + const project = useProject(); + const { courseId, projectId } = useParams(); + const { message } = useAppApi(); + const API = useApi(); + const [editingFeedback, setEditingFeedback] = useState<{ [key: number]: string }>({}); + const [isEditingFeedback, setIsEditingFeedback] = useState<{ [key: number]: boolean }>({}); + const [oldFeedback, setOldFeedback] = useState<{ [key: number]: string }>({}); + + const updateTable = async (groupId: number, feedback: Omit, usePost: boolean) => { + if (!projectId || submissions === null || !groupId) return console.error("No projectId or submissions or groupId found"); + + let res; if (usePost) { res = await API.POST( - ApiRoutes.PROJECT_SCORE, - { - body: { - score: 0, - feedback: "", - ...feedback, + ApiRoutes.PROJECT_SCORE, + { + body: feedback, + pathValues: { id: projectId, groupId }, }, - pathValues: { id: projectId, groupId }, - }, - "message" - ) + "message" + ); } else { - res = await API.PATCH(ApiRoutes.PROJECT_SCORE, { body: feedback, pathValues: { id: projectId, groupId } }, "message") + res = await API.PUT(ApiRoutes.PROJECT_SCORE, { body: feedback, pathValues: { id: projectId, groupId } }, "message"); } - if (!res.success) return + if (!res.success) return; - const data = res.response.data + const data = res.response.data; const newSubmissions: ProjectSubmissionsType[] = submissions.map((s) => { - if (s.group.groupId !== groupId) return s + if (s.group.groupId !== groupId) return s; return { ...s, feedback: { ...s.feedback, ...data, }, - } - }) + }; + }); - onChange(newSubmissions) - } + onChange(newSubmissions); + }; const updateScore = async (s: ProjectSubmissionsType, scoreStr: string) => { - if (!projectId || !project) return console.error("No projectId or project found") - if (!project.maxScore) return console.error("Scoring not available for this project") - scoreStr = scoreStr.trim() - let score: number | null - if (scoreStr === "") score = null - else score = parseFloat(scoreStr) - if (isNaN(score as number)) score = null - if (score !== null && score > project.maxScore) return message.error(t("project.scoreTooHigh")) - await updateTable(s.group.groupId, { score }, s.feedback === null) - } - - const updateFeedback = async (s: ProjectSubmissionsType, feedback: string) => { - await updateTable(s.group.groupId, { feedback }, s.feedback === null) - } + if (!projectId || !project) return console.error("No projectId or project found"); + if (!project.maxScore) return console.error("Scoring not available for this project"); + scoreStr = scoreStr.trim(); + let score: number | null; + if (scoreStr === "") score = null; + else score = parseFloat(scoreStr); + if (isNaN(score as number)) score = null; + if (score !== null ) { + if (score > project.maxScore) { + return message.error(t("project.scoreTooHigh")) + } else if (score < 0) return message.error(t("project.scoreNegative")); + + + }; + + await updateTable(s.group.groupId, { score: score ?? null, feedback: s.feedback?.feedback ?? "" }, s.feedback === null); + }; + + const updateFeedback = async (groupId: number) => { + const feedback = editingFeedback[groupId]; + if (feedback !== undefined) { + const s = submissions?.find(s => s.group.groupId === groupId) + await updateTable(groupId, { feedback, score: s?.feedback?.score ?? null }, s?.feedback === null); + setEditingFeedback((prev) => { + const newState = { ...prev }; + delete newState[groupId]; + return newState; + }); + } + setIsEditingFeedback((prev) => ({ ...prev, [groupId]: false })); + }; const downloadFile = async (route: ApiRoutes.SUBMISSION_FILE | ApiRoutes.SUBMISSION_ARTIFACT, filename: string) => { - const response = await API.GET( + const response = await API.GET( route, { config: { @@ -86,28 +107,33 @@ const SubmissionsTable: FC<{ submissions: ProjectSubmissionsType[] | null; onCha }, }, "message" - ) - if (!response.success) return - console.log(response) - const url = window.URL.createObjectURL(new Blob([response.response.data])) - const link = document.createElement("a") - link.href = url - let fileName = filename+".zip" // default filename - link.setAttribute("download", fileName) - document.body.appendChild(link) - link.click() - link.parentNode!.removeChild(link) - - } - + ); + if (!response.success) return; + const url = window.URL.createObjectURL(new Blob([response.response.data])); + const link = document.createElement("a"); + link.href = url; + let fileName = filename + ".zip"; // default filename + link.setAttribute("download", fileName); + document.body.appendChild(link); + link.click(); + link.parentNode!.removeChild(link); + }; const downloadSubmission = async (submission: ProjectSubmissionsType) => { - if (!submission.submission) return console.error("No submission found") - downloadFile(submission.submission.fileUrl, submission.group.name+".zip") - if(withArtifacts && submission.submission.artifactUrl) { - downloadFile(submission.submission.artifactUrl, submission.group.name+"-artifacts.zip") + if (!submission.submission) return console.error("No submission found"); + downloadFile(submission.submission.fileUrl, submission.group.name + ".zip"); + if (withArtifacts && submission.submission.artifactUrl) { + downloadFile(submission.submission.artifactUrl, submission.group.name + "-artifacts.zip"); } - } + }; + + const handleEditFeedback = (groupId: number) => { + setIsEditingFeedback((prev) => { + setOldFeedback((prev) => ({ ...prev, [groupId]: editingFeedback[groupId] })); + return { ...prev, [groupId]: true } + }) + }; + const columns: TableProps["columns"] = useMemo(() => { const cols: TableProps["columns"] = [ @@ -117,30 +143,31 @@ const SubmissionsTable: FC<{ submissions: ProjectSubmissionsType[] | null; onCha key: "group", render: (g) => {g.name}, sorter: (a: ProjectSubmissionsType, b: ProjectSubmissionsType) => { - return a.group.groupId - b.group.groupId + return a.group.groupId - b.group.groupId; }, }, { title: t("project.submission"), key: "submissionId", - render: (s: ProjectSubmissionsType) => s.submission ? ( - - - - ) : null, + render: (s: ProjectSubmissionsType) => + s.submission ? ( + + + + ) : null, }, { title: t("project.status"), dataIndex: "submission", key: "submissionStatus", render: (s) => ( - - {" "} - + + {" "} + ), }, { @@ -148,90 +175,125 @@ const SubmissionsTable: FC<{ submissions: ProjectSubmissionsType[] | null; onCha dataIndex: "submission", key: "submission", render: (time: ProjectSubmissionsType["submission"]) => time?.submissionTime && {new Date(time.submissionTime).toLocaleString()}, - sorter: (a: ProjectSubmissionsType, b: ProjectSubmissionsType) => { - // Implement sorting logic for submissionTime column - const timeA: any = a.submission?.submissionTime || 0 - const timeB: any = b.submission?.submissionTime || 0 - return timeA - timeB - }, }, - ] + ]; if (!project || project.maxScore) { cols.push({ title: `Score (/${project?.maxScore ?? ""})`, key: "score", render: (s: ProjectSubmissionsType) => ( - updateScore(s, e), maxLength: 10 }} - > - {s.feedback?.score ?? t("project.noScoreLabel")} - + updateScore(s, e), + maxLength: 10, + text: s.feedback?.score ? s.feedback?.score?.toString() : "", + }} + > + {s.feedback?.score ?? t("project.noScoreLabel")} + ), - }) + }); } cols.push({ title: "Download", key: "download", render: (s: ProjectSubmissionsType) => ( - -