From be7abd959f9a25a5e867aec34e1067e986ff6b4e Mon Sep 17 00:00:00 2001 From: Scott Berg <saberg3@wisc.edu> Date: Fri, 22 Feb 2019 16:06:07 -0600 Subject: [PATCH] Refactor courses to be enrolled and planned courses. Added course tabs. --- src/app/core/models/course.ts | 7 +- src/app/core/models/planned-term.ts | 3 +- .../course-item/course-item.component.ts | 8 + .../store/effects/plan.effects.ts | 68 ++---- src/app/degree-planner/store/reducer.ts | 21 +- .../term-container.component.html | 202 +++++++----------- .../term-container.component.scss | 46 +++- .../term-container.component.ts | 115 +++++----- 8 files changed, 226 insertions(+), 244 deletions(-) diff --git a/src/app/core/models/course.ts b/src/app/core/models/course.ts index 8257447..d96769e 100644 --- a/src/app/core/models/course.ts +++ b/src/app/core/models/course.ts @@ -26,7 +26,12 @@ export interface CourseBase { enrollmentOptions?: any; packageEnrollmentStatus?: any; creditRange?: any; - studentEnrollmentStatus: 'Enrolled' | 'Waitlisted' | null; + studentEnrollmentStatus: + | 'Enrolled' + | 'Waitlisted' + | 'Cart' + | 'Not Offered' + | null; } export interface Course extends CourseBase { diff --git a/src/app/core/models/planned-term.ts b/src/app/core/models/planned-term.ts index 0446370..8c05e23 100644 --- a/src/app/core/models/planned-term.ts +++ b/src/app/core/models/planned-term.ts @@ -10,5 +10,6 @@ export interface PlannedTerm { termCode: string; era: PlannedTermEra; note?: PlannedTermNote; - courses: Course[]; + plannedCourses: Course[]; + enrolledCourses: Course[]; } diff --git a/src/app/degree-planner/shared/course-item/course-item.component.ts b/src/app/degree-planner/shared/course-item/course-item.component.ts index 393d006..7ea1d76 100644 --- a/src/app/degree-planner/shared/course-item/course-item.component.ts +++ b/src/app/degree-planner/shared/course-item/course-item.component.ts @@ -175,6 +175,14 @@ export class CourseItemComponent implements OnInit { .subscribe((result: { confirmed: boolean }) => { // If the user confirmed the removal, remove course if (result.confirmed) { + console.log(this.type); + + console.log({ + type: this.type, + fromTermCode: this.course.termCode, + recordId: this.course.id, + }); + switch (this.type) { case 'course': this.store.dispatch( diff --git a/src/app/degree-planner/store/effects/plan.effects.ts b/src/app/degree-planner/store/effects/plan.effects.ts index d3cf2ca..cb1ebae 100644 --- a/src/app/degree-planner/store/effects/plan.effects.ts +++ b/src/app/degree-planner/store/effects/plan.effects.ts @@ -49,7 +49,7 @@ import { } from '@app/degree-planner/store/state'; import { YearMapping } from '@app/core/models/year'; import { Note } from '@app/core/models/note'; -import { CourseBase } from '@app/core/models/course'; +import { CourseBase, Course } from '@app/core/models/course'; import { pickTermEra } from '@app/degree-planner/shared/utils'; @Injectable() @@ -313,52 +313,6 @@ export class DegreePlanEffects { } } -const loadPlanTerms = ( - api: DegreePlannerApiService, - visibleDegreePlan: DegreePlan, - subjects: SubjectMapping, - activeTermCodes: string[], -): Observable<PlannedTerm[]> => { - const notesAndTerms$ = forkJoinWithKeys({ - notes: api.getAllNotes(visibleDegreePlan.roadmapId), - terms: api.getAllTermCourses(visibleDegreePlan.roadmapId), - }); - - const uniqueTerms$ = notesAndTerms$.pipe( - map(({ notes, terms }) => { - const noteTermCodes = notes.map(note => note.termCode); - const courseTermCodes = terms.map(term => term.termCode); - const uniqueTermCodes = unique([ - ...noteTermCodes, - ...courseTermCodes, - ...activeTermCodes, - ]); - - return uniqueTermCodes.sort(); - }), - ); - - const visibleTerms$ = forkJoin(uniqueTerms$, notesAndTerms$).pipe( - map(([uniqueTerms, { notes, terms }]) => { - return uniqueTerms.map(termCode => { - const baseNote = notes.find(matchesTermCode(termCode)); - const note: PlannedTermNote | undefined = baseNote - ? { isLoaded: true, text: baseNote.note, id: baseNote.id } - : undefined; - const term = terms.find(matchesTermCode(termCode)); - const courses = (term ? term.courses : []).map(course => ({ - ...course, - subject: subjects[course.subjectCode], - })); - - return { termCode, note, courses } as PlannedTerm; - }); - }), - ); - - return visibleTerms$; -}; - type SimpleMap = { [name: string]: any }; type ObservableMap<T = SimpleMap> = { [K in keyof T]: Observable<T[K]> }; @@ -404,14 +358,28 @@ const buildTerm = ( ? { isLoaded: true, text: baseNote.note, id: baseNote.id } : undefined; const group = courses.find(matchesTermCode(termCode)); + const formattedCourses = (group ? group.courses : []).map(course => { + return { ...course, termCode, subject: subjects[course.subjectCode] }; + }); + + const plannedCourses: Course[] = []; + const enrolledCourses: Course[] = []; + + formattedCourses.forEach(course => { + if (course.studentEnrollmentStatus === 'Enrolled') { + enrolledCourses.push(course); + return; + } + plannedCourses.push(course); + }); + const era = pickTermEra(termCode, activeTermCodes); return { termCode, era, note, - courses: (group ? group.courses : []).map(course => { - return { ...course, termCode, subject: subjects[course.subjectCode] }; - }), + plannedCourses, + enrolledCourses, }; }; diff --git a/src/app/degree-planner/store/reducer.ts b/src/app/degree-planner/store/reducer.ts index 7b7d691..24ed21e 100644 --- a/src/app/degree-planner/store/reducer.ts +++ b/src/app/degree-planner/store/reducer.ts @@ -303,7 +303,7 @@ export function degreePlannerReducer( const year = state.visibleYears[yearCode]; if (year) { - const courses = year[termName].courses; + const courses = year[termName].plannedCourses; const course = courses.find(course => course.id === recordId); const oldIndex = courses.findIndex(course => course.id === recordId); if (course) { @@ -317,7 +317,7 @@ export function degreePlannerReducer( ...state.visibleYears[yearCode], [termName]: { ...state.visibleYears[yearCode][termName], - courses: newCourses, + plannedCourses: newCourses, }, }, }; @@ -573,7 +573,7 @@ const termCodeExists = (termCode: string, things: { termCode: string }[]) => { }; const emptyTerm = (termCode: string, era: PlannedTermEra): PlannedTerm => { - return { termCode, era, courses: [] }; + return { termCode, era, plannedCourses: [], enrolledCourses: [] }; }; const emptyYear = (yearCode: string, activeTermCodes: string[]): Year => { @@ -621,7 +621,7 @@ const findCourse = (years: YearMapping, termCode: string, recordId: number) => { const year = years[yearCode]; if (year) { const term = year[termName]; - return term.courses.find(course => course.id === recordId); + return term.plannedCourses.find(course => course.id === recordId); } }; @@ -634,10 +634,12 @@ const createYearWithCourse = ( ): Year => { const { termName } = parseTermCode(termCode); const term = year[termName]; - const courses = term.courses.filter(c => c.courseId !== course.courseId); + const courses = term.plannedCourses.filter( + c => c.courseId !== course.courseId, + ); newIndex = newIndex !== undefined ? newIndex : courses.length; courses.splice(newIndex, 0, course); - return { ...year, [termName]: { ...term, courses } }; + return { ...year, [termName]: { ...term, plannedCourses: courses } }; }; const createYearWithoutCourse = ( @@ -648,6 +650,9 @@ const createYearWithoutCourse = ( ): Year => { const { termName } = parseTermCode(termCode); const term = year[termName]; - const courses = term.courses.filter(course => course.id !== recordId); - return { ...year, [termName]: { ...term, courses } }; + const courses = term.plannedCourses.filter(course => course.id !== recordId); + return { + ...year, + [termName]: { ...term, plannedCourses: courses }, + }; }; 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 906b965..1640deb 100644 --- a/src/app/degree-planner/term-container/term-container.component.html +++ b/src/app/degree-planner/term-container/term-container.component.html @@ -1,3 +1,58 @@ +<ng-template #enrolled> + <div class="course-list-wrapper"> + <div class="course-list"> + <div class="course-list-inner"> + <ng-container *ngIf="enrolledCourses.length === 0"> + <p *ngIf="(term$ | async).era === 'past'" class="no-courses">No Courses Taken</p> + <p *ngIf="(term$ | async).era === 'active'" class="no-courses">Not Enrolled in any Courses</p> + </ng-container> + + <cse-course-item *ngFor="let course of enrolledCourses" type="course" [disabled]="true" [course]="course"></cse-course-item> + </div> + </div> + </div> +</ng-template> + +<ng-template #planned> + <div class="course-list-wrapper"> + <div class="course-list" + cdkDropList + id="term-{{ termCode }}" + [cdkDropListData]="(term$ | async).termCode" + [cdkDropListConnectedTo]="dropZoneIds$ | async" + (cdkDropListDropped)="drop($event)" + (cdkDropListEntered)="dragEnter($event)" + (cdkDropListExited)="dragExit($event)" + > + <div class="course-list-inner term-body"> + <ng-container *ngIf="plannedCourses.length === 0 && !hasItemDraggedOver"> + <p *ngIf="(term$ | async).era === 'active'" class="no-courses">No Courses In Cart</p> + <p *ngIf="(term$ | async).era === 'future'" class="no-courses">No Courses Planned</p> + </ng-container> + <div + cdkDrag + [cdkDragData]="course" + class="course-wrapper" + *ngFor="let course of plannedCourses" + > + <cse-course-item type="course" [course]="course"></cse-course-item> + </div> + </div> + </div> + + <!-- Add course --> + <div class="add-new-wrapper" *ngIf="(term$ | async).era !== 'past'"> + <button + mat-raised-button + class="add-course-button" + (click)="openCourseSearch()" + > + + Add Course + </button> + </div> + </div> +</ng-template> + <mat-card class="term-container"> <div class="term-inner"> <div @@ -7,7 +62,10 @@ > <h2>{{ termCode | getTermDescription }}</h2> <div fxLayout="row" fxLayoutAlign="space-between center"> - <p class="text-right semi-bold credits">{{ credits$ | async }} Cr</p> + <p class="text-right semi-bold credits"> + <span *ngIf="visibleCredits === 'planned'">{{plannedCredits}} Cr</span> + <span *ngIf="visibleCredits === 'enrolled'">{{enrolledCredits}} Cr</span> + </p> <ng-container *ngIf="(note$ | async) as note; else newNote"> <button mat-icon-button @@ -38,133 +96,23 @@ </ng-template> </div> </div> - <div - id="term-{{ termCode }}" - class="term-body" - cdkDropList - [cdkDropListData]="(term$ | async).termCode" - [cdkDropListConnectedTo]="dropZoneIds$ | async" - (cdkDropListDropped)="drop($event)" - > - <!-- Render term note (if it exists) --> - <ng-container *ngIf="(note$ | async) as note"> - <ng-container *ngIf="note.isLoaded; else noteIsLoading"> - <div class="note-item" (click)="openNotesDialog(note)"> - <p class="semi-bold">Note</p> - <p class="note-excerpt">{{ note.text }}</p> - </div> - </ng-container> - <ng-template #noteIsLoading> - <div class="note-item note-item-loading"> - <p class="semi-bold">Note</p> - <p class="note-excerpt">{{ note.text }}</p> - <mat-progress-spinner - mode="indeterminate" - diameter="24" - ></mat-progress-spinner> - </div> - </ng-template> - </ng-container> + </div> - <!-- Render list of cart courses in this term --> - <div - *ngIf="(term$ | async).era == 'active'" - fxLayout="row" - class="term-header" - fxLayoutAlign="space-between center" - > - <h3>My Courses: Cart</h3> - <div fxLayout="row" fxLayoutAlign="space-between center"> - <p class="text-right semi-bold credits"> - {{ cartCredits$ | async }} Cr - </p> - <button mat-icon-button> - <a href="/my-courses"> - <mat-icon - aria-label="Go to cart" - color="primary" - matTooltip="Go to cart" - matTooltipPosition="above" - > - shopping_cart - </mat-icon> - </a> - </button> - </div> - </div> + <!-- If this term is an active term --> + <ng-container *ngIf="(term$ |async).era === 'active'"> + <mat-tab-group (selectedTabChange)="changeVisibleCredits($event)" [selectedIndex]="1"> + <mat-tab label="Enrolled"><ng-container *ngTemplateOutlet="enrolled"></ng-container></mat-tab> + <mat-tab label="Cart"><ng-container *ngTemplateOutlet="planned"></ng-container></mat-tab> + </mat-tab-group> + </ng-container> - <div - class="course-wrapper" - *ngFor="let course of (courses$ | async)" - cdkDrag - [cdkDragData]="course" - > - <ng-container class="course-wrapper-inner" *ngIf="course.id !== null"> - <cse-course-item - type="course" - [course]="course" - [isPastTerm]="isPastTerm$ | async" - > - </cse-course-item> - </ng-container> - </div> - <div - class="no-courses" - *ngIf="(courses$ | async).length == 0 && (term$ | async).era != 'past'" - > - <p> - No courses planned - </p> - </div> - </div> - <div - id="term-{{ termCode }}" - class="term-body" - cdkDropList - [cdkDropListData]="(term$ | async).termCode" - [cdkDropListConnectedTo]="dropZoneIds$ | async" - (cdkDropListDropped)="drop($event)" - > - <!-- Render list of courses in this term --> - <div - class="course-wrapper" - *ngFor="let course of (courses$ | async)" - cdkDrag - [cdkDragDisabled]="(isPastTerm$ | async) || course.id === null" - [cdkDragData]="course" - > - <ng-container class="course-wrapper-inner" *ngIf="course.id === null"> - <cse-course-item - type="course" - [course]="course" - [isPastTerm]="isPastTerm$ | async" - [isCurrentTerm]="isCurrentTerm$ | async" - [disabled]="(isPastTerm$ | async) || course.id === null" - > - </cse-course-item> - </ng-container> - </div> - <div - class="no-courses" - *ngIf="(courses$ | async).length === 0 && (term$ | async).era == 'past'" - > - <p> - {{ - (isPastTerm$ | async) ? 'No courses taken' : 'No enrolled courses' - }} - </p> - </div> - </div> - </div> + <!-- If this term is a past term --> + <ng-container *ngIf="(term$ |async).era === 'past'"> + <ng-container *ngTemplateOutlet="enrolled"></ng-container> + </ng-container> - <!-- Add course --> - <div class="add-new-wrapper" *ngIf="(isPastTerm$ | async) === false"> - <button - mat-raised-button - class="add-course-button" - (click)="openCourseSearch()" - > - + Add Course - </button> - </div> + <!-- If this term is a past term --> + <ng-container *ngIf="(term$ |async).era === 'future'"> + <ng-container *ngTemplateOutlet="planned"></ng-container> + </ng-container> </mat-card> 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 7161f94..5321743 100644 --- a/src/app/degree-planner/term-container/term-container.component.scss +++ b/src/app/degree-planner/term-container/term-container.component.scss @@ -7,12 +7,47 @@ transition: all 0.2s ease-out; } -.term-body { - padding-bottom: 50px; +/deep/.mat-tab-label, +/deep/.mat-tab-label-active { + min-width: 0 !important; + padding: 0px 10px !important; + margin: 0px !important; } -.add-course-button { - text-transform: uppercase; +/deep/.mat-tab-header { + border-bottom: solid #e0e4e7 1px; +} + +.course-list-wrapper { + position: relative; + display: block; + height: 380px; + display: flex; + flex-direction: column; +} + +.course-list { + height: 100%; + flex: 1 1 100%; + padding: 10px; + // background-color: teal; + overflow-y: scroll; + + &:after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + pointer-events: none; + // box-shadow: inset 0px 7px 12px -5px rgba(50, 50, 50, 0.1); + } +} + +.no-courses { + font-weight: bold; + text-align: center; } .term-inner { @@ -57,8 +92,6 @@ .add-new-wrapper { border-top: solid #e0e4e7 1px; padding: 8px; - position: absolute; - bottom: 0; width: 100%; box-sizing: border-box; @@ -66,6 +99,7 @@ width: 100%; box-shadow: 0 1px 3px 1px rgba(0, 0, 0, 0.1); color: #2879a8; + text-transform: uppercase; } } 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 157894a..0bde915 100644 --- a/src/app/degree-planner/term-container/term-container.component.ts +++ b/src/app/degree-planner/term-container/term-container.component.ts @@ -1,7 +1,7 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Input, OnInit, OnDestroy } from '@angular/core'; import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { MatDialog, ErrorStateMatcher } from '@angular/material'; -import { Observable } from 'rxjs'; +import { Observable, Subscription, pipe } from 'rxjs'; import { filter, map, distinctUntilChanged } from 'rxjs/operators'; import { Store, select } from '@ngrx/store'; import { DegreePlannerState } from '@app/degree-planner/store/state'; @@ -27,41 +27,31 @@ const isntUndefined = <T>(thing: T | undefined): thing is T => { return thing !== undefined; }; -const sumCredits = (courses: Course[]): number => { - return courses.reduce((sum, course) => { - if (course.credits != undefined) { - return sum + course.credits; - } - return sum; - }, 0); -}; - -const sumCreditsInCart = (courses: Course[]): number => { - return courses - .filter(course => course.id && course.creditRange) - .reduce((sum, course) => { - return sum + parseInt(course.creditRange); - }, 0); -}; - @Component({ selector: 'cse-term-container', templateUrl: './term-container.component.html', styleUrls: ['./term-container.component.scss'], }) -export class TermContainerComponent implements OnInit { +export class TermContainerComponent implements OnInit, OnDestroy { @Input() yearCode: string; @Input() termName: 'fall' | 'spring' | 'summer'; public termCode: string; public term$: Observable<PlannedTerm>; public note$: Observable<PlannedTermNote | undefined>; - public courses$: Observable<Course[]>; - public credits$: Observable<number>; - public cartCredits$: Observable<number>; - public isPastTerm$: Observable<boolean>; public dropZoneIds$: Observable<string[]>; - public isCurrentTerm$: Observable<boolean>; + + public termSubscription: Subscription; + + // List of courses pulled for the Observable + public plannedCourses: Course[]; + public enrolledCourses: Course[]; + public era: 'past' | 'active' | 'future'; + + public hasItemDraggedOver: boolean; + public plannedCredits: string; + public enrolledCredits: number; + public visibleCredits: 'enrolled' | 'planned'; constructor( public dialog: MatDialog, @@ -71,6 +61,7 @@ export class TermContainerComponent implements OnInit { public ngOnInit() { const termOffset = { fall: 2, spring: 4, summer: 6 }; this.termCode = `${this.yearCode}${termOffset[this.termName]}`; + this.hasItemDraggedOver = false; this.term$ = this.store.pipe( select(selectors.selectVisibleTerm, { termCode: this.termCode }), @@ -78,28 +69,20 @@ export class TermContainerComponent implements OnInit { distinctUntilChanged(), ); - this.note$ = this.term$.pipe( - map(term => term.note), - distinctUntilChanged(), - ); + this.termSubscription = this.term$.subscribe(term => { + // const {plannedCourses, enrolledCourses} = term; + this.plannedCourses = term.plannedCourses; + this.plannedCredits = this.sumPlannedCredits(term.plannedCourses); - this.courses$ = this.term$.pipe( - map(term => term.courses), - distinctUntilChanged(), - ); + this.enrolledCourses = term.enrolledCourses; + this.enrolledCredits = this.sumEnrolledCredits(term.enrolledCourses); - this.credits$ = this.courses$.pipe( - map(sumCredits), - distinctUntilChanged(), - ); - - this.cartCredits$ = this.courses$.pipe( - map(sumCreditsInCart), - distinctUntilChanged(), - ); + this.era = term.era; + this.visibleCredits = term.era === 'past' ? 'enrolled' : 'planned'; + }); - this.isPastTerm$ = this.term$.pipe( - map(term => term.era === 'past'), + this.note$ = this.term$.pipe( + map(term => term.note), distinctUntilChanged(), ); @@ -108,14 +91,10 @@ export class TermContainerComponent implements OnInit { utils.yearsToDropZoneIds(), distinctUntilChanged(utils.compareStringArrays), ); + } - this.isPastTerm$ = this.store.pipe( - select(selectors.isPastTerm(this.termCode)), - ); - - this.isCurrentTerm$ = this.store.pipe( - select(selectors.isCurrentTerm(this.termCode)), - ); + ngOnDestroy() { + this.termSubscription.unsubscribe(); } openNotesDialog(note?: PlannedTermNote) { @@ -137,6 +116,16 @@ export class TermContainerComponent implements OnInit { this.store.dispatch(new OpenCourseSearch(this.termCode)); } + changeVisibleCredits(event) { + switch (event.tab.textLabel) { + case 'Enrolled': + this.visibleCredits = 'enrolled'; + break; + default: + this.visibleCredits = 'planned'; + } + } + drop(event: CdkDragDrop<string>) { const newContainer = event.container.id; const previousContainer = event.previousContainer.id; @@ -206,4 +195,28 @@ export class TermContainerComponent implements OnInit { ); } } + + dragEnter(event, item) { + this.hasItemDraggedOver = true; + } + + dragExit(event, item) { + this.hasItemDraggedOver = false; + } + + sumEnrolledCredits(courses: Course[]): number { + return courses.reduce((sum, course) => sum + course.credits, 0); + } + + sumPlannedCredits(courses: Course[]): string { + const credits = { min: 0, max: 0 }; + courses.forEach(course => { + credits.min = credits.min + course.creditMin; + credits.max = credits.max + course.creditMax; + }); + + return credits.min === credits.max + ? credits.min.toString() + : `${credits.min}-${credits.max}`; + } } -- GitLab