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 }