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  }