diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 3bc4b5e713bc41db755ae3d952d72732cf7890d7..d0f787c6fafd11885a85a547e84156e0ed0652be 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -20,6 +20,7 @@ import { ErrorEffects } from '@app/degree-planner/store/effects/error.effects'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { CourseDetailsDialogComponent } from './degree-planner/dialogs/course-details-dialog/course-details-dialog.component'; import { FeedbackDialogComponent } from './degree-planner/dialogs/feedback-dialog/feedback-dialog.component'; +import { CreditOverloadDialogComponent } from './degree-planner/dialogs/credit-overload-dialog/credit-overload-dialog.component'; @NgModule({ imports: [ StoreModule.forRoot({ @@ -44,7 +45,11 @@ import { FeedbackDialogComponent } from './degree-planner/dialogs/feedback-dialo }), ], declarations: [AppComponent, HeaderComponent], - entryComponents: [CourseDetailsDialogComponent, FeedbackDialogComponent], + entryComponents: [ + CourseDetailsDialogComponent, + FeedbackDialogComponent, + CreditOverloadDialogComponent, + ], providers: [], bootstrap: [AppComponent], schemas: [CUSTOM_ELEMENTS_SCHEMA], diff --git a/src/app/core/models/course.ts b/src/app/core/models/course.ts index 1a642843cf943bc44d14e4dd69965fe0ff9efd4b..3dc6c319efdb913a5161c4e9a2ee343353aefe5e 100644 --- a/src/app/core/models/course.ts +++ b/src/app/core/models/course.ts @@ -7,8 +7,8 @@ export interface CourseBase { subjectCode: string; catalogNumber: string; credits?: number; - creditMin: number; - creditMax: number; + creditMin?: number; + creditMax?: number; grade?: any; classNumber: string | null; courseOrder: number; diff --git a/src/app/core/models/student-info.ts b/src/app/core/models/student-info.ts new file mode 100644 index 0000000000000000000000000000000000000000..992d5ea7a0c28910c50da32866a2d04f46dc8c37 --- /dev/null +++ b/src/app/core/models/student-info.ts @@ -0,0 +1,24 @@ +export interface StudentInfo { + personAttributes: { + emplid: string; + pvi: string; + name: { + first: string; + last: string; + }; + email: string; + netid: string; + }; + ferpaAttributes: { + name: boolean; + email: boolean; + }; + primaryCareer: null | { + careerCode: string; + programName: string; + enrollmentStatusCode: string; + academicLevelDescription: string; + academicLoadDescription: string; + termCode: string; + }; +} diff --git a/src/app/degree-planner/dialogs/credit-overload-dialog/credit-overload-dialog.component.html b/src/app/degree-planner/dialogs/credit-overload-dialog/credit-overload-dialog.component.html new file mode 100644 index 0000000000000000000000000000000000000000..8e485cbe27089b131da5250198086d7c5ee4da5f --- /dev/null +++ b/src/app/degree-planner/dialogs/credit-overload-dialog/credit-overload-dialog.component.html @@ -0,0 +1,23 @@ +<h1 mat-dialog-title style="margin:1rem">Warning: Credit Overload</h1> +<div mat-dialog-content class="mat-typography" style="padding:0;margin:1rem"> + <p style="max-width:500px;margin:0"> + Undergraduate students who wish to take more than {{ maxCredits }} during + the {{ termName }} semester must recieve approval from their School or College. + </p> +</div> +<div mat-dialog-actions style="padding:0;margin:1rem"> + <a + mat-button + target="_blank" + href="https://registrar.wisc.edu/enrollment-related-info/"> + More information + </a> + <button + mat-raised-button + mat-dialog-close + cdkFocusInitial + color="primary" + aria-label="Dismiss dialog"> + Dismiss + </button> +</div> diff --git a/src/app/degree-planner/dialogs/credit-overload-dialog/credit-overload-dialog.component.ts b/src/app/degree-planner/dialogs/credit-overload-dialog/credit-overload-dialog.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..f39441acc5dfd6f8f7810f897304bc36b61ad0a4 --- /dev/null +++ b/src/app/degree-planner/dialogs/credit-overload-dialog/credit-overload-dialog.component.ts @@ -0,0 +1,17 @@ +import { Component, Input, Inject } from '@angular/core'; +import { TermCode } from '@app/core/models/termcode'; +import { MAT_DIALOG_DATA } from '@angular/material'; + +@Component({ + selector: 'cse-credit-overload-dialog', + templateUrl: './credit-overload-dialog.component.html', +}) +export class CreditOverloadDialogComponent { + public termName: TermCode['termName']; + public maxCredits: number; + + constructor(@Inject(MAT_DIALOG_DATA) data: any) { + this.termName = data.termName; + this.maxCredits = data.maxCredits; + } +} diff --git a/src/app/degree-planner/services/api.service.ts b/src/app/degree-planner/services/api.service.ts index 19a7f082b94202b9ba8b9a315f1d2918e9bf7ddd..e9b6823fc18e67247b1e6e2c2122a6ac2e1ba278 100644 --- a/src/app/degree-planner/services/api.service.ts +++ b/src/app/degree-planner/services/api.service.ts @@ -14,6 +14,7 @@ import { Profile } from '@app/core/models/profile'; import { SavedForLaterCourseBase } from '@app/core/models/saved-for-later-course'; import { SearchResults } from '@app/core/models/search-results'; import { TermCode } from '@app/core/models/termcode'; +import { StudentInfo } from '@app/core/models/student-info'; const HTTP_OPTIONS = { headers: new HttpHeaders({ @@ -351,6 +352,11 @@ export class DegreePlannerApiService { ); } + public getStudentInfo() { + const url = `${environment.apiEnrollUrl}/studentInfo`; + return this.http.get<StudentInfo>(url, HTTP_OPTIONS); + } + /** * Helper function for building API endpoint URLs */ diff --git a/src/app/degree-planner/services/constants.service.ts b/src/app/degree-planner/services/constants.service.ts index deabf63a9560d53a4889ecaf1aaa5005953f01c3..5a2aa26d81e87a19bc02ac5a3e0f6ffd311686f5 100644 --- a/src/app/degree-planner/services/constants.service.ts +++ b/src/app/degree-planner/services/constants.service.ts @@ -5,9 +5,11 @@ import { DegreePlannerApiService } from '@app/degree-planner/services/api.servic import { map, tap } from 'rxjs/operators'; import { TermCode } from '@app/core/models/termcode'; import { SubjectDescription, SubjectCodesTo } from '@app/core/models/course'; +import { StudentInfo } from '@app/core/models/student-info'; export interface ConstantData { activeTermCodes: TermCode[]; + studentInfo: Partial<StudentInfo>; subjectDescriptions: SubjectCodesTo<SubjectDescription>; } @@ -15,6 +17,7 @@ export interface ConstantData { export class ConstantsService implements Resolve<ConstantData> { private constants: ConstantData = { activeTermCodes: [], + studentInfo: {}, subjectDescriptions: {}, }; @@ -32,6 +35,19 @@ export class ConstantsService implements Resolve<ConstantData> { return this.constants.activeTermCodes; } + public getStudentInfo() { + return this.constants.studentInfo; + } + + public isUndergrad(): boolean { + const info = this.getStudentInfo(); + if (info.primaryCareer) { + return info.primaryCareer.careerCode === 'UGRD'; + } else { + return false; + } + } + public allSubjectDescriptions() { return this.constants.subjectDescriptions; } @@ -49,6 +65,8 @@ export class ConstantsService implements Resolve<ConstantData> { public resolve() { const activeTermCodes = this.api.getActiveTermCodes(); + const studentInfo = this.api.getStudentInfo(); + const subjectDescriptions = forkJoinWithKeys({ short: this.api.getSubjectShortDescriptions(), longByTerm: this.api.getSubjectLongDescriptions(), @@ -78,6 +96,7 @@ export class ConstantsService implements Resolve<ConstantData> { return forkJoinWithKeys({ activeTermCodes, + studentInfo, subjectDescriptions, }).pipe(tap(constants => (this.constants = constants))); } diff --git a/src/app/degree-planner/term-container/term-container.component.html b/src/app/degree-planner/term-container/term-container.component.html index 1e609fd2b95edfa77b19fabbd99d7d60e899ffe0..343124bb86bf95ec47ec9a5fa39f1c0d10668721 100644 --- a/src/app/degree-planner/term-container/term-container.component.html +++ b/src/app/degree-planner/term-container/term-container.component.html @@ -137,6 +137,10 @@ </div> </div> + <p class="credit-overload-warning" *ngIf="tooManyCredits$ | async"> + Warning: credit overload + </p> + <!-- Render term note (if it exists) --> <ng-container *ngIf="(note$ | async) as note"> <ng-container *ngIf="note.isLoaded; else noteIsLoading"> diff --git a/src/app/degree-planner/term-container/term-container.component.scss b/src/app/degree-planner/term-container/term-container.component.scss index 3439d5ae1b26bdc2040fbdb195ff7d04c6125597..deabfd2f8a61bee2dc5938b83f4a1b9196e86780 100644 --- a/src/app/degree-planner/term-container/term-container.component.scss +++ b/src/app/degree-planner/term-container/term-container.component.scss @@ -101,6 +101,11 @@ } } +.credit-overload-warning { + margin-top: 0; + color: #ff8000; +} + .term-container h2 { color: #494949; font-weight: 400; diff --git a/src/app/degree-planner/term-container/term-container.component.ts b/src/app/degree-planner/term-container/term-container.component.ts index 4c656fff59f4efc7e5a76500c84e446f55fc82bf..f008a300e1515cb3eae794e9771a24a230e36ae9 100644 --- a/src/app/degree-planner/term-container/term-container.component.ts +++ b/src/app/degree-planner/term-container/term-container.component.ts @@ -1,39 +1,59 @@ import { Component, Input, OnInit, OnDestroy } from '@angular/core'; import { CdkDragDrop } from '@angular/cdk/drag-drop'; -import { MatDialog, ErrorStateMatcher } from '@angular/material'; -import { Observable, Subscription, pipe } from 'rxjs'; -import { filter, map, distinctUntilChanged } from 'rxjs/operators'; +import { MatDialog } from '@angular/material'; +import { Observable, Subscription } from 'rxjs'; +import { filter, map, distinctUntilChanged, pairwise } from 'rxjs/operators'; import { Store, select } from '@ngrx/store'; import { DegreePlannerState } from '@app/degree-planner/store/state'; -import { - ToggleCourseSearch, - OpenCourseSearch, -} from '@app/degree-planner/store/actions/ui.actions'; - -// Models +import { OpenCourseSearch } from '@app/degree-planner/store/actions/ui.actions'; import * as actions from '@app/degree-planner/store/actions/course.actions'; import { CloseCourseSearch } from '@app/degree-planner/store/actions/ui.actions'; import * as selectors from '@app/degree-planner/store/selectors'; import { PlannedTerm, PlannedTermNote } from '@app/core/models/planned-term'; -import { Note } from '@app/core/models/note'; import { Course } from '@app/core/models/course'; import { NotesDialogComponent, NotesDialogData, } from '@app/degree-planner/dialogs/notes-dialog/notes-dialog.component'; import * as utils from '@app/degree-planner/shared/utils'; -import { findSafariExecutable } from 'selenium-webdriver/safari'; import { TermCode } from '@app/core/models/termcode'; - -// Dialogs import { ConfirmDialogComponent } from '@app/shared/dialogs/confirm-dialog/confirm-dialog.component'; -import { maybeQueueResolutionOfComponentResources } from '@angular/core/src/metadata/resource_loading'; -import { WeekDay } from '@angular/common'; import { MediaMatcher } from '@angular/cdk/layout'; +import { ConstantsService } from '../services/constants.service'; +import { CreditOverloadDialogComponent } from '../dialogs/credit-overload-dialog/credit-overload-dialog.component'; + const isntUndefined = <T>(thing: T | undefined): thing is T => { return thing !== undefined; }; +const sumCredits = ( + courses: ReadonlyArray<{ creditMin?: number; creditMax?: number }>, +) => { + const min = courses.reduce((sum, course) => { + return sum + (course.creditMin !== undefined ? course.creditMin : 0); + }, 0); + + const max = courses.reduce((sum, course) => { + return sum + (course.creditMax !== undefined ? course.creditMax : 0); + }, 0); + + return { min, max }; +}; + +// Both the summer and fall/spring undergrad credit limits are INCLUSIVE. +const SUMMER_CREDIT_LIMIT = 12; +const FALL_SPRING_CREDIT_LIMIT = 18; + +const maximumAllowedCreditsForTerm = (termCode: TermCode) => { + switch (termCode.termName) { + case 'fall': + case 'spring': + return FALL_SPRING_CREDIT_LIMIT; + case 'summer': + return SUMMER_CREDIT_LIMIT; + } +}; + @Component({ selector: 'cse-term-container', templateUrl: './term-container.component.html', @@ -45,6 +65,7 @@ export class TermContainerComponent implements OnInit, OnDestroy { public term$: Observable<PlannedTerm>; public note$: Observable<PlannedTermNote | undefined>; public dropZoneIds$: Observable<string[]>; + public tooManyCredits$: Observable<boolean>; public termSubscription: Subscription; public activeTermHasNotOffered: boolean; @@ -63,6 +84,7 @@ export class TermContainerComponent implements OnInit, OnDestroy { constructor( public dialog: MatDialog, private store: Store<{ degreePlanner: DegreePlannerState }>, + private constants: ConstantsService, mediaMatcher: MediaMatcher, ) { this.mobileView = mediaMatcher.matchMedia('(max-width: 900px)'); @@ -77,6 +99,55 @@ export class TermContainerComponent implements OnInit, OnDestroy { distinctUntilChanged(), ); + this.tooManyCredits$ = this.term$.pipe( + map(term => { + const credits = sumCredits(term.plannedCourses); + const maxAllowedCredits = maximumAllowedCreditsForTerm(term.termCode); + return credits.min >= maxAllowedCredits; + }), + ); + + /** + * Alert the user that they are adding too many credits to a term IFF: + * 1. The user is an undergrad + * 2. The term did not exceed the credit limit before the most recent change + * 3. The term's minimum credit amount exceeds the term's credit limit + */ + + // Condition #1 + if (this.constants.isUndergrad()) { + this.term$.pipe(pairwise()).subscribe(([prev, curr]) => { + // Sanity check: don't compare two terms if they're different terms. + if (prev.termCode.equals(curr.termCode) === false) { + return; + } + + const prevCredits = sumCredits(prev.plannedCourses); + const currCredits = sumCredits(curr.plannedCourses); + const maxAllowedCredits = maximumAllowedCreditsForTerm(curr.termCode); + const prevWasOverLimit = prevCredits.min >= maxAllowedCredits; + const currIsUnderLimit = currCredits.min < maxAllowedCredits; + const currHasFewerCreditsThanPrev = currCredits.min < prevCredits.min; + + if (prevWasOverLimit || currHasFewerCreditsThanPrev) { + // Failed condition #2 + return; + } + + if (currIsUnderLimit) { + // Failed condition #3 + return; + } + + this.dialog.open(CreditOverloadDialogComponent, { + data: { + termName: curr.termCode.termName, + maxCredits: maxAllowedCredits, + }, + }); + }); + } + this.termSubscription = this.term$.subscribe(term => { // const {plannedCourses, enrolledCourses} = term; this.plannedCourses = term.plannedCourses; diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index b30e7bd3ac8b7075bd49494d6b4874323a3b07ba..4a9ab01286568b0a696ed0ee87eb43ac76f28a8b 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -38,6 +38,7 @@ import { import { ClickStopPropagationDirective } from '@app/shared/directives/click-stop-propigation'; import { CourseDescriptionPipe } from './pipes/course-description.pipe'; +import { CreditOverloadDialogComponent } from '@app/degree-planner/dialogs/credit-overload-dialog/credit-overload-dialog.component'; const modules = [ CommonModule, @@ -85,6 +86,7 @@ const directives = [ClickStopPropagationDirective]; CourseDetailsComponent, CourseDetailsDialogComponent, FeedbackDialogComponent, + CreditOverloadDialogComponent, ConfirmDialogComponent, PromptDialogComponent, ], diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index 89d7282bda1cf58726a9b62ad8d5ed674a75c8f2..83e945bf411007ef1a2b9fe4a37a3713affb22de 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -3,5 +3,6 @@ export const environment = { version: '0.0.1', apiPlannerUrl: '/api/planner/v1', apiSearchUrl: '/api/search/v1', + apiEnrollUrl: '/api/enroll/v1', snackbarDuration: 4000, }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 3e1f1732c1fc72a8d5901baa1d3d323f364d0b98..8af2633f68511a4660cdd50caa76e61b474a1b0d 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -8,6 +8,7 @@ export const environment = { version: 'DEV', apiPlannerUrl: '/api/planner/v1', apiSearchUrl: '/api/search/v1', + apiEnrollUrl: '/api/enroll/v1', snackbarDuration: 4000, };