import { Observable, Subscription } from 'rxjs'; import { distinctUntilChanged } from 'rxjs/operators'; import { Store, select } from '@ngrx/store'; import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core'; import { GlobalState } from '@app/core/state'; import * as utils from '@app/degree-planner/shared/utils'; import * as selectors from '@app/degree-planner/store/selectors'; // API import { DegreePlannerApiService } from '@app/degree-planner/services/api.service'; // Forms import { FormBuilder, FormGroup, AbstractControl } from '@angular/forms'; // snackBar import { MatSnackBar } from '@angular/material'; // Data modles import { Course, SubjectMapping } from '@app/core/models/course'; import { TermCode } from '@app/core/models/termcode'; @Component({ selector: 'cse-course-search', templateUrl: './course-search.component.html', styleUrls: ['./course-search.component.scss'], }) export class CourseSearchComponent implements OnInit, OnDestroy { @ViewChild('subject') subjectInput; // Internal variables used to store search info public queriedCourses: Course[]; public hasResults: boolean; public isLoading: boolean; public courseSearchForm: FormGroup; // Observable used for drag and drop and for populating term select public dropZoneIds$: Observable<string[]>; public activeTerms$: Observable<TermCode[]>; public activeSelectedSearchTerm$: Observable<TermCode | undefined>; public isCourseSearchOpen$: Observable<boolean>; // Internal variables to store subjects and filtered subjects list public subjects: string[]; public subjectMap: SubjectMapping; public filteredSubjects: string[]; public termSubscription: Subscription; public searchOpenSubscribe: Subscription; constructor( private store: Store<GlobalState>, private fb: FormBuilder, private api: DegreePlannerApiService, private snackBar: MatSnackBar, ) {} public ngOnInit(): void { // Internal values used to manage loading state this.queriedCourses = []; this.hasResults = false; this.isLoading = false; // Get active term drop zones this.dropZoneIds$ = this.store.pipe( select(selectors.selectAllVisibleYears), utils.yearsToDropZoneIds(), distinctUntilChanged(utils.compareStringArrays), ); // Get active terms to populate term select box this.activeTerms$ = this.store.pipe(select(selectors.getActiveTerms)); // Get observable for the search open state this.isCourseSearchOpen$ = this.store.pipe( select(selectors.isCourseSearchOpen), ); // Reset serach form when closed this.searchOpenSubscribe = this.isCourseSearchOpen$.subscribe(openState => { if (!openState) { this.resetSearchForm(); } }); // Get all subjects and set them to internal values this.store .pipe(select(selectors.getSubjectDescriptions)) .subscribe(subjects => { this.subjectMap = { [-1]: 'All', ...subjects }; const ordered = Object.entries(subjects) .map(subject => { return subject[1]; }) .sort(); this.subjects = ['all', ...ordered]; this.filteredSubjects = ['all', ...ordered]; }); // Deafults for the search form this.courseSearchForm = this.fb.group({ term: '0000', subject: 'All', search: '', }); // TODO Unsubscribe from this on component disconnect // Get observable for currently active term this.activeSelectedSearchTerm$ = this.store.pipe( select(selectors.getActiveSelectedSearchTerm), ); // Listen for changes on the active selected term and update the form when it changes this.termSubscription = this.activeSelectedSearchTerm$.subscribe( termCode => { const values = this.courseSearchForm.value; this.courseSearchForm.setValue({ ...values, term: termCode ? termCode.toString() : '0000', }); }, ); // Listen for changes to the term value (this.courseSearchForm.get( 'term', ) as AbstractControl).valueChanges.subscribe((term: string) => { if (this.hasResults) { const { search, subject } = this.courseSearchForm.value; this.search(search, term, subject); } }); // Listen for changes in the subject input (this.courseSearchForm.get( 'subject', ) as AbstractControl).valueChanges.subscribe(value => { // If the subject value is blank, or if the user is typing "all" if (/^(a[l]{0,2})?$/i.test(value.trim())) { this.filteredSubjects = this.subjects; return; } this.filteredSubjects = this.subjects.filter(subject => { // Make both comparisons lowercase and remove all whitespace const formattedSubject = subject.replace(/\s/g, '').toLowerCase(); const formattedValue = value.replace(/\s/g, '').toLowerCase(); if (formattedSubject.indexOf(formattedValue) === 0) { return true; } return false; }); }); } // Unsubscribe from subs to prevent memeory leaks public ngOnDestroy() { this.termSubscription.unsubscribe(); this.searchOpenSubscribe.unsubscribe(); } public subjectChange($event, input) { const { search, term, subject } = this.courseSearchForm.value; this.search(search, term, subject); // Focus is needed to keep the cursor at the start of the input after a selection is made input.focus(); } // Get a subject code from text public getSubjectCode(text): string | undefined { // Check if subject is valid const subjectEntry: [string, any] | undefined = Object.entries( this.subjectMap, ).find(option => option[1].toLowerCase() === text.toLowerCase()); return subjectEntry ? subjectEntry[0] : subjectEntry; } // Function that runs on form submit // Get form values and run a search public formSubmit() { const { search, term, subject } = this.courseSearchForm.value; this.search(search, term, subject); } // Run a search and display all results if the serach is valid public search(search, term, subject) { // Get the form field values // Check if subject is valid const subjectCode = this.getSubjectCode(subject); if (!subjectCode) { this.snackBar.open('Please select a valid subject', undefined, { duration: 6000, }); return; } // Set the internal UI state this.isLoading = true; this.hasResults = false; this.queriedCourses = []; // Hit the search API this.api .searchCourses({ subjectCode, searchText: search, termCode: term === '' ? '0000' : term, }) .toPromise() .then(res => { // TODO add error handeling // Update the internal state this.hasResults = true; this.isLoading = false; // Map out the results and update the course object to match the needed structure this.queriedCourses = res.hits.map(course => { return { ...course, subject: course.subject.description, subjectCode: course.subject.subjectCode, } as Course; }); }) .catch(console.log); } // Reset the search form values and clear results public resetSearchForm() { this.queriedCourses = []; this.hasResults = false; // Ensure that the search form has been created before attempting to set values if (this.courseSearchForm) { this.courseSearchForm.setValue({ subject: 'All', search: '', term: '0000', }); } } }