github.com/quickfeed/quickfeed@v0.0.0-20240507093252-ed8ca812a09c/public/src/Helpers.ts (about)

     1  import { useParams } from "react-router"
     2  import { Assignment, Course, Enrollment, GradingBenchmark, Group, Review, Submission, User, Enrollment_UserStatus, Group_GroupStatus, Enrollment_DisplayState, Submission_Status, Submissions } from "../proto/qf/types_pb"
     3  import { Score } from "../proto/kit/score/score_pb"
     4  import { CourseGroup, SubmissionOwner } from "./overmind/state"
     5  import { Timestamp } from "@bufbuild/protobuf"
     6  import { CourseSubmissions } from "../proto/qf/requests_pb"
     7  
     8  export enum Color {
     9      RED = "danger",
    10      BLUE = "primary",
    11      GREEN = "success",
    12      YELLOW = "warning",
    13      GRAY = "secondary",
    14      WHITE = "light",
    15      BLACK = "dark",
    16  }
    17  
    18  export enum Sort {
    19      NAME,
    20      STATUS,
    21      ID
    22  }
    23  
    24  // ConnStatus indicates the status of streaming connection to the server
    25  export enum ConnStatus {
    26      CONNECTED,
    27      DISCONNECTED,
    28      RECONNECTING,
    29  }
    30  
    31  const months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
    32  
    33  /** Returns a string with a prettier format for a deadline */
    34  export const getFormattedTime = (timestamp: Timestamp | undefined): string => {
    35      if (!timestamp) {
    36          return "N/A"
    37      }
    38      const deadline = timestamp.toDate()
    39      const minutes = deadline.getMinutes()
    40      const zero = minutes < 10 ? "0" : ""
    41      return `${deadline.getDate()} ${months[deadline.getMonth()]} ${deadline.getFullYear()} ${deadline.getHours()}:${zero}${minutes}`
    42  }
    43  
    44  export interface Deadline {
    45      className: string,
    46      message: string,
    47      daysUntil: number,
    48  }
    49  
    50  /**
    51   * Utility function for LandingPageTable to format the output string and class/css
    52   * depending on how far into the future the deadline is.
    53   *
    54   * layoutTime = "2021-03-20T23:59:00"
    55   */
    56  export const timeFormatter = (deadline: Timestamp): Deadline => {
    57      const timeToDeadline = deadline.toDate().getTime()
    58      const days = Math.floor(timeToDeadline / (1000 * 3600 * 24))
    59      const hours = Math.floor(timeToDeadline / (1000 * 3600))
    60      const minutes = Math.floor((timeToDeadline % (1000 * 3600)) / (1000 * 60))
    61  
    62      if (timeToDeadline < 0) {
    63          const daysSince = -days
    64          const hoursSince = -hours
    65          return { className: "table-danger", message: `Expired ${daysSince > 0 ? `${daysSince} days ago` : `${hoursSince} hours ago`}`, daysUntil: 0 }
    66      }
    67  
    68      if (days === 0) {
    69          return { className: "table-danger", message: `${hours} hours and ${minutes} minutes to deadline!`, daysUntil: 0 }
    70      }
    71  
    72      if (days < 3) {
    73          return { className: "table-warning", message: `${days} day${days === 1 ? " " : "s"} to deadline`, daysUntil: days }
    74      }
    75  
    76      if (days < 14) {
    77          return { className: "table-primary", message: `${days} days`, daysUntil: days }
    78      }
    79  
    80      return { className: "", message: "", daysUntil: days }
    81  }
    82  
    83  // Used for displaying enrollment status
    84  export const EnrollmentStatus = {
    85      0: "None",
    86      1: "Pending",
    87      2: "Student",
    88      3: "Teacher",
    89  }
    90  
    91  // TODO: Could be computed on the backend (https://github.com/quickfeed/quickfeed/issues/420)
    92  /** getPassedTestCount returns a string with the number of passed tests and the total number of tests */
    93  export const getPassedTestsCount = (score: Score[]): string => {
    94      let totalTests = 0
    95      let passedTests = 0
    96      score.forEach(s => {
    97          if (s.Score === s.MaxScore) {
    98              passedTests++
    99          }
   100          totalTests++
   101      })
   102      if (totalTests === 0) {
   103          return ""
   104      }
   105      return `${passedTests}/${totalTests}`
   106  }
   107  
   108  /** hasEnrollment returns true if any of the provided has been approved */
   109  export const hasEnrollment = (enrollments: Enrollment[]): boolean => {
   110      return enrollments.some(enrollment => enrollment.status > Enrollment_UserStatus.PENDING)
   111  }
   112  
   113  export const isStudent = (enrollment: Enrollment): boolean => { return hasStudent(enrollment.status) }
   114  export const isTeacher = (enrollment: Enrollment): boolean => { return hasTeacher(enrollment.status) }
   115  export const isPending = (enrollment: Enrollment): boolean => { return hasPending(enrollment.status) }
   116  
   117  export const isPendingGroup = (group: Group): boolean => { return group.status === Group_GroupStatus.PENDING }
   118  export const isApprovedGroup = (group: Group): boolean => { return group.status === Group_GroupStatus.APPROVED }
   119  
   120  /** isEnrolled returns true if the user is enrolled in the course, and is no longer pending. */
   121  export const isEnrolled = (enrollment: Enrollment): boolean => { return enrollment.status >= Enrollment_UserStatus.STUDENT }
   122  
   123  export const hasNone = (status: Enrollment_UserStatus): boolean => { return status === Enrollment_UserStatus.NONE }
   124  export const hasPending = (status: Enrollment_UserStatus): boolean => { return status === Enrollment_UserStatus.PENDING }
   125  export const hasStudent = (status: Enrollment_UserStatus): boolean => { return status === Enrollment_UserStatus.STUDENT }
   126  export const hasTeacher = (status: Enrollment_UserStatus): boolean => { return status === Enrollment_UserStatus.TEACHER }
   127  
   128  /** hasEnrolled returns true if user has enrolled in course, or is pending approval. */
   129  export const hasEnrolled = (status: Enrollment_UserStatus): boolean => { return status >= Enrollment_UserStatus.PENDING }
   130  
   131  export const isVisible = (enrollment: Enrollment): boolean => { return enrollment.state === Enrollment_DisplayState.VISIBLE }
   132  export const isFavorite = (enrollment: Enrollment): boolean => { return enrollment.state === Enrollment_DisplayState.FAVORITE }
   133  
   134  export const isAuthor = (user: User, review: Review): boolean => { return user.ID === review.ReviewerID }
   135  
   136  export const isManuallyGraded = (assignment: Assignment): boolean => {
   137      return assignment.reviewers > 0
   138  }
   139  
   140  export const isApproved = (submission: Submission): boolean => { return submission.status === Submission_Status.APPROVED }
   141  export const isRevision = (submission: Submission): boolean => { return submission.status === Submission_Status.REVISION }
   142  export const isRejected = (submission: Submission): boolean => { return submission.status === Submission_Status.REJECTED }
   143  
   144  export const hasReviews = (submission: Submission): boolean => { return submission.reviews.length > 0 }
   145  export const hasBenchmarks = (obj: Review | Assignment): boolean => { return obj.gradingBenchmarks.length > 0 }
   146  export const hasCriteria = (benchmark: GradingBenchmark): boolean => { return benchmark.criteria.length > 0 }
   147  export const hasEnrollments = (obj: Group): boolean => { return obj.enrollments.length > 0 }
   148  
   149  /** getCourseID returns the course ID determined by the current route */
   150  export const getCourseID = (): bigint => {
   151      const route = useParams<{ id?: string }>()
   152      return route.id ? BigInt(route.id) : BigInt(0)
   153  }
   154  
   155  export const isHidden = (value: string, query: string): boolean => {
   156      return !value.toLowerCase().includes(query) && query.length > 0
   157  }
   158  
   159  /** getSubmissionsScore calculates the total score of all submissions */
   160  export const getSubmissionsScore = (submissions: Submission[]): number => {
   161      let score = 0
   162      submissions.forEach(submission => {
   163          score += submission.score
   164      })
   165      return score
   166  }
   167  
   168  /** getNumApproved returns the number of approved submissions */
   169  export const getNumApproved = (submissions: Submission[]): number => {
   170      let num = 0
   171      submissions.forEach(submission => {
   172          if (isApproved(submission)) {
   173              num++
   174          }
   175      })
   176      return num
   177  }
   178  
   179  export const EnrollmentStatusBadge = {
   180      0: "",
   181      1: "badge badge-info",
   182      2: "badge badge-primary",
   183      3: "badge badge-danger",
   184  }
   185  
   186  /** SubmissionStatus returns a string with the status of the submission, given the status number, ex. Submission.Status.APPROVED -> "Approved" */
   187  export const SubmissionStatus = {
   188      0: "None",
   189      1: "Approved",
   190      2: "Rejected",
   191      3: "Revision",
   192  }
   193  
   194  // TODO: This could possibly be done on the server. Would need to add a field to the proto submission/score model.
   195  /** assignmentStatusText returns a string that is used to tell the user what the status of their submission is */
   196  export const assignmentStatusText = (assignment: Assignment, submission: Submission): string => {
   197      // If the submission is not graded, return a descriptive text
   198      if (submission.status === Submission_Status.NONE) {
   199          // If the assignment requires manual approval, and the score is above the threshold, return Await Approval
   200          if (!assignment.autoApprove && submission.score >= assignment.scoreLimit) {
   201              return "Awaiting approval"
   202          }
   203          if (submission.score < assignment.scoreLimit) {
   204              return `Need ${assignment.scoreLimit}% score for approval`
   205          }
   206      }
   207      // If the submission is graded, return the status
   208      return SubmissionStatus[submission.status]
   209  }
   210  
   211  // Helper functions for default values for new courses
   212  export const defaultTag = (date: Date): string => {
   213      return date.getMonth() >= 10 || date.getMonth() < 4 ? "Spring" : "Fall"
   214  }
   215  
   216  export const defaultYear = (date: Date): number => {
   217      return (date.getMonth() <= 11 && date.getDate() <= 31) && date.getMonth() > 10 ? (date.getFullYear() + 1) : date.getFullYear()
   218  }
   219  
   220  export const userLink = (user: User): string => {
   221      return `https://github.com/${user.Login}`
   222  }
   223  
   224  export const userRepoLink = (user: User, course?: Course): string => {
   225      if (!course) {
   226          return userLink(user)
   227      }
   228      return `https://github.com/${course.ScmOrganizationName}/${user.Login}-labs`
   229  }
   230  
   231  export const groupRepoLink = (group: Group, course?: Course): string => {
   232      if (!course) {
   233          return ""
   234      }
   235      return `https://github.com/${course.ScmOrganizationName}/${group.name}`
   236  }
   237  
   238  export const getSubmissionCellColor = (submission: Submission): string => {
   239      if (isApproved(submission)) {
   240          return "result-approved"
   241      }
   242      if (isRevision(submission)) {
   243          return "result-revision"
   244      }
   245      if (isRejected(submission)) {
   246          return "result-rejected"
   247      }
   248      return "clickable"
   249  }
   250  
   251  // pattern for group name validation. Only letters, numbers, underscores and dashes are allowed.
   252  const pattern = /^[a-zA-Z0-9_-]+$/
   253  export const validateGroup = (group: CourseGroup): { valid: boolean, message: string } => {
   254      if (group.name.length === 0) {
   255          return { valid: false, message: "Group name cannot be empty" }
   256      }
   257      if (group.name.length > 20) {
   258          return { valid: false, message: "Group name cannot be longer than 20 characters" }
   259      }
   260      if (group.name.includes(" ")) {
   261          // Explicitly warn the user that spaces are not allowed.
   262          // Common mistake is to use spaces instead of underscores.
   263          return { valid: false, message: "Group name cannot contain spaces" }
   264      }
   265      if (!pattern.test(group.name)) {
   266          return { valid: false, message: "Group name can only contain letters (a-z, A-Z), numbers, underscores and dashes" }
   267      }
   268      if (group.users.length === 0) {
   269          return { valid: false, message: "Group must have at least one user" }
   270      }
   271      return { valid: true, message: "" }
   272  }
   273  
   274  // newID returns a new auto-incrementing ID
   275  // Can be used to generate IDs for client-only objects
   276  // such as the Alert object
   277  export const newID = (() => {
   278      let id: number = 0
   279      return () => {
   280          return id++
   281      }
   282  })()
   283  
   284  /* Use this function to simulate a delay in the loading of data */
   285  /* Used in development to simulate a slow network connection */
   286  export const delay = (ms: number) => {
   287      return new Promise(resolve => setTimeout(resolve, ms))
   288  }
   289  
   290  
   291  export enum EnrollmentSort {
   292      Name,
   293      Status,
   294      Email,
   295      Activity,
   296      Slipdays,
   297      Approved,
   298      StudentID
   299  }
   300  
   301  export enum SubmissionSort {
   302      ID,
   303      Name,
   304      Status,
   305      Score,
   306      Approved
   307  }
   308  
   309  /** Sorting */
   310  const enrollmentCompare = (a: Enrollment, b: Enrollment, sortBy: EnrollmentSort, descending: boolean): number => {
   311      const sortOrder = descending ? -1 : 1
   312      switch (sortBy) {
   313          case EnrollmentSort.Name: {
   314              const nameA = a.user?.Name ?? ""
   315              const nameB = b.user?.Name ?? ""
   316              return sortOrder * (nameA.localeCompare(nameB))
   317          }
   318          case EnrollmentSort.Status:
   319              return sortOrder * (a.status - b.status)
   320          case EnrollmentSort.Email: {
   321              const emailA = a.user?.Email ?? ""
   322              const emailB = b.user?.Email ?? ""
   323              return sortOrder * (emailA.localeCompare(emailB))
   324          }
   325          case EnrollmentSort.Activity:
   326              if (a.lastActivityDate && b.lastActivityDate) {
   327                  return sortOrder * (a.lastActivityDate.toDate().getTime() - b.lastActivityDate.toDate().getTime())
   328              }
   329              return 0
   330          case EnrollmentSort.Slipdays:
   331              return sortOrder * (a.slipDaysRemaining - b.slipDaysRemaining)
   332          case EnrollmentSort.Approved:
   333              return sortOrder * Number(a.totalApproved - b.totalApproved)
   334          case EnrollmentSort.StudentID: {
   335              const aID = a.user?.ID ?? BigInt(0)
   336              const bID = b.user?.ID ?? BigInt(0)
   337              return sortOrder * Number(aID - bID)
   338          }
   339          default:
   340              return 0
   341      }
   342  }
   343  
   344  export const sortEnrollments = (enrollments: Enrollment[], sortBy: EnrollmentSort, descending: boolean): Enrollment[] => {
   345      return enrollments.sort((a, b) => {
   346          return enrollmentCompare(a, b, sortBy, descending)
   347      })
   348  }
   349  
   350  export class SubmissionsForCourse {
   351      userSubmissions: Map<bigint, Submissions> = new Map()
   352      groupSubmissions: Map<bigint, Submissions> = new Map()
   353  
   354      /** ForUser returns user submissions for the given enrollment */
   355      ForUser(enrollment: Enrollment): Submission[] {
   356          return this.userSubmissions.get(enrollment.ID)?.submissions ?? []
   357      }
   358  
   359      /** ForGroup returns group submissions for the given group or enrollment */
   360      ForGroup(group: Group | Enrollment): Submission[] {
   361          if (group instanceof Group) {
   362              return this.groupSubmissions.get(group.ID)?.submissions ?? []
   363          }
   364          return this.groupSubmissions.get(group.groupID)?.submissions ?? []
   365      }
   366  
   367      /** ForOwner returns all submissions related to the passed in owner.
   368       * This is usually the selected group or user. */
   369      ForOwner(owner: SubmissionOwner): Submission[] {
   370          if (owner.type === "GROUP") {
   371              return this.groupSubmissions.get(owner.id)?.submissions ?? []
   372          }
   373          return this.userSubmissions.get(owner.id)?.submissions ?? []
   374      }
   375  
   376      ByID(id: bigint): Submission | undefined {
   377          for (const submissions of this.userSubmissions.values()) {
   378              const submission = submissions.submissions.find(s => s.ID === id)
   379              if (submission) {
   380                  return submission
   381              }
   382          }
   383          for (const submissions of this.groupSubmissions.values()) {
   384              const submission = submissions.submissions.find(s => s.ID === id)
   385              if (submission) {
   386                  return submission
   387              }
   388          }
   389          return undefined
   390      }
   391  
   392      OwnerByID(id: bigint): SubmissionOwner | undefined {
   393          for (const [key, submissions] of this.userSubmissions.entries()) {
   394              const submission = submissions.submissions.find(s => s.ID === id)
   395              if (submission) {
   396                  if (submission.groupID > 0) {
   397                      return { type: "GROUP", id: submission.groupID }
   398                  }
   399                  return { type: "ENROLLMENT", id: key }
   400              }
   401          }
   402          for (const [key, submissions] of this.groupSubmissions.entries()) {
   403              const submission = submissions.submissions.find(s => s.ID === id)
   404              if (submission) {
   405                  return { type: "GROUP", id: key }
   406              }
   407          }
   408          return undefined
   409      }
   410  
   411      update(owner: SubmissionOwner, submission: Submission) {
   412          const submissions = this.ForOwner(owner)
   413          const index = submissions.findIndex(s => s.AssignmentID === submission.AssignmentID)
   414          if (index === -1) {
   415              return
   416          } else {
   417              submissions[index] = submission
   418          }
   419          if (owner.type === "GROUP") {
   420              const clone = new Map(this.groupSubmissions)
   421              this.groupSubmissions = clone.set(owner.id, new Submissions({ submissions }))
   422          } else {
   423              const clone = new Map(this.userSubmissions)
   424              this.userSubmissions = clone.set(owner.id, new Submissions({ submissions }))
   425          }
   426      }
   427  
   428      setSubmissions(type: "USER" | "GROUP", submissions: CourseSubmissions) {
   429          const map = new Map<bigint, Submissions>()
   430          for (const [key, value] of Object.entries(submissions.submissions)) {
   431              map.set(BigInt(key), value)
   432          }
   433          switch (type) {
   434              case "USER":
   435                  this.userSubmissions = map
   436                  break
   437              case "GROUP":
   438                  this.groupSubmissions = map
   439                  break
   440          }
   441      }
   442  }