github.com/quickfeed/quickfeed@v0.0.0-20240507093252-ed8ca812a09c/public/src/overmind/state.tsx (about)

     1  import { derived } from "overmind"
     2  import { Context } from "."
     3  import { Assignment, Course, Enrollment, Enrollment_UserStatus, Group, Group_GroupStatus, Submission, User } from "../../proto/qf/types_pb"
     4  import { Color, ConnStatus, getNumApproved, getSubmissionsScore, isApproved, isManuallyGraded, isPending, isPendingGroup, isTeacher, SubmissionsForCourse, SubmissionSort } from "../Helpers"
     5  
     6  export interface CourseGroup {
     7      courseID: bigint
     8      // User IDs of all members of the group
     9      users: bigint[]
    10      name: string
    11  }
    12  
    13  export interface Alert {
    14      id: number
    15      text: string
    16      color: Color
    17      // The delay in milliseconds before the alert is removed
    18      delay?: number
    19  }
    20  
    21  interface GroupOrEnrollment {
    22      ID: bigint,
    23      name?: string,
    24      user?: User,
    25      status?: Enrollment_UserStatus | Group_GroupStatus
    26  }
    27  
    28  type EnrollmentsByCourse = { [CourseID: string]: Enrollment }
    29  export type SubmissionOwner = { type: "ENROLLMENT" | "GROUP", id: bigint }
    30  export type AssignmentsMap = { [key: string]: boolean }
    31  export type State = {
    32  
    33      /***************************************************************************
    34      *                   Data relating to the current user
    35      ***************************************************************************/
    36  
    37      /* This is the user that is currently logged in */
    38      self: User,
    39  
    40      /* Indicates if the user has valid data */
    41      // derived from self
    42      isValid: boolean,
    43  
    44      /* Indicates if the user is logged in */
    45      // derived from self
    46      isLoggedIn: boolean,
    47  
    48      /* Contains all the courses the user is enrolled in */
    49      enrollments: Enrollment[],
    50  
    51      /* Contains all the courses the user is enrolled in, indexed by course ID */
    52      // derived from enrollments
    53      enrollmentsByCourseID: { [courseID: string]: Enrollment },
    54  
    55      /* Contains all the groups the user is a member of, indexed by course ID */
    56      userGroup: { [courseID: string]: Group },
    57  
    58      /* Contains all submissions for the user, indexed by course ID */
    59      // The individual submissions for a given course are indexed by assignment order - 1
    60      submissions: { [courseID: string]: Submission[] },
    61  
    62      /* Current enrollment status of the user for a given course */
    63      status: { [courseID: string]: Enrollment_UserStatus }
    64  
    65      /* Indicates if the user is a teacher of the current course */
    66      // derived from enrollmentsByCourseID
    67      isTeacher: boolean
    68  
    69      /* Indicates if the user is the course creator of the current course */
    70      // derived from courses
    71      isCourseCreator: boolean
    72  
    73      /* Contains links to all repositories for a given course */
    74      // Individual repository links are accessed by Repository.Type
    75      repositories: { [courseid: string]: { [repo: string]: string } },
    76  
    77      /***************************************************************************
    78      *                              Public data
    79      ***************************************************************************/
    80  
    81      /* Contains all users of a given course */
    82      // Requires the user to be admin to get from backend
    83      users: { [userID: string]: User },
    84  
    85      /* Contains all courses */
    86      courses: Course[],
    87  
    88      /* Contains all assignments for a given course */
    89      assignments: { [courseID: string]: Assignment[] },
    90  
    91  
    92      /***************************************************************************
    93      *                         Course Specific Data
    94       ***************************************************************************/
    95  
    96      /* Contains all submissions for a given course */
    97  
    98  
    99      /** Contains all members of the current course.
   100       *  Derived from either enrollments or groups based on groupView.
   101       *  The members are filtered and sorted based on the current
   102       *  values of sortSubmissionsBy, sortAscending, and submissionFilters */
   103      courseMembers: Enrollment[] | Group[],
   104  
   105      /* Course teachers, indexed by user ID */
   106      /* Derived from enrollments for selected course */
   107      courseTeachers: { [userID: string]: User }
   108  
   109      /* Contains all enrollments for a given course */
   110      courseEnrollments: { [courseID: string]: Enrollment[] },
   111  
   112      /* Contains all groups for a given course */
   113      groups: { [courseID: string]: Group[] },
   114  
   115      /* Number of enrolled users */
   116      // derived from courseEnrollments
   117      numEnrolled: number,
   118  
   119      /* Contains all enrollments where the enrollment status is pending */
   120      // derived from courseEnrollments
   121      pendingEnrollments: Enrollment[],
   122  
   123      /* Contains all groups where the group status is pending */
   124      // derived from groups
   125      pendingGroups: Group[],
   126  
   127      /* Contains all users with admins sorted first */
   128      allUsers: User[],
   129  
   130      /* Indicates if the course has any assignment that is manually graded */
   131      isCourseManuallyGraded: boolean
   132  
   133  
   134      /***************************************************************************
   135       *                             Frontend Activity State
   136       ***************************************************************************/
   137  
   138      /* Indicates if the state is loading */
   139      isLoading: boolean,
   140  
   141      /* The current course ID */
   142      activeCourse: bigint,
   143  
   144      /* The currently selected assignment ID */
   145      selectedAssignmentID: number,
   146  
   147      /* The current assignment */
   148      selectedAssignment: Assignment | null,
   149  
   150      /* Contains a group in creation */
   151      courseGroup: CourseGroup,
   152  
   153      /* Contains alerts to be displayed to the user */
   154      alerts: Alert[],
   155  
   156      /* Current search query */
   157      query: string,
   158  
   159      /* Currently selected enrollment */
   160      selectedEnrollment: Enrollment | null,
   161  
   162      /* Currently selected submission */
   163      selectedSubmission: Submission | null,
   164  
   165      /* The value to sort submissions by */
   166      sortSubmissionsBy: SubmissionSort,
   167  
   168      /* Whether to sort by ascending or descending */
   169      sortAscending: boolean,
   170  
   171      /* Submission filters */
   172      submissionFilters: string[],
   173  
   174      /* Determine if all submissions should be displayed, or only group submissions */
   175      groupView: boolean,
   176      showFavorites: boolean,
   177  
   178  
   179      /* Currently selected group */
   180      /* Contains either an existing group to edit, or a new group to create */
   181      activeGroup: Group | null,
   182  
   183      hasGroup: (courseID: string) => boolean,
   184  
   185      connectionStatus: ConnStatus,
   186  
   187      // ID of owner of the current submission
   188      // Must be either an enrollment ID or a group ID
   189      submissionOwner: SubmissionOwner,
   190  
   191      submissionsForCourse: SubmissionsForCourse,
   192      isManuallyGraded: (submission: Submission) => boolean,
   193      loadedCourse: { [courseID: string]: boolean },
   194      getAssignmentsMap: (courseID: bigint) => AssignmentsMap,
   195  }
   196  
   197  
   198  /* Initial State */
   199  /* To add to state, extend the State type and initialize the variable below */
   200  export const state: State = {
   201      self: new User(),
   202      isLoggedIn: derived(({ self }: State) => {
   203          return Number(self.ID) !== 0
   204      }),
   205  
   206      isValid: derived(({ self }: State) => {
   207          return self.Name.length > 0 && self.StudentID.length > 0 && self.Email.length > 0
   208      }),
   209  
   210      enrollments: [],
   211      enrollmentsByCourseID: derived(({ enrollments }: State) => {
   212          const enrollmentsByCourseID: EnrollmentsByCourse = {}
   213          for (const enrollment of enrollments) {
   214              enrollmentsByCourseID[enrollment.courseID.toString()] = enrollment
   215          }
   216          return enrollmentsByCourseID
   217      }),
   218      submissions: {},
   219      userGroup: {},
   220  
   221      isTeacher: derived(({ enrollmentsByCourseID, activeCourse }: State) => {
   222          if (activeCourse > 0 && enrollmentsByCourseID[activeCourse.toString()]) {
   223              return isTeacher(enrollmentsByCourseID[activeCourse.toString()])
   224          }
   225          return false
   226      }),
   227      isCourseCreator: derived(({ courses, activeCourse, self }: State) => {
   228          const course = courses.find(c => c.ID === activeCourse)
   229          if (course && course.courseCreatorID === self.ID) {
   230              return true
   231          }
   232          return false
   233      }),
   234      status: {},
   235  
   236      users: {},
   237      allUsers: [],
   238      courses: [],
   239      courseTeachers: derived(({ courseEnrollments, activeCourse }: State) => {
   240          if (!activeCourse || !courseEnrollments[activeCourse.toString()]) {
   241              return {}
   242          }
   243          const teachersMap: { [userID: string]: User } = {}
   244          courseEnrollments[activeCourse.toString()].forEach(enrollment => {
   245              if (isTeacher(enrollment) && enrollment.user) {
   246                  teachersMap[enrollment.userID.toString()] = enrollment.user
   247              }
   248          })
   249          return teachersMap
   250      }),
   251      courseMembers: derived(({
   252          activeCourse, groupView, submissionsForCourse, assignments, groups,
   253          courseEnrollments, submissionFilters, sortAscending, sortSubmissionsBy
   254      }: State, {
   255          review: { assignmentID }
   256      }: Context["state"]) => {
   257          // Filter and sort course members based on the current state
   258          if (!activeCourse) {
   259              return []
   260          }
   261          const submissions = groupView
   262              ? submissionsForCourse.groupSubmissions
   263              : submissionsForCourse.userSubmissions
   264  
   265          if (submissions.size === 0) {
   266              return []
   267          }
   268  
   269          // If a specific assignment is selected, filter by that assignment
   270          let numAssignments = 0
   271          if (assignmentID > 0) {
   272              numAssignments = 1
   273          } else if (groupView) {
   274              numAssignments = assignments[activeCourse.toString()]?.filter(a => a.isGroupLab).length || 0
   275          } else {
   276              numAssignments = assignments[activeCourse.toString()]?.length ?? 0
   277          }
   278  
   279          let filtered: GroupOrEnrollment[] = groupView ? groups[activeCourse.toString()] : courseEnrollments[activeCourse.toString()] ?? []
   280          for (const filter of submissionFilters) {
   281              switch (filter) {
   282                  case "teachers":
   283                      filtered = filtered.filter(el => {
   284                          return el.status !== Enrollment_UserStatus.TEACHER
   285                      })
   286                      break
   287                  case "approved":
   288                      // approved filters all entries where all assignments have been approved
   289                      filtered = filtered.filter(el => {
   290                          if (assignmentID > 0) {
   291                              // If a specific assignment is selected, filter by that assignment
   292                              const sub = submissions.get(el.ID)?.submissions?.find(s => s.AssignmentID === assignmentID)
   293                              return sub !== undefined && !isApproved(sub)
   294                          }
   295                          const numApproved = submissions.get(el.ID)?.submissions?.reduce((acc, cur) => {
   296                              return acc + ((cur &&
   297                                  isApproved(cur)) ? 1 : 0)
   298                          }, 0) ?? 0
   299                          return numApproved < numAssignments
   300                      })
   301                      break
   302                  case "released":
   303                      filtered = filtered.filter(el => {
   304                          if (assignmentID > 0) {
   305                              const sub = submissions.get(el.ID)?.submissions?.find(s => s.AssignmentID === assignmentID)
   306                              return sub !== undefined && !sub.released
   307                          }
   308                          const hasReleased = submissions.get(el.ID)?.submissions.some(sub => sub.released)
   309                          return !hasReleased
   310                      })
   311                      break
   312                  default:
   313                      break
   314              }
   315          }
   316  
   317          const sortOrder = sortAscending ? -1 : 1
   318          const sortedSubmissions = Object.values(filtered).sort((a, b) => { // skipcq: JS-0044
   319              let subA: Submission | undefined
   320              let subB: Submission | undefined
   321              if (assignmentID > 0) {
   322                  // If a specific assignment is selected, sort by that assignment
   323                  subA = submissions.get(a.ID)?.submissions.find(sub => sub.AssignmentID === assignmentID)
   324                  subB = submissions.get(b.ID)?.submissions.find(sub => sub.AssignmentID === assignmentID)
   325              }
   326  
   327              const subsA = submissions.get(a.ID)?.submissions
   328              const subsB = submissions.get(b.ID)?.submissions
   329  
   330              switch (sortSubmissionsBy) {
   331                  case SubmissionSort.ID: {
   332                      if (a instanceof Enrollment && b instanceof Enrollment) {
   333                          return sortOrder * (Number(a.userID) - Number(b.userID))
   334                      } else {
   335                          return sortOrder * (Number(a.ID) - Number(b.ID))
   336                      }
   337                  }
   338                  case SubmissionSort.Score: {
   339                      if (assignmentID > 0) {
   340                          const sA = subA?.score
   341                          const sB = subB?.score
   342                          if (sA !== undefined && sB !== undefined) {
   343                              return sortOrder * (sB - sA)
   344                          } else if (sA !== undefined) {
   345                              return -sortOrder
   346                          }
   347                          return sortOrder
   348                      }
   349                      const aSubs = subsA ? getSubmissionsScore(subsA) : 0
   350                      const bSubs = subsB ? getSubmissionsScore(subsB) : 0
   351                      return sortOrder * (aSubs - bSubs)
   352                  }
   353                  case SubmissionSort.Approved: {
   354                      if (assignmentID > 0) {
   355                          const sA = subA && isApproved(subA) ? 1 : 0
   356                          const sB = subB && isApproved(subB) ? 1 : 0
   357                          return sortOrder * (sA - sB)
   358                      }
   359                      const aApproved = subsA ? getNumApproved(subsA) : 0
   360                      const bApproved = subsB ? getNumApproved(subsB) : 0
   361                      return sortOrder * (aApproved - bApproved)
   362                  }
   363                  case SubmissionSort.Name: {
   364                      const nameA = groupView ? a.name ?? "" : a.user?.Name ?? ""
   365                      const nameB = groupView ? b.name ?? "" : b.user?.Name ?? ""
   366                      return sortOrder * (nameA.localeCompare(nameB))
   367                  }
   368                  default:
   369                      return 0
   370              }
   371          })
   372          return sortedSubmissions as Group[] | Enrollment[]
   373      }),
   374      selectedEnrollment: null,
   375      selectedSubmission: null,
   376      selectedAssignment: derived(({ activeCourse, selectedSubmission, assignments }: State) => {
   377          return assignments[activeCourse.toString()]?.find(a => a.ID === selectedSubmission?.AssignmentID) ?? null
   378      }),
   379      assignments: {},
   380      repositories: {},
   381  
   382      courseGroup: { courseID: 0n, users: [], name: "" },
   383      alerts: [],
   384      isLoading: true,
   385      activeCourse: BigInt(-1),
   386      selectedAssignmentID: -1,
   387      courseEnrollments: {},
   388      groups: {},
   389      pendingGroups: derived(({ activeCourse, groups }: State) => {
   390          if (activeCourse > 0 && groups[activeCourse.toString()]) {
   391              return groups[activeCourse.toString()]?.filter(group => isPendingGroup(group))
   392          }
   393          return []
   394      }),
   395      pendingEnrollments: derived(({ activeCourse, courseEnrollments }: State) => {
   396          if (activeCourse > 0 && courseEnrollments[activeCourse.toString()]) {
   397              return courseEnrollments[activeCourse.toString()].filter(enrollment => isPending(enrollment))
   398          }
   399          return []
   400      }),
   401      numEnrolled: derived(({ activeCourse, courseEnrollments }: State) => {
   402          if (activeCourse > 0 && courseEnrollments[activeCourse.toString()]) {
   403              return courseEnrollments[activeCourse.toString()]?.filter(enrollment => !isPending(enrollment)).length
   404          }
   405          return 0
   406      }),
   407      isCourseManuallyGraded: derived(({ activeCourse, assignments }: State) => {
   408          if (activeCourse > 0 && assignments[activeCourse.toString()]) {
   409              return assignments[activeCourse.toString()].some(a => isManuallyGraded(a))
   410          }
   411          return false
   412      }),
   413      query: "",
   414      sortSubmissionsBy: SubmissionSort.Approved,
   415      sortAscending: true,
   416      submissionFilters: [],
   417      groupView: false,
   418      activeGroup: null,
   419      hasGroup: derived(({ userGroup }: State) => courseID => {
   420          return userGroup[courseID] !== undefined
   421      }),
   422      showFavorites: false,
   423  
   424      connectionStatus: ConnStatus.DISCONNECTED,
   425      isManuallyGraded: derived(({ activeCourse, assignments }: State) => submission => {
   426          const assignment = assignments[activeCourse.toString()]?.find(a => a.ID === submission.AssignmentID)
   427          return assignment ? assignment.reviewers > 0 : false
   428      }),
   429  
   430      getAssignmentsMap: derived(({ assignments }: State, { review: { assignmentID } }: Context["state"]) => courseID => {
   431          const asgmts = assignments[courseID.toString()]?.filter(assignment => (assignmentID < 0) || assignment.ID === assignmentID) ?? []
   432          const assignmentsMap: AssignmentsMap = {}
   433          asgmts.forEach(assignment => {
   434              assignmentsMap[assignment.ID.toString()] = assignment.isGroupLab
   435          })
   436          return assignmentsMap
   437      }),
   438  
   439      submissionOwner: { type: "ENROLLMENT", id: 0n },
   440      loadedCourse: {},
   441      submissionsForCourse: new SubmissionsForCourse()
   442  }