From 3323d43073663fbaccab25735860ee6a1fa8a61e Mon Sep 17 00:00:00 2001 From: Scott Berg <scott.berg@wisc.edu> Date: Mon, 18 Feb 2019 22:18:39 +0000 Subject: [PATCH] Search improvements --- .../degree-planner.component.html | 96 ++++++++----- .../degree-planner.component.ts | 79 ++++++++++- .../degree-planner/services/api.service.ts | 27 +--- .../course-item/course-item.component.html | 5 +- .../course-item/course-item.component.ts | 134 ++++++++++++++++-- .../store/effects/plan.effects.ts | 50 ++++--- src/app/degree-planner/store/selectors.ts | 14 ++ src/app/degree-planner/store/state.ts | 2 + 8 files changed, 309 insertions(+), 98 deletions(-) diff --git a/src/app/degree-planner/degree-planner.component.html b/src/app/degree-planner/degree-planner.component.html index 6072075..4ecd026 100644 --- a/src/app/degree-planner/degree-planner.component.html +++ b/src/app/degree-planner/degree-planner.component.html @@ -1,40 +1,68 @@ <mat-sidenav-container hasBackdrop="false" *ngIf="(degreePlan$ | async) as degreePlan"> - <mat-sidenav position="end" mode="over" [opened]="isCourseSearchVisible$ | async"> + <mat-sidenav #addMenu position="end" mode="over" [opened]="isCourseSearchOpen$ | async"> <mat-toolbar color="primary" class="dialog-toolbar"> <span class="dialog-toolbar-title">Course Search</span> - <button mat-button class="close-btn" (click)="closeCourseSearch();"><i class="material-icons">clear</i></button> + <button mat-button class="close-btn" (click)="closeCourseSearch();"><i class="material-icons">keyboard_arrow_right</i></button> </mat-toolbar> - <mat-sidenav #addMenu position="end" mode="over" [opened]="isCourseSearchOpen$ | async"> - <mat-toolbar color="primary" class="dialog-toolbar"> - <span class="dialog-toolbar-title">Course Search</span> - <button mat-button class="close-btn" (click)="closeCourseSearch();"><i class="material-icons">keyboard_arrow_right</i></button> - </mat-toolbar> - - <form [formGroup]='courseSearchForm' (ngSubmit)="search()" class="add-course-form" fxLayout="column" fxLayoutAlign="space-around none" style="padding: 12px 22px;"> - <mat-form-field> - <mat-select placeholder="Term" formControlName="term"> - <mat-option value="0000" selected>All</mat-option> - <mat-option *ngFor="let term of activeTerms$ | async" [value]="term">{{term | getTermDescription}}</mat-option> - </mat-select> - </mat-form-field> - - <mat-form-field> - <mat-select placeholder="Subject" formControlName="subject" required> - <mat-option value="all">All</mat-option> - <mat-option *ngFor="let subject of (subjects$ | async) | keyvalue" [value]="subject.key"> - {{subject.value}} - </mat-option> - </mat-select> - </mat-form-field> - - <mat-form-field> - <div class="search-input-wrapper"> - <input id="keyword-field" matInput placeholder="Keyword, number" formControlName="search" value=""> - <button id="search-button" mat-icon-button aria-label="Search" matSuffix style="margin-top: -10px;"><i class="material-icons">search</i></button> + <form [formGroup]='courseSearchForm' (ngSubmit)="search()" class="add-course-form" fxLayout="column" fxLayoutAlign="space-around none" style="padding: 12px 22px;"> + + <mat-form-field> + <mat-select placeholder="Term" aria-label="Term" formControlName="term"> + <mat-option value="0000">All</mat-option> + <mat-option *ngFor="let yearCode of activeTerms$ | async" [value]="yearCode">{{yearCode | getTermDescription}}</mat-option> + </mat-select> + </mat-form-field> + + <mat-form-field class="example-full-width"> + <input type="text" placeholder="Subject" aria-label="Subject" matInput formControlName="subject" [matAutocomplete]="subject"> + <mat-autocomplete autoActiveFirstOption #subject="matAutocomplete"> + <mat-option *ngFor="let subject of filteredSubjects | keyvalue" [value]="subject.value">{{subject.value}}</mat-option> + </mat-autocomplete> + </mat-form-field> + + <mat-form-field> + <div class="search-input-wrapper"> + <input id="keyword-field" matInput placeholder="Keyword, number" formControlName="search" value=""> + <button id="search-button" mat-icon-button aria-label="Search" matSuffix style="margin-top: -10px;"><i class="material-icons">search</i></button> + </div> + </mat-form-field> + + </form> + <div *ngIf="hasResults || isLoading"> + <div class="search-results-toolbar mat-typography" fxLayout="row" fxLayoutAlign="space-between center" style="padding: 12px 22px; background-color: #EDF1F3; min-height: 60px;"> + <h3 *ngIf="isLoading" style="margin: 0px;">Searching for courses...</h3> + <h3 *ngIf="hasResults" style="margin: 0px;">{{queriedCourses.length}} result(s)</h3> + <button *ngIf="hasResults" mat-button (click)="resetSearch()">Reset Search</button> + </div> + <mat-progress-bar mode="indeterminate" *ngIf="isLoading"></mat-progress-bar> + + <div id="course-search-results" fxLayout="column" fxLayoutAlign="space-around none" style="margin-top: 20px; padding: 12px 22px;"> + <div *ngIf="hasResults && queriedCourses.length === 0" class="mat-typography" style="text-align: center;"> + <img style="width: 50%;" src="../../assets/img/bucky-sad.svg" alt="No results found"> + <h3>No search results found.</h3> + </div> + + <div + cdkDropList + id="queried-courses-list" + [cdkDropListData]="queriedCourses" + [cdkDropListConnectedTo]="dropZoneIds$ | async" + > + <div + class="course-wrapper" + cdkDrag + [cdkDragData]="course" + *ngFor="let course of queriedCourses" + > + <div class="course-wrapper-inner"> + <cse-course-item [course]="course" type="search"></cse-course-item> </div> - </mat-form-field> - </form> + </div> + </div> + </div> + </div> + </mat-sidenav> <div *ngIf="hasResults || isLoading"> <div class="search-results-toolbar mat-typography" fxLayout="row" fxLayoutAlign="space-between center" style="padding: 12px 22px; background-color: #EDF1F3; min-height: 60px;"> @@ -49,7 +77,7 @@ cdkDropList id="queried-courses-list" [cdkDropListData]="queriedCourses" - [cdkDropListConnectedTo]="dropZones$ | async"> + [cdkDropListConnectedTo]="getTermDropZone()"> <div class="course-wrapper" cdkDrag @@ -62,13 +90,12 @@ </div> </div> </div> - </mat-sidenav> <div cdkDropList id="queried-courses-list" [cdkDropListData]="queriedCourses" - [cdkDropListConnectedTo]="dropZones$ | async"> + [cdkDropListConnectedTo]="dropZoneIds$ | async"> <div class="course-wrapper" cdkDrag @@ -79,7 +106,6 @@ </div> </div> </div> - </mat-sidenav> <mat-sidenav-content> <mat-sidenav-container id="plans-container"> diff --git a/src/app/degree-planner/degree-planner.component.ts b/src/app/degree-planner/degree-planner.component.ts index 7844c3b..5f4f04e 100644 --- a/src/app/degree-planner/degree-planner.component.ts +++ b/src/app/degree-planner/degree-planner.component.ts @@ -4,7 +4,7 @@ import { withLatestFrom, distinctUntilChanged, } from 'rxjs/operators'; -import { OnInit } from '@angular/core'; +import { OnInit, ViewChild } from '@angular/core'; import { Observable } from 'rxjs'; import { select } from '@ngrx/store'; import { Component } from '@angular/core'; @@ -16,11 +16,11 @@ import { MatSnackBar } from '@angular/material'; import { GlobalState } from '@app/core/state'; import { DegreePlan } from '@app/core/models/degree-plan'; import { Year } from '@app/core/models/year'; -import { Course } from '@app/core/models/course'; import { FormBuilder, FormGroup } from '@angular/forms'; import { DegreePlannerApiService } from '@app/degree-planner/services/api.service'; import * as selectors from '@app/degree-planner/store/selectors'; import * as utils from '@app/degree-planner/shared/utils'; +import { Course, SubjectMapping } from '@app/core/models/course'; // Actions import { @@ -59,6 +59,7 @@ export class DegreePlannerComponent implements OnInit { public subjects$: Observable<object>; public activeTerms$: Observable<string[]>; public yearCodes$: Observable<string[]>; + public dropZoneIds$: Observable<string[]>; // Search variables public queriedCourses: Course[]; @@ -67,6 +68,9 @@ export class DegreePlannerComponent implements OnInit { public courseSearchForm: FormGroup; public isCourseSearchVisible$: Observable<boolean>; + public subjects: SubjectMapping; + public filteredSubjects: SubjectMapping; + constructor( private store: Store<GlobalState>, public mediaMatcher: MediaMatcher, @@ -107,13 +111,57 @@ export class DegreePlannerComponent implements OnInit { distinctUntilChanged(utils.compareStringArrays), ); + this.store + .pipe(select(selectors.getSubjectDescriptions)) + .subscribe(subjects => { + this.subjects = { [-1]: 'All', ...subjects }; + this.filteredSubjects = { [-1]: 'All', ...subjects }; + }); + + this.dropZoneIds$ = this.store.pipe( + select(selectors.selectAllVisibleYears), + utils.yearsToDropZoneIds(), + distinctUntilChanged(utils.compareStringArrays), + ); + // Deafults for the search form this.courseSearchForm = this.fb.group({ term: '0000', - subject: 'all', + subject: 'All', search: '', - coursesInput: null, }); + + this.courseSearchForm.valueChanges.subscribe(values => { + if (values.subject === '') { + this.filteredSubjects = this.subjects; + return; + } + + const filtered = {}; + + // Filter the terms based on users search + Object.entries(this.subjects).map(subject => { + const [key, name] = subject; + + const search = name.replace(/\s/g, ''); + + if (search.toLowerCase().indexOf(values.subject.toLowerCase()) === 0) { + filtered[key] = name; + } + }); + + this.filteredSubjects = filtered; + }); + + this.isCourseSearchOpen$ = this.store.pipe( + select(selectors.isCourseSearchOpen), + ); + } + + public handleAcademicYearToggle(year: Year): void { + // this.store.dispatch( + // new ToggleAcademicYear({ year: year.twoDigitYearCode.toString() }), + // ); } public handleDegreePlanChange(event: MatSelectChange): void { @@ -235,6 +283,24 @@ export class DegreePlannerComponent implements OnInit { public search() { // Get the form field values const { search, term, subject } = this.courseSearchForm.value; + let subjectCode; + + // Check if subject is valid + Object.entries(this.subjects).forEach(option => { + const [key, value] = option; + if (value === subject) { + subjectCode = key; + } + }); + + if (!subjectCode) { + this.snackBar.open('Please select a valid subject', undefined, { + duration: 6000, + }); + return; + } + + console.log(search, term, subject, subjectCode); // Set the internal UI state this.isLoading = true; @@ -244,7 +310,7 @@ export class DegreePlannerComponent implements OnInit { // Hit the search API this.api .searchCourses({ - subjectCode: subject, + subjectCode, searchText: search, termCode: term === '' ? '0000' : term, }) @@ -273,8 +339,9 @@ export class DegreePlannerComponent implements OnInit { this.queriedCourses = []; this.hasResults = false; this.courseSearchForm.setValue({ - subject: 'all', + subject: 'All', search: '', + term: '0000', }); } } diff --git a/src/app/degree-planner/services/api.service.ts b/src/app/degree-planner/services/api.service.ts index 63a681a..2dbf41b 100644 --- a/src/app/degree-planner/services/api.service.ts +++ b/src/app/degree-planner/services/api.service.ts @@ -57,6 +57,10 @@ export class DegreePlannerApiService { ); } + public getAllSubjectDescriptions(): Observable<{}> { + return this.http.get(this.searchEndpoint('subjects')); + } + public getActiveTerms(): Observable<Term[]> { return this.http.get<Term[]>(this.searchEndpoint('terms')); } @@ -111,27 +115,6 @@ export class DegreePlannerApiService { searchText?: string; termCode?: string; }): Observable<any> { - // const data = { - // filters: [ - // { term: { 'subject.subjectCode': '266' } }, - // { - // has_child: { - // type: 'enrollmentPackage', - // query: { - // match: { - // 'packageEnrollmentStatus.status': 'OPEN WAITLISTED CLOSED', - // }, - // }, - // }, - // }, - // ], - // page: 1, - // pageSize: 500, - // queryString: 'programing', - // selectedTerm: '1194', - // sortOrder: 'SCORE', - // }; - const { subjectCode, termCode, searchText } = config; const payload: any = { @@ -148,7 +131,7 @@ export class DegreePlannerApiService { }; // If we have a specific subject code, add a fitler for it - if (subjectCode !== 'all') { + if (subjectCode !== '-1') { payload.filters.push({ term: { 'subject.subjectCode': subjectCode } }); } diff --git a/src/app/degree-planner/shared/course-item/course-item.component.html b/src/app/degree-planner/shared/course-item/course-item.component.html index d854691..ba27d1b 100644 --- a/src/app/degree-planner/shared/course-item/course-item.component.html +++ b/src/app/degree-planner/shared/course-item/course-item.component.html @@ -28,9 +28,10 @@ <button mat-menu-item [matMenuTriggerFor]="academicYearsGroup">Move</button> <mat-menu #academicYearsGroup="matMenu" class="course-item-submenu"> <button mat-menu-item (click)="moveToSavedForLater(course)" *ngIf="type != 'saved'" class="saved-for-later-list">Saved for later</button> - <button mat-menu-item *ngFor="let term of (droppableTermCodes$ | async)" (click)="(type == 'saved') ? addToTerm(course, term) : switchTerm(course, term)">{{ term | getTermDescription }}</button> + <button mat-menu-item *ngFor="let term of (droppableTermCodes$ | async)" (click)="onMove(term)">{{ term | getTermDescription }}</button> </mat-menu> - <button mat-menu-item (click)="openRemoveConfirmationDialog()">Remove</button> + <button mat-menu-item *ngIf="type !== 'saved'" (click)="onSaveForLater()">Save for later</button> + <button mat-menu-item *ngIf="type !== 'search'" (click)="onRemove()">Remove</button> </mat-menu> </div> <div *ngIf="disabled" fxLayout="row" fxLayoutAlign="end center"> 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 e586ae4..cdad1ab 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 @@ -8,13 +8,15 @@ import { AddCourse, AddSaveForLater, MoveCourseBetweenTerms, + RemoveSaveForLater, + RemoveCourse, } from './../../store/actions/course.actions'; import { GlobalState } from '@app/core/state'; import { Course } from '@app/core/models/course'; import * as selectors from '@app/degree-planner/store/selectors'; import { DegreePlannerApiService } from '@app/degree-planner/services/api.service'; -// tslint:disable-next-line:max-line-length +import { ConfirmDialogComponent } from '@app/shared/dialogs/confirm-dialog/confirm-dialog.component'; import { CourseDetailsDialogComponent } from '@app/degree-planner/dialogs/course-details-dialog/course-details-dialog.component'; // tslint:disable-next-line:max-line-length import { RemoveCourseConfirmDialogComponent } from '@app/degree-planner/dialogs/remove-course-confirm-dialog/remove-course-confirm-dialog.component'; @@ -79,18 +81,130 @@ export class CourseItemComponent implements OnInit { ); } + /** + * + * Handle moving a course to different terms based on course type + * + */ + onMove(termCode: string) { + switch (this.type) { + case 'course': + const { id, termCode: from } = this.course as { + id: number; + termCode: string; + }; + this.store.dispatch( + new MoveCourseBetweenTerms({ to: termCode, from, id }), + ); + break; + + case 'saved': + const { subjectCode, courseId } = this.course; + this.addToTerm(this.course, termCode); + this.store.dispatch(new RemoveSaveForLater({ subjectCode, courseId })); + break; + + case 'search': + this.addToTerm(this.course, termCode); + break; + } + } + + /** + * + * Handle saving a course for later (This is not possible if a course is already saved) + * + */ + onSaveForLater() { + const { + courseId, + subjectCode, + title, + catalogNumber, + termCode, + } = this.course; + + // Dispatch a save for later event + this.store.dispatch( + new AddSaveForLater({ + courseId: courseId, + subjectCode: subjectCode, + title: title, + catalogNumber: catalogNumber, + }), + ); + + // If course is in a term, we need to remove it + if (this.type === 'course') { + this.store.dispatch( + new RemoveCourse({ + fromTermCode: termCode, + recordId: this.course.id as number, + }), + ); + } + } + + /** + * + * Handle removing a course (This is not possible for type 'search') + * + */ + onRemove() { + const dialogOptions = { + title: 'Remove Course?', + text: '', + confirmText: 'Remove Course', + confirmColor: 'accent', + }; + + switch (this.type) { + case 'saved': + dialogOptions.text = `This will remove "${ + this.course.title + }" from your saved courses.`; + break; + + default: + dialogOptions.text = `This will remove "${ + this.course.title + }" from your degree plan and your cart.`; + } + + this.dialog + .open(ConfirmDialogComponent, { data: dialogOptions }) + .afterClosed() + .subscribe((result: { confirmed: boolean }) => { + // If the user confirmed the removal, remove course + if (result.confirmed) { + switch (this.type) { + case 'course': + this.store.dispatch( + new RemoveCourse({ + fromTermCode: this.course.termCode, + recordId: this.course.id as number, + }), + ); + break; + + case 'saved': + const { subjectCode, courseId } = this.course; + this.store.dispatch( + new RemoveSaveForLater({ subjectCode, courseId }), + ); + break; + } + } + }); + } + addToTerm(course, term) { const { subjectCode, courseId } = course; + const termCode = term; this.store.dispatch(new AddCourse({ subjectCode, courseId, termCode })); } - switchTerm(course, term) { - const { id, termCode: from } = course; - const to = term; - this.store.dispatch(new MoveCourseBetweenTerms({ to, from, id })); - } - openCourseDetailsDialog(course) { this.api .getCourseDetails(course.subjectCode, course.courseId) @@ -100,10 +214,4 @@ export class CourseItemComponent implements OnInit { }); }); } - - openRemoveConfirmationDialog() { - const dialogRef = this.dialog.open(RemoveCourseConfirmDialogComponent, { - data: { course: this.course, type: this.type }, - }); - } } diff --git a/src/app/degree-planner/store/effects/plan.effects.ts b/src/app/degree-planner/store/effects/plan.effects.ts index 1f25d40..de75461 100644 --- a/src/app/degree-planner/store/effects/plan.effects.ts +++ b/src/app/degree-planner/store/effects/plan.effects.ts @@ -73,31 +73,41 @@ export class DegreePlanEffects { return forkJoinWithKeys({ allDegreePlans: this.api.getAllDegreePlans(), subjects: this.api.getAllSubjects(), + subjectDescriptions: this.api.getAllSubjectDescriptions(), activeTermCodes, }); }), // Load data specific to the primary degree plan. - flatMap(({ allDegreePlans, subjects, activeTermCodes }) => { - const savedForLaterCourses = this.loadSavedForLaterCourses(subjects); - const visibleDegreePlan = pickPrimaryDegreePlan(allDegreePlans); - const visibleYears = loadPlanYears( - this.api, - visibleDegreePlan.roadmapId, - subjects, - activeTermCodes, - ); + flatMap( + ({ allDegreePlans, subjects, subjectDescriptions, activeTermCodes }) => { + const savedForLaterCourses = this.loadSavedForLaterCourses(subjects); + const visibleDegreePlan = pickPrimaryDegreePlan(allDegreePlans); + const visibleYears = loadPlanYears( + this.api, + visibleDegreePlan.roadmapId, + subjects, + activeTermCodes, + ); - return forkJoinWithKeys({ - visibleDegreePlan: of(visibleDegreePlan), - visibleYears, - savedForLaterCourses, - activeTermCodes: of(activeTermCodes), - allDegreePlans: of(allDegreePlans), - subjects: of(subjects), - expandedYears: of([] as string[]), - isCourseSearchVisible: of(false), - }); - }), + const descriptions = {}; + + subjectDescriptions['0000'].map(subject => { + descriptions[subject.subjectCode] = subject.formalDescription; + }); + + return forkJoinWithKeys({ + visibleDegreePlan: of(visibleDegreePlan), + visibleYears, + savedForLaterCourses, + activeTermCodes: of(activeTermCodes), + allDegreePlans: of(allDegreePlans), + subjects: of(subjects), + expandedYears: of([] as string[]), + isCourseSearchVisible: of(false), + subjectDescriptions: of(descriptions), + }); + }, + ), // map(payload => { // const allTerms = payload.visibleYears.map(term => term.termCode); // const currentIndex = allTerms.indexOf(payload.activeTermCodes[0]); diff --git a/src/app/degree-planner/store/selectors.ts b/src/app/degree-planner/store/selectors.ts index c5878ec..3a2d5d7 100644 --- a/src/app/degree-planner/store/selectors.ts +++ b/src/app/degree-planner/store/selectors.ts @@ -79,6 +79,20 @@ export const selectVisibleTerm = createSelector( }, ); +export const getSubjectDescriptions = createSelector( + getDegreePlannerState, + (state: DegreePlannerState) => { + return state.subjectDescriptions; + }, +); + +export const isCourseSearchOpen = createSelector( + getDegreePlannerState, + (state: DegreePlannerState) => { + return state.isCourseSearchVisible; + }, +); + export const isCurrentTerm = (termCode: string) => createSelector( getDegreePlannerState, diff --git a/src/app/degree-planner/store/state.ts b/src/app/degree-planner/store/state.ts index 3b8b2d0..45ccb17 100644 --- a/src/app/degree-planner/store/state.ts +++ b/src/app/degree-planner/store/state.ts @@ -11,6 +11,7 @@ export interface DegreePlannerState { activeTermCodes: string[]; allDegreePlans: DegreePlan[]; subjects: SubjectMapping; + subjectDescriptions: SubjectMapping; expandedYears: string[]; isCourseSearchVisible: boolean; } @@ -22,6 +23,7 @@ export const INITIAL_DEGREE_PLANNER_STATE: DegreePlannerState = { activeTermCodes: [], allDegreePlans: [], subjects: {}, + subjectDescriptions: {}, expandedYears: [], isCourseSearchVisible: false, }; -- GitLab