github.com/quickfeed/quickfeed@v0.0.0-20240507093252-ed8ca812a09c/public/src/overmind/actions.tsx (about) 1 import { Color, ConnStatus, hasStudent, hasTeacher, isPending, isStudent, isTeacher, isVisible, newID, SubmissionSort, SubmissionStatus, validateGroup } from "../Helpers" 2 import { 3 User, Enrollment, Submission, Course, Group, GradingCriterion, Assignment, GradingBenchmark, Enrollment_UserStatus, Submission_Status, Enrollment_DisplayState, Group_GroupStatus 4 } from "../../proto/qf/types_pb" 5 import { Organization, SubmissionRequest_SubmissionType, } from "../../proto/qf/requests_pb" 6 import { Alert, CourseGroup, SubmissionOwner } from "./state" 7 import * as internalActions from "./internalActions" 8 import { Context } from "." 9 import { Response } from "../client" 10 import { Code, ConnectError } from "@bufbuild/connect" 11 import { PartialMessage } from "@bufbuild/protobuf" 12 13 export const internal = internalActions 14 15 export const onInitializeOvermind = async ({ actions, effects }: Context) => { 16 // Initialize the API client. *Must* be done before accessing the client. 17 effects.api.init(actions.errorHandler) 18 await actions.fetchUserData() 19 // Currently this only alerts the user if they are not logged in after a page refresh 20 const alert = localStorage.getItem("alert") 21 if (alert) { 22 actions.alert({ text: alert, color: Color.RED }) 23 localStorage.removeItem("alert") 24 } 25 } 26 27 export const handleStreamError = (context: Context, error: Error): void => { 28 context.state.connectionStatus = ConnStatus.DISCONNECTED 29 context.actions.alert({ text: error.message, color: Color.RED, delay: 10000 }) 30 } 31 32 export const receiveSubmission = ({ state }: Context, submission: Submission): void => { 33 let courseID = 0n 34 let assignmentOrder = 0 35 Object.entries(state.assignments).forEach( 36 ([, assignments]) => { 37 const assignment = assignments.find(a => a.ID === submission.AssignmentID) 38 if (assignment && assignment.CourseID !== 0n) { 39 assignmentOrder = assignment.order 40 courseID = assignment.CourseID 41 return 42 } 43 } 44 ) 45 if (courseID === 0n) { 46 return 47 } 48 Object.assign(state.submissions[courseID.toString()][assignmentOrder - 1], submission) 49 } 50 51 /** 52 * START CURRENT USER ACTIONS 53 */ 54 55 /** Fetches and stores an authenticated user in state */ 56 export const getSelf = async ({ state, effects }: Context): Promise<boolean> => { 57 const response = await effects.api.client.getUser({}) 58 if (response.error) { 59 return false 60 } 61 state.self = response.message 62 return true 63 } 64 65 /** Gets all enrollments for the current user and stores them in state */ 66 export const getEnrollmentsByUser = async ({ state, effects }: Context): Promise<void> => { 67 const response = await effects.api.client.getEnrollments({ 68 FetchMode: { 69 case: "userID", 70 value: state.self.ID, 71 } 72 }) 73 if (response.error) { 74 return 75 } 76 state.enrollments = response.message.enrollments 77 for (const enrollment of state.enrollments) { 78 state.status[enrollment.courseID.toString()] = enrollment.status 79 } 80 } 81 82 /** Fetches all users (requires admin privileges) */ 83 export const getUsers = async ({ state, effects }: Context): Promise<void> => { 84 const response = await effects.api.client.getUsers({}) 85 if (response.error) { 86 return 87 } 88 for (const user of response.message.users) { 89 state.users[user.ID.toString()] = user 90 } 91 // Insert users sorted by admin privileges 92 state.allUsers = response.message.users.sort((a, b) => { 93 if (a.IsAdmin > b.IsAdmin) { return -1 } 94 if (a.IsAdmin < b.IsAdmin) { return 1 } 95 return 0 96 }) 97 } 98 99 /** Changes user information server-side */ 100 export const updateUser = async ({ actions, effects }: Context, user: User): Promise<void> => { 101 const response = await effects.api.client.updateUser(user) 102 if (response.error) { 103 return 104 } 105 await actions.getSelf() 106 } 107 108 /** 109 * END CURRENT USER ACTIONS 110 */ 111 112 /** Fetches all courses */ 113 export const getCourses = async ({ state, effects }: Context): Promise<void> => { 114 state.courses = [] 115 const response = await effects.api.client.getCourses({}) 116 if (response.error) { 117 return 118 } 119 state.courses = response.message.courses 120 } 121 122 /** updateAdmin is used to update the admin privileges of a user. Admin status toggles between true and false */ 123 export const updateAdmin = async ({ state, effects }: Context, user: User): Promise<void> => { 124 // Confirm that user really wants to change admin status 125 if (confirm(`Are you sure you want to ${user.IsAdmin ? "demote" : "promote"} ${user.Name}?`)) { 126 // Convert to proto object and change admin status 127 const req = new User(user) 128 req.IsAdmin = !user.IsAdmin 129 // Send updated user to server 130 const response = await effects.api.client.updateUser(req) 131 if (response.error) { 132 return 133 } 134 // If successful, update user in state with new admin status 135 const found = state.allUsers.findIndex(s => s.ID === user.ID) 136 if (found > -1) { 137 state.allUsers[found].IsAdmin = req.IsAdmin 138 } 139 } 140 } 141 142 export const getEnrollmentsByCourse = async ({ state, effects }: Context, value: { courseID: bigint, statuses: Enrollment_UserStatus[] }): Promise<void> => { 143 const response = await effects.api.client.getEnrollments({ 144 FetchMode: { 145 case: "courseID", 146 value: value.courseID, 147 }, 148 statuses: value.statuses, 149 }) 150 if (response.error) { 151 return 152 } 153 state.courseEnrollments[value.courseID.toString()] = response.message.enrollments 154 } 155 156 /** setEnrollmentState toggles the state of an enrollment between favorite and visible */ 157 export const setEnrollmentState = async ({ effects }: Context, enrollment: Enrollment): Promise<void> => { 158 enrollment.state = isVisible(enrollment) 159 ? Enrollment_DisplayState.HIDDEN 160 : Enrollment_DisplayState.VISIBLE 161 162 await effects.api.client.updateCourseVisibility(enrollment) 163 } 164 165 /** Updates a given submission with a new status. This updates the given submission, as well as all other occurrences of the given submission in state. */ 166 export const updateSubmission = async ({ state, effects }: Context, { owner, submission, status }: { owner: SubmissionOwner, submission: Submission | null, status: Submission_Status }): Promise<void> => { 167 /* Do not update if the status is already the same or if there is no selected submission */ 168 if (!submission || submission.status === status) { 169 return 170 } 171 172 /* Confirm that user really wants to change submission status */ 173 if (!confirm(`Are you sure you want to set status ${SubmissionStatus[status]} on this submission?`)) { 174 return 175 } 176 177 const clone = submission.clone() 178 clone.status = status 179 /* Update the submission status */ 180 const response = await effects.api.client.updateSubmission({ 181 courseID: state.activeCourse, 182 submissionID: submission.ID, 183 status: clone.status, 184 released: submission.released, 185 score: submission.score, 186 }) 187 if (response.error) { 188 return 189 } 190 submission.status = status 191 state.submissionsForCourse.update(owner, submission) 192 } 193 194 /** updateEnrollment updates an enrollment status with the given status */ 195 export const updateEnrollment = async ({ state, actions, effects }: Context, { enrollment, status }: { enrollment: Enrollment, status: Enrollment_UserStatus }): Promise<void> => { 196 if (!enrollment.user) { 197 // user name is required 198 return 199 } 200 201 if (status === Enrollment_UserStatus.NONE) { 202 const proceed = await actions.internal.isEmptyRepo({ userID: enrollment.userID, courseID: enrollment.courseID }) 203 if (!proceed) { 204 return 205 } 206 } 207 208 // Confirm that user really wants to change enrollment status 209 let confirmed = false 210 switch (status) { 211 case Enrollment_UserStatus.NONE: 212 confirmed = confirm("WARNING! Rejecting a student is irreversible. Are you sure?") 213 break 214 case Enrollment_UserStatus.STUDENT: 215 // If the enrollment is pending, don't ask for confirmation 216 confirmed = isPending(enrollment) || confirm(`Warning! ${enrollment.user.Name} is a teacher. Are sure you want to demote?`) 217 break 218 case Enrollment_UserStatus.TEACHER: 219 confirmed = confirm(`Are you sure you want to promote ${enrollment.user.Name} to teacher status?`) 220 break 221 case Enrollment_UserStatus.PENDING: 222 // Status pending should never be set by this function. 223 // If the intent is to accept a pending enrollment, status should be set to student. 224 return 225 } 226 if (!confirmed) { 227 return 228 } 229 230 // Lookup the enrollment 231 // The enrollment should be in state, if it is not, do nothing 232 const enrollments = state.courseEnrollments[state.activeCourse.toString()] ?? [] 233 const found = enrollments.findIndex(e => e.ID === enrollment.ID) 234 if (found === -1) { 235 return 236 } 237 238 // Clone enrollment object and change status 239 const temp = enrollment.clone() 240 temp.status = status 241 242 // Send updated enrollment to server 243 const response = await effects.api.client.updateEnrollments({ enrollments: [temp] }) 244 if (response.error) { 245 return 246 } 247 // If successful, update enrollment in state with new status 248 if (status === Enrollment_UserStatus.NONE) { 249 // If the enrollment is rejected, remove it from state 250 enrollments.splice(found, 1) 251 } else { 252 // If the enrollment is accepted, update the enrollment in state 253 enrollments[found].status = status 254 } 255 } 256 257 /** approvePendingEnrollments approves all pending enrollments for the current course */ 258 export const approvePendingEnrollments = async ({ state, actions, effects }: Context): Promise<void> => { 259 if (!confirm("Please confirm that you want to approve all students")) { 260 return 261 } 262 263 // Clone and set status to student for all pending enrollments. 264 // We need to clone the enrollments to avoid modifying the state directly. 265 // We do not want to update set the enrollment status before the update is successful. 266 const enrollments = state.pendingEnrollments.map(e => { 267 const temp = e.clone() 268 temp.status = Enrollment_UserStatus.STUDENT 269 return temp 270 }) 271 272 // Send updated enrollments to server 273 const response = await effects.api.client.updateEnrollments({ enrollments }) 274 if (response.error) { 275 // Fetch enrollments again if update failed in case the user was able to approve some enrollments 276 await actions.getEnrollmentsByCourse({ courseID: state.activeCourse, statuses: [Enrollment_UserStatus.PENDING] }) 277 return 278 } 279 for (const enrollment of state.pendingEnrollments) { 280 enrollment.status = Enrollment_UserStatus.STUDENT 281 } 282 } 283 284 /** Get assignments for all the courses the current user is enrolled in */ 285 export const getAssignments = async ({ state, actions }: Context): Promise<void> => { 286 await Promise.all(state.enrollments.map(async enrollment => { 287 if (isPending(enrollment)) { 288 // No need to get assignments for pending enrollments 289 return 290 } 291 await actions.getAssignmentsByCourse(enrollment.courseID) 292 })) 293 } 294 295 /** Get assignments for a single course, given by courseID */ 296 export const getAssignmentsByCourse = async ({ state, effects }: Context, courseID: bigint): Promise<void> => { 297 const response = await effects.api.client.getAssignments({ courseID }) 298 if (response.error) { 299 return 300 } 301 state.assignments[courseID.toString()] = response.message.assignments 302 } 303 304 export const getRepositories = async ({ state, effects }: Context): Promise<void> => { 305 await Promise.all(state.enrollments.map(async enrollment => { 306 if (isPending(enrollment)) { 307 // No need to get repositories for pending enrollments 308 return 309 } 310 const courseID = enrollment.courseID 311 state.repositories[courseID.toString()] = {} 312 const response = await effects.api.client.getRepositories({ courseID }) 313 if (response.error) { 314 return 315 } 316 state.repositories[courseID.toString()] = response.message.URLs 317 })) 318 } 319 320 export const getGroup = async ({ state, effects }: Context, enrollment: Enrollment): Promise<void> => { 321 const response = await effects.api.client.getGroup({ courseID: enrollment.courseID, groupID: enrollment.groupID }) 322 if (response.error) { 323 return 324 } 325 state.userGroup[enrollment.courseID.toString()] = response.message 326 } 327 328 export const createGroup = async ({ state, actions, effects }: Context, group: CourseGroup): Promise<void> => { 329 const check = validateGroup(group) 330 if (!check.valid) { 331 actions.alert({ text: check.message, color: Color.RED, delay: 10000 }) 332 return 333 } 334 335 const response = await effects.api.client.createGroup({ 336 courseID: group.courseID, 337 name: group.name, 338 users: group.users.map(userID => new User({ ID: userID })) 339 }) 340 341 if (response.error) { 342 return 343 } 344 345 state.userGroup[group.courseID.toString()] = response.message 346 state.activeGroup = null 347 } 348 349 /** getOrganization returns the organization object for orgName retrieved from the server. */ 350 export const getOrganization = async ({ effects }: Context, orgName: string): Promise<Response<Organization>> => { 351 return await effects.api.client.getOrganization({ ScmOrganizationName: orgName }) 352 } 353 354 /* createCourse creates a new course */ 355 export const createCourse = async ({ state, actions, effects }: Context, value: { course: Course, org: Organization }): Promise<boolean> => { 356 const course: PartialMessage<Course> = { ...value.course } 357 /* Fill in required fields */ 358 course.ScmOrganizationID = value.org.ScmOrganizationID 359 course.ScmOrganizationName = value.org.ScmOrganizationName 360 course.courseCreatorID = state.self.ID 361 /* Send the course to the server */ 362 const response = await effects.api.client.createCourse(course) 363 if (response.error) { 364 return false 365 } 366 /* If successful, add the course to the state */ 367 state.courses.push(response.message) 368 /* User that created the course is automatically enrolled in the course. Refresh the enrollment list */ 369 await actions.getEnrollmentsByUser() 370 return true 371 } 372 373 /** Updates a given course and refreshes courses in state if successful */ 374 export const editCourse = async ({ actions, effects }: Context, { course }: { course: Course }): Promise<void> => { 375 const response = await effects.api.client.updateCourse(course) 376 if (response.error) { 377 return 378 } 379 await actions.getCourses() 380 } 381 382 /** Fetches and stores all submissions of a given course into state. Triggers the loading spinner. */ 383 export const loadCourseSubmissions = async ({ state, actions }: Context, courseID: bigint): Promise<void> => { 384 state.isLoading = true 385 await actions.refreshCourseSubmissions(courseID) 386 state.loadedCourse[courseID.toString()] = true 387 state.isLoading = false 388 } 389 390 /** Refreshes all submissions for a given course. Calling this action directly will not trigger the loading spinner. 391 * Use `loadCourseSubmissions` instead if you want to trigger the loading spinner, such as on page load. */ 392 export const refreshCourseSubmissions = async ({ state, effects }: Context, courseID: bigint): Promise<void> => { 393 // None of these should fail independently. 394 const userResponse = await effects.api.client.getSubmissionsByCourse({ 395 CourseID: courseID, 396 FetchMode: { 397 case: "Type", 398 value: SubmissionRequest_SubmissionType.USER 399 } 400 }) 401 const groupResponse = await effects.api.client.getSubmissionsByCourse({ 402 CourseID: courseID, 403 FetchMode: { 404 case: "Type", 405 value: SubmissionRequest_SubmissionType.GROUP 406 } 407 }) 408 if (userResponse.error || groupResponse.error) { 409 return 410 } 411 412 state.submissionsForCourse.setSubmissions("USER", userResponse.message) 413 state.submissionsForCourse.setSubmissions("GROUP", groupResponse.message) 414 415 for (const submissions of Object.values(userResponse.message.submissions)) { 416 for (const submission of submissions.submissions) { 417 state.review.reviews.set(submission.ID, submission.reviews) 418 } 419 } 420 } 421 422 export const getGroupsByCourse = async ({ state, effects }: Context, courseID: bigint): Promise<void> => { 423 state.groups[courseID.toString()] = [] 424 const response = await effects.api.client.getGroupsByCourse({ courseID }) 425 if (response.error) { 426 return 427 } 428 state.groups[courseID.toString()] = response.message.groups 429 } 430 431 export const getUserSubmissions = async ({ state, effects }: Context, courseID: bigint): Promise<void> => { 432 const id = courseID.toString() 433 if (!state.submissions[id]) { 434 state.submissions[id] = [] 435 } 436 const response = await effects.api.client.getSubmissions({ 437 CourseID: courseID, 438 FetchMode: { 439 case: "UserID", 440 value: state.self.ID, 441 }, 442 }) 443 if (response.error) { 444 return 445 } 446 // Insert submissions into state.submissions by the assignment order 447 state.assignments[id]?.forEach(assignment => { 448 const submission = response.message.submissions.find(s => s.AssignmentID === assignment.ID) 449 if (!state.submissions[id][assignment.order - 1]) { 450 state.submissions[id][assignment.order - 1] = submission ? submission : new Submission() 451 } 452 }) 453 } 454 455 export const getGroupSubmissions = async ({ state, effects }: Context, courseID: bigint): Promise<void> => { 456 const enrollment = state.enrollmentsByCourseID[courseID.toString()] 457 if (!(enrollment && enrollment.group)) { 458 return 459 } 460 const response = await effects.api.client.getSubmissions({ 461 CourseID: courseID, 462 FetchMode: { 463 case: "GroupID", 464 value: enrollment.groupID, 465 }, 466 }) 467 if (response.error) { 468 return 469 } 470 state.assignments[courseID.toString()]?.forEach(assignment => { 471 const submission = response.message.submissions.find(sbm => sbm.AssignmentID === assignment.ID) 472 if (submission && assignment.isGroupLab) { 473 state.submissions[courseID.toString()][assignment.order - 1] = submission 474 } 475 }) 476 } 477 478 export const setActiveCourse = ({ state }: Context, courseID: bigint): void => { 479 state.activeCourse = courseID 480 } 481 482 export const toggleFavorites = ({ state }: Context): void => { 483 state.showFavorites = !state.showFavorites 484 } 485 486 export const setSelectedAssignmentID = ({ state }: Context, assignmentID: number): void => { 487 state.selectedAssignmentID = assignmentID 488 } 489 490 export const setSelectedSubmission = ({ state }: Context, submission: Submission): void => { 491 state.selectedSubmission = submission.clone() 492 } 493 494 export const getSubmission = async ({ state, effects }: Context, { courseID, owner, submission }: { courseID: bigint, owner: SubmissionOwner, submission: Submission }): Promise<void> => { 495 const response = await effects.api.client.getSubmission({ 496 CourseID: courseID, 497 FetchMode: { 498 case: "SubmissionID", 499 value: submission.ID, 500 }, 501 }) 502 if (response.error) { 503 return 504 } 505 state.submissionsForCourse.update(owner, response.message) 506 if (state.selectedSubmission && state.selectedSubmission.ID === submission.ID) { 507 // Only update the selected submission if it is the same as the one we just fetched. 508 // This is to avoid overwriting the selected submission with a different one. 509 // This can happen when the user clicks on a submission in the submission list, and then 510 // selects a different submission in the submission list before the first request has finished. 511 state.selectedSubmission = response.message 512 } 513 } 514 515 /** Rebuilds the currently active submission */ 516 export const rebuildSubmission = async ({ state, actions, effects }: Context, { owner, submission }: { owner: SubmissionOwner, submission: Submission | null }): Promise<void> => { 517 if (!(submission && state.selectedAssignment && state.activeCourse)) { 518 return 519 } 520 const response = await effects.api.client.rebuildSubmissions({ 521 courseID: state.activeCourse, 522 assignmentID: state.selectedAssignment.ID, 523 submissionID: submission.ID, 524 }) 525 if (response.error) { 526 return 527 } 528 // TODO: Alerting is temporary due to the fact that the server no longer returns the updated submission. 529 // TODO: gRPC streaming should be implemented to send the updated submission to the api.client. 530 await actions.getSubmission({ courseID: state.activeCourse, submission, owner }) 531 actions.alert({ color: Color.GREEN, text: 'Submission rebuilt successfully' }) 532 } 533 534 /* rebuildAllSubmissions rebuilds all submissions for a given assignment */ 535 export const rebuildAllSubmissions = async ({ effects }: Context, { courseID, assignmentID }: { courseID: bigint, assignmentID: bigint }): Promise<boolean> => { 536 const response = await effects.api.client.rebuildSubmissions({ 537 courseID, 538 assignmentID, 539 }) 540 return !response.error 541 } 542 543 /** Enrolls a user (self) in a course given by courseID. Refreshes enrollments in state if enroll is successful. */ 544 export const enroll = async ({ state, effects }: Context, courseID: bigint): Promise<void> => { 545 const response = await effects.api.client.createEnrollment({ 546 courseID, 547 userID: state.self.ID, 548 }) 549 if (response.error) { 550 return 551 } 552 const enrolsResponse = await effects.api.client.getEnrollments({ 553 FetchMode: { 554 case: "userID", 555 value: state.self.ID, 556 } 557 }) 558 559 if (enrolsResponse.error) { 560 return 561 } 562 state.enrollments = enrolsResponse.message.enrollments 563 564 } 565 566 export const updateGroupStatus = async ({ effects }: Context, { group, status }: { group: Group, status: Group_GroupStatus }): Promise<void> => { 567 const oldStatus = group.status 568 group.status = status 569 const response = await effects.api.client.updateGroup(group) 570 if (response.error) { 571 group.status = oldStatus 572 } 573 } 574 575 export const deleteGroup = async ({ state, actions, effects }: Context, group: Group): Promise<void> => { 576 if (!confirm("Deleting a group is an irreversible action. Are you sure?")) { 577 return 578 } 579 const proceed = await actions.internal.isEmptyRepo({ groupID: group.ID, courseID: group.courseID }) 580 if (!proceed) { 581 return 582 } 583 584 const deleteResponse = await effects.api.client.deleteGroup({ 585 courseID: group.courseID, 586 groupID: group.ID, 587 }) 588 if (deleteResponse.error) { 589 return 590 } 591 state.groups[group.courseID.toString()] = state.groups[group.courseID.toString()].filter(g => g.ID !== group.ID) 592 } 593 594 export const updateGroup = async ({ state, actions, effects }: Context, group: Group): Promise<void> => { 595 const response = await effects.api.client.updateGroup(group) 596 if (response.error) { 597 return 598 } 599 const found = state.groups[group.courseID.toString()].find(g => g.ID === group.ID) 600 if (found && response.message) { 601 Object.assign(found, response.message) 602 actions.setActiveGroup(null) 603 } 604 } 605 606 export const createOrUpdateCriterion = async ({ effects }: Context, { criterion, assignment }: { criterion: GradingCriterion, assignment: Assignment }): Promise<void> => { 607 const benchmark = assignment.gradingBenchmarks.find(bm => bm.ID === criterion.ID) 608 if (!benchmark) { 609 // If a benchmark is not found, the criterion is invalid. 610 return 611 } 612 613 // Existing criteria have a criteria id > 0, new criteria have a criteria id of 0 614 if (criterion.ID) { 615 const response = await effects.api.client.updateCriterion(criterion) 616 if (response.error) { 617 return 618 } 619 const index = benchmark.criteria.findIndex(c => c.ID === criterion.ID) 620 if (index > -1) { 621 benchmark.criteria[index] = criterion 622 } 623 } else { 624 const response = await effects.api.client.createCriterion(criterion) 625 if (response.error) { 626 return 627 } 628 benchmark.criteria.push(response.message) 629 } 630 } 631 632 export const createOrUpdateBenchmark = async ({ effects }: Context, { benchmark, assignment }: { benchmark: GradingBenchmark, assignment: Assignment }): Promise<void> => { 633 // Check if this need cloning 634 const bm = benchmark.clone() 635 if (benchmark.ID) { 636 const response = await effects.api.client.updateBenchmark(bm) 637 if (response.error) { 638 return 639 } 640 const index = assignment.gradingBenchmarks.indexOf(benchmark) 641 if (index > -1) { 642 assignment.gradingBenchmarks[index] = benchmark 643 } 644 } else { 645 const response = await effects.api.client.createBenchmark(benchmark) 646 if (response.error) { 647 return 648 } 649 assignment.gradingBenchmarks.push(response.message) 650 } 651 } 652 653 export const createBenchmark = async ({ effects }: Context, { benchmark, assignment }: { benchmark: GradingBenchmark, assignment: Assignment }): Promise<void> => { 654 benchmark.AssignmentID = assignment.ID 655 const response = await effects.api.client.createBenchmark(benchmark) 656 if (response.error) { 657 return 658 } 659 assignment.gradingBenchmarks.push(benchmark) 660 } 661 662 export const deleteCriterion = async ({ effects }: Context, { criterion, assignment }: { criterion?: GradingCriterion, assignment: Assignment }): Promise<void> => { 663 if (!criterion) { 664 // Criterion is invalid 665 return 666 } 667 668 const benchmark = assignment.gradingBenchmarks.find(bm => bm.ID === criterion?.ID) 669 if (!benchmark) { 670 // Criterion has no parent benchmark 671 return 672 } 673 674 if (!confirm("Do you really want to delete this criterion?")) { 675 // Do nothing if user cancels 676 return 677 } 678 679 // Delete criterion 680 const response = await effects.api.client.deleteCriterion(criterion) 681 if (response.error) { 682 return 683 } 684 685 // Remove criterion from benchmark in state if request was successful 686 const index = assignment.gradingBenchmarks.indexOf(benchmark) 687 if (index > -1) { 688 assignment.gradingBenchmarks.splice(index, 1) 689 } 690 691 } 692 693 export const deleteBenchmark = async ({ effects }: Context, { benchmark, assignment }: { benchmark?: GradingBenchmark, assignment: Assignment }): Promise<void> => { 694 if (benchmark && confirm("Do you really want to delete this benchmark?")) { 695 const response = await effects.api.client.deleteBenchmark(benchmark) 696 if (response.error) { 697 return 698 } 699 const index = assignment.gradingBenchmarks.indexOf(benchmark) 700 if (index > -1) { 701 assignment.gradingBenchmarks.splice(index, 1) 702 } 703 } 704 } 705 706 export const setActiveEnrollment = ({ state }: Context, enrollment: Enrollment | null): void => { 707 state.selectedEnrollment = enrollment ? enrollment : null 708 } 709 710 export const startSubmissionStream = ({ actions, effects }: Context) => { 711 effects.streamService.submissionStream({ 712 onStatusChange: actions.setConnectionStatus, 713 onMessage: actions.receiveSubmission, 714 onError: actions.handleStreamError, 715 }) 716 } 717 718 export const updateAssignments = async ({ actions, effects }: Context, courseID: bigint): Promise<void> => { 719 const response = await effects.api.client.updateAssignments({ courseID }) 720 if (response.error) { 721 return 722 } 723 actions.alert({ text: "Assignments updated", color: Color.GREEN }) 724 } 725 726 /* fetchUserData is called when the user enters the app. It fetches all data that is needed for the user to be able to use the app. */ 727 /* If the user is not logged in, i.e does not have a valid token, the process is aborted. */ 728 export const fetchUserData = async ({ state, actions }: Context): Promise<boolean> => { 729 const successful = await actions.getSelf() 730 // If getSelf returns false, the user is not logged in. Abort. 731 if (!successful) { 732 state.isLoading = false 733 return false 734 } 735 // Order matters here. Some data is dependent on other data. Ex. fetching submissions depends on enrollments. 736 await actions.getEnrollmentsByUser() 737 await actions.getAssignments() 738 await actions.getCourses() 739 const results = [] 740 for (const enrollment of state.enrollments) { 741 const courseID = enrollment.courseID 742 if (isStudent(enrollment) || isTeacher(enrollment)) { 743 results.push(actions.getUserSubmissions(courseID)) 744 results.push(actions.getGroupSubmissions(courseID)) 745 const statuses = isStudent(enrollment) ? [Enrollment_UserStatus.STUDENT, Enrollment_UserStatus.TEACHER] : [] 746 results.push(actions.getEnrollmentsByCourse({ courseID, statuses })) 747 if (enrollment.groupID > 0) { 748 results.push(actions.getGroup(enrollment)) 749 } 750 } 751 if (isTeacher(enrollment)) { 752 results.push(actions.getGroupsByCourse(courseID)) 753 } 754 } 755 await Promise.all(results) 756 if (state.self.IsAdmin) { 757 await actions.getUsers() 758 } 759 await actions.getRepositories() 760 actions.startSubmissionStream() 761 // End loading screen. 762 state.isLoading = false 763 return true 764 } 765 766 /* Utility Actions */ 767 768 /** Switches between teacher and student view. */ 769 export const changeView = async ({ state, effects }: Context, courseID: bigint): Promise<void> => { 770 const enrollment = state.enrollmentsByCourseID[courseID.toString()] 771 if (hasStudent(enrollment.status)) { 772 const response = await effects.api.client.getEnrollments({ 773 FetchMode: { 774 case: "userID", 775 value: state.self.ID, 776 }, 777 statuses: [Enrollment_UserStatus.TEACHER], 778 }) 779 if (response.error) { 780 return 781 } 782 if (response.message.enrollments.find(enrol => enrol.courseID === courseID && hasTeacher(enrol.status))) { 783 enrollment.status = Enrollment_UserStatus.TEACHER 784 } 785 } else if (hasTeacher(enrollment.status)) { 786 enrollment.status = Enrollment_UserStatus.STUDENT 787 } 788 } 789 790 export const loading = ({ state }: Context): void => { 791 state.isLoading = !state.isLoading 792 } 793 794 /** Sets a query string in state. */ 795 export const setQuery = ({ state }: Context, query: string): void => { 796 state.query = query 797 } 798 799 export const errorHandler = (context: Context, { method, error }: { method: string, error: ConnectError }): void => { 800 if (!error) { 801 return 802 } 803 804 // TODO(jostein): Currently all errors are handled the same way. 805 // We could handle each method individually, and assign a log level to each method. 806 // The log level could be determined based on user role. 807 808 if (error.code === Code.Unauthenticated) { 809 // If we end up here, the user session has expired. 810 if (method === "GetUser") { 811 return // Do not show alert if the user is not logged in. 812 } 813 context.actions.alert({ 814 text: "Your session has expired. Please log in again.", 815 color: Color.RED 816 }) 817 // Store an alert message in localStorage that will be displayed after reloading the page. 818 localStorage.setItem("alert", "Your session has expired. Please log in again.") 819 } else { 820 // The error message includes the error code, while the rawMessage only includes the error message. 821 // 822 // error.message: "[not_found] failed to create github application: ..." 823 // error.rawMessage: "failed to create github application: ..." 824 // 825 // If the current user is an admin, the method name is included along with the error code. 826 // e.g. "GetOrganization: [not_found] failed to create github application: ..." 827 const message = context.state.self.IsAdmin ? `${method}: ${error.message}` : error.rawMessage 828 context.actions.alert({ 829 text: message, 830 color: Color.RED 831 }) 832 } 833 } 834 835 export const alert = ({ state }: Context, a: Pick<Alert, "text" | "color" | "delay">): void => { 836 state.alerts.push({ id: newID(), ...a }) 837 } 838 839 export const popAlert = ({ state }: Context, alert: Alert): void => { 840 state.alerts = state.alerts.filter(a => a.id !== alert.id) 841 } 842 843 export const logout = ({ state }: Context): void => { 844 // This does not empty the state. 845 state.self = new User() 846 } 847 848 export const setAscending = ({ state }: Context, ascending: boolean): void => { 849 state.sortAscending = ascending 850 } 851 852 export const setSubmissionSort = ({ state }: Context, sort: SubmissionSort): void => { 853 if (state.sortSubmissionsBy !== sort) { 854 state.sortSubmissionsBy = sort 855 } else { 856 state.sortAscending = !state.sortAscending 857 } 858 } 859 860 export const clearSubmissionFilter = ({ state }: Context): void => { 861 state.submissionFilters = [] 862 } 863 864 export const setSubmissionFilter = ({ state }: Context, filter: string): void => { 865 if (state.submissionFilters.includes(filter)) { 866 state.submissionFilters = state.submissionFilters.filter(f => f !== filter) 867 } else { 868 state.submissionFilters.push(filter) 869 } 870 } 871 872 export const setGroupView = ({ state }: Context, groupView: boolean): void => { 873 state.groupView = groupView 874 } 875 876 export const setActiveGroup = ({ state }: Context, group: Group | null): void => { 877 state.activeGroup = group?.clone() ?? null 878 } 879 880 export const updateGroupUsers = ({ state }: Context, user: User): void => { 881 if (!state.activeGroup) { 882 return 883 } 884 const group = state.activeGroup 885 // Remove the user from the group if they are already in it. 886 const index = group.users.findIndex(u => u.ID === user.ID) 887 if (index >= 0) { 888 group.users.splice(index, 1) 889 } else { 890 group.users.push(user) 891 } 892 } 893 894 export const updateGroupName = ({ state }: Context, name: string): void => { 895 if (!state.activeGroup) { 896 return 897 } 898 state.activeGroup.name = name 899 } 900 901 export const setConnectionStatus = ({ state }: Context, status: ConnStatus) => { 902 state.connectionStatus = status 903 } 904 905 // setSubmissionOwner sets the owner of the currently selected submission. 906 // The owner is either an enrollment or a group. 907 export const setSubmissionOwner = ({ state }: Context, owner: Enrollment | Group) => { 908 if (owner instanceof Group) { 909 state.submissionOwner = { type: "GROUP", id: owner.ID } 910 } else { 911 const groupID = state.selectedSubmission?.groupID ?? 0n 912 if (groupID > 0) { 913 state.submissionOwner = { type: "GROUP", id: groupID } 914 return 915 } 916 state.submissionOwner = { type: "ENROLLMENT", id: owner.ID } 917 } 918 } 919 920 export const updateSubmissionOwner = ({ state }: Context, owner: SubmissionOwner) => { 921 state.submissionOwner = owner 922 }