Skip to content
Snippets Groups Projects
course-search.component.ts 7.41 KiB
Newer Older
import { Observable, Subscription } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';
import { Store, select } from '@ngrx/store';
Scott Berg's avatar
Scott Berg committed
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';

@Component({
  selector: 'cse-course-search',
  templateUrl: './course-search.component.html',
  styleUrls: ['./course-search.component.scss'],
})
export class CourseSearchComponent implements OnInit, OnDestroy {
Scott Berg's avatar
Scott Berg committed
  @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<string[]>;
  public activeSelectedSearchTerm$: Observable<string>;
  public isCourseSearchOpen$: Observable<boolean>;

  // Internal variables to store subjects and filtered subjects list
Scott Berg's avatar
Scott Berg committed
  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 => {
Scott Berg's avatar
Scott Berg committed
        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 });
      },
    );

    // Listen for changes to the term value
    (this.courseSearchForm.get(
      'term',
    ) as AbstractControl).valueChanges.subscribe(term => {
      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;
      }

Scott Berg's avatar
Scott Berg committed
      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) {
Scott Berg's avatar
Scott Berg committed
          return true;
Scott Berg's avatar
Scott Berg committed
        return false;
      });
    });
  }

  // Unsubscribe from subs to prevent memeory leaks
  public ngOnDestroy() {
    this.termSubscription.unsubscribe();
    this.searchOpenSubscribe.unsubscribe();
  }

Scott Berg's avatar
Scott Berg committed
  public subjectChange($event, input) {
    console.log(input);

    const subject = $event.source.value;
    const { search, term } = 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
Scott Berg's avatar
Scott Berg committed
    console.log(subject);
Scott Berg's avatar
Scott Berg committed
    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,
Isaac Evavold's avatar
Isaac Evavold committed
          } 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',
      });
    }
  }
}