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 }