Matt
Matt

Reputation: 463

Implement custom search relevancy using an Angular pipe

I currently have a basic course search feature with a custom pipe filter, stackblitz here. It returns results based on whether there exists a match in the course title, description or keywords, but it returns the courses in some arbitrary order. I'd like to modify the pipe to return more relevant results first, with the precedence being that the search query matched in 1. the title, 2. the description, 3. the keywords.

Some test case examples:

  1. The query "chinese" currently returns "1 French" before "1X Chinese" because there's a match in the keywords to "chinese" for "1 French".

  2. the query "linear algebra" returns 54 Math first before 89A statistics, even though the latter has a match in all 3 fields.

What would be the best way to go about implementing this? I'm currently looking at this response, which would mean I could try to define my own comparator with the following logic (in pseudocode)

orderByComparator(a:Course, b:Course):number{
    if(searchRelevancy(a) < searchRelevancy(b)) return -1;
    else if(searchRelevancy(a) > searchRelevancy(b)) return 1;
    else return 0;
  }

where searchRelevancy could be a function that calculates the rank of each course based on the query

searchRelevancy(course: Course, query: string): number{
   var rank: number;
   if(course.course_title.toUpperCase().includes(query)) rank += 5;
   if(course.description.toUpperCase().includes(query)) rank += 3;
   if(course.keywords.toUpperCase().includes(query)) rank += 1;
return rank
}

and the view would include

  <li *ngFor="let course of courses | courseFilter: searchText |
 orderBy: ['title', 'description', ''keywords]">

However, I'm relatively new to Angular and the implementation details were beyond what I can currently follow. Is this the most straightforward approach? If I know I'm only filtering course objects, is it possible to modify the current course-filter pipe without defining a second orderBy pipe?

Any pointers would be appreciated!

Upvotes: 1

Views: 393

Answers (1)

monty
monty

Reputation: 1590

You'll first want to add the rank to each Course and then order by that. i.e.

addRank(a: Course, query: string): number{

    var rank: number;
    if(course.course_title.toUpperCase().includes(query)) rank += 5;
    if(course.description.toUpperCase().includes(query)) rank += 3;
    if(course.keywords.toUpperCase().includes(query)) rank += 1;
    course.rank = rank

Then order by that rank in descending order:

<li *ngFor="let course of courses | addRank: searchText | orderBy: '-rank'">

However, Angular (unlike AngularJs/Angular 1) does not provide orderBy, or filter, and they suggest that you do these yourself in the component, rather than using pipes:

Angular doesn't provide pipes for filtering or sorting lists. Developers familiar with AngularJS know these as filter and orderBy. There are no equivalents in Angular.

This isn't an oversight. Angular doesn't offer such pipes because they perform poorly and prevent aggressive minification.

Ref: AngularPipes

Taking heed of this, you'd then likely use a separate courseResults list that is a filtered and ordered version of the courses array. You might initialise this to courses before you've entered any search/filter criteria of course. And you might also look at minimizing unnecessary search/filter calls by adding a debounce to your search input (if you're doing it as you type; if you're doing it on a button click then you'd skip that).

Simple Solution:

So, building on from your StackBlitz, I got rid of using the pipes altogether, making a new StackBlitz:

<input type='text' [(ngModel)]='searchText' placeholder=" Search a Topic, Subject, or a Course!" (keyup)="search($event)"> 

Where we don't display all courses, but courseResults...:

<ul>
    <li *ngFor="let course of courseResults">

... which is initialised to either empty or all courses (as here):

public courseResults: Course[] = this.courses;

And then the search can be:

search($event) {
  this.courseResults = this.sortByRank(this.rankAndFilter(this.courses, this.searchText.toUpperCase()));
}

private rankAndFilter(courses: Course[], query: string) {
  var result = [];
  var rank: number;

  for (let course of courses) {
    rank = 0;
    if(course.course_title.toUpperCase().includes(query)) {rank += 5};
    if(course.description.toUpperCase().includes(query)) {rank += 3};
    if(course.keywords.toUpperCase().includes(query)) {rank += 1};
    course.rank = rank;
    if (rank > 0) result.push(course);
  }      
  return result;
}

private sortByRank(courses: Course[]) {
  return courses.sort(function(a:Course,b:Course){
    return b.rank - a.rank;
  });
}

Extended Solution:

[Edit] A new forked StackBlitz that uses debounce (so doesn't do the whole filter and sort on each and every keystroke if the user is typing really fast) and distinctUntilChanged (if user backspaces a character, it won't bother), and created a global extension to Array (sortBy and sortByDesc).

So now we have the input:

<input type='text' #searchInput placeholder=" Search a Topic, Subject, or a Course!"> 

Then get that ViewChild as an ElementRef:

@ViewChild('searchInput') searchInputRef: ElementRef;

And then after init, we set up the observable on input on the native element and subscribe to it:

ngAfterViewInit() {
  var subscription = fromEvent(this.searchInputRef.nativeElement, 'input').pipe(
    map((e: KeyboardEvent) => (<HTMLInputElement>e.target).value),
    debounceTime(400),
    distinctUntilChanged()
    //switchMap(() => this.performSearch())
  );
  subscription.subscribe(data => {
    this.performSearch(data);
  });

}  

private performSearch(searchText) {
  console.log("Search: " + searchText);
  this.searchText = searchText;
  this.courseResults = this.rankAndFilter(this.courses, searchText).sortByDesc('rank'); 
  return this.courseResults;
}

Upvotes: 1

Related Questions