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'; import { DegreePlannerApiService } from '@app/degree-planner/services/api.service'; import { FormBuilder, FormGroup, AbstractControl } from '@angular/forms'; import { MatSnackBar } from '@angular/material'; import { Course, SubjectCodesTo, SubjectDescription, } from '@app/core/models/course'; import { TermCode } from '@app/degree-planner/shared/term-codes/termcode'; import { MediaMatcher } from '@angular/cdk/layout'; import { ConstantsService } from '../services/constants.service'; import { TermCodeFactory } from '../services/termcode.factory'; @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: ReadonlyArray<TermCode>; public activeSelectedSearchTerm$: Observable<TermCode | undefined>; public isCourseSearchOpen$: Observable<boolean>; // Internal variables to store subjects and filtered subjects list public subjects: string[]; public subjectMap: SubjectCodesTo<SubjectDescription>; public filteredSubjects: string[]; public termSubscription: Subscription; public searchOpenSubscribe: Subscription; public mobileView: MediaQueryList; private shouldClearSubject: boolean; constructor( private store: Store<GlobalState>, private fb: FormBuilder, private api: DegreePlannerApiService, private snackBar: MatSnackBar, public termCodeFactory: TermCodeFactory, mediaMatcher: MediaMatcher, public constants: ConstantsService, ) { this.mobileView = mediaMatcher.matchMedia('(max-width: 900px)'); this.shouldClearSubject = true; } public ngOnInit(): void { // Internal values used to manage loading state this.queriedCourses = []; this.hasResults = false; this.isLoading = false; this.activeTerms = this.termCodeFactory.active; // Get active term drop zones this.dropZoneIds$ = this.store.pipe( select(selectors.selectAllVisibleYears), utils.yearsToDropZoneIds(), distinctUntilChanged(utils.compareStringArrays), ); // 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 const subjectDescriptions = this.constants.allSubjectDescriptions(); this.subjectMap = { '-1': { short: 'All', long: 'All' }, ...subjectDescriptions, }; const ordered = Object.values(subjectDescriptions) .map(v => v.long) .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; // blur the element so that the field shows the first characters of long options input.blur(); this.search(search, term, subject); this.shouldClearSubject = true; } // Get a subject code from text public getSubjectCode(text: string): string | undefined { const pair = Object.entries(this.subjectMap).find(([_, value]) => { return value.long.toLowerCase() === text.toLowerCase(); }); if (pair === undefined) { return undefined; } else { return pair[0]; } } // 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) { // Check if subject is valid const subjectCode = this.getSubjectCode(subject); if (!subjectCode) { this.courseSearchForm.controls['subject'].setErrors({ invalid: true }); 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 => { // FIXME return ({ ...course, subjectCode: course.subject.subjectCode, } as unknown) as Course; }); }) .catch(console.log.bind(console)); } // 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: this.courseSearchForm.value.term, }); } } public clearSubject() { if (!this.shouldClearSubject) { return; } const { term, search } = this.courseSearchForm.value; this.shouldClearSubject = false; this.courseSearchForm.setValue({ subject: '', search, term, }); } }