Using A Single, Pre-Compiled Keyword Search Target For Filtering In Angular 10.1.5
The other day, I picked up a cool Angular trick from fellow InVision engineer, Josh Siok: when performing a keyword-based search on a given collection, he will pre-compile a "keywords" String for each item. Then, when he goes to perform the keyword-based filtering, he only has to inspect the one pre-compiled value. At work, we do a lot of in-page filtering; as such, I wanted to explore this approach in Angular 10.1.5.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
To explore this idea, I'm going to take a collection of Friends and wrap it in another Array
for safer mutations. This way, I can augment the wrapper-Array
without worrying about corrupting the underlying data, which may be referenced by other parts of my application. In this case, the underlying friends collection uses this TypeScript interface:
interface Friend {
id: number;
name: string;
isBFF: boolean;
hobbies: string[];
}
And, the wrapper-Array
that will render the search results uses this TypeScript interface:
interface SearchResult {
friend: Friend;
sort: string;
keywords: string;
}
As you can see, the wrapper-Array
is going to contain two search-based properties:
sort
keywords
Both of these values represent a pre-compiled data-point that aggregates values from within the embedded friend
payload. The former determines how the results will be sorted, bubbling BFF (Best-Friends Forever) to the top; and, the latter determines which results will match a given search query, incorporating the Name, BFF, and Hobby values.
Functionally speaking, we're going to have this:
Sort = stringify( isBFF + Name )
Keywords = stringify( Name + isBFF + Hobbies )
Here's what this looks like in my App component - the wrapper-Array
is computed in setAllSearchResults()
method and the filtering is then applied in the setFilteredSearchResults()
method:
// Import the core angular services.
import { Component } from "@angular/core";
// Import the application components and services.
import { Friend } from "./friends";
import { friends } from "./friends";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
interface SearchResult {
friend: Friend;
sort: string;
keywords: string;
}
@Component({
selector: "app-root",
styleUrls: [ "./app.component.less" ],
templateUrl: "./app.component.html"
})
export class AppComponent {
public allSearchResults!: SearchResult[];
public filteredSearchResults!: SearchResult[];
public searchFilter: string;
private friends: Friend[] = friends;
// I initialize the app component.
constructor() {
this.searchFilter = "";
this.setAllSearchResults();
this.setFilteredSearchResults();
}
// ---
// PUBLIC METHODS.
// ---
// I update the filtered search results to use the given filter.
public applySearchFilter( searchFilter: string ) : void {
this.searchFilter = searchFilter.trim();
this.setFilteredSearchResults();
}
// ---
// PRIVATE METHODS.
// ---
// I setup the all-results collection based on the current friends.
private setAllSearchResults() : void {
this.allSearchResults = this.friends.map(
( friend ) => {
// When we sort the results, we want to bubble the BFFs to the top. In
// order to simplify this operation - treating it as an alpha-numeric
// sort - we're going to prefix the calculated sort value with a string
// that separates out the two cohorts.
var sortPrefix = ( friend.isBFF )
? "a|"
: "z|"
;
// Now, the sortable target will be implicitly sorted by BFF first and
// then Name second.
var sort = ( sortPrefix + friend.name ).toLowerCase();
// When the user searches the list, we want them to be able to search
// across a variety of data-points. In order to simplify this operation,
// we're going to pre-compile a "keywords" payload that aggregates all of
// the targeted data-points.
var keywords = [ friend.name ]
.concat( friend.hobbies )
.concat( friend.isBFF ? "bff" : "" )
.join( "\n" )
.toLowerCase()
;
return({
friend: friend,
sort: sort,
keywords: keywords
});
}
);
// Note that our in-place sort just uses the pre-compiled "sort" property - it
// doesn't need to inspect the "friend" object at this point.
this.allSearchResults.sort(
( a, b ) => {
return( a.sort.localeCompare( b.sort ) );
}
);
}
// I setup the filtered-results collection based on the current all-results.
private setFilteredSearchResults() : void {
var normalizedFilter = this.searchFilter.toLowerCase();
// If there is no search-filter, then we can just reset the filtered-results to
// be the all-results collection.
if ( ! normalizedFilter ) {
this.filteredSearchResults = this.allSearchResults;
return;
}
// Note that when we apply the filter against the search result, we only have to
// examine the pre-compiled "keywords" value - we don't have to start searching
// across a number of embedded properties - that work has already been done.
this.filteredSearchResults = this.allSearchResults.filter(
( result ) => {
return( result.keywords.includes( normalizedFilter ) );
}
);
}
}
As you can see, by pre-compiling the sort
and keywords
payload, our subsequent .sort()
and .filter()
operations become dead simple, respectively:
return( a.sort.localeCompare( b.sort ) );
return( result.keywords.includes( normalizedFilter ) );
No messing around with different properties, no digging into embedded objects - we just use the single, pre-compiled String
values. Easy peasy!
Here's the HTML view template for this component:
<p>
<input
#searchFilterRef
type="search"
placeholder="Search friends..."
autofocus
autocomplete="off"
class="filter"
(input)="applySearchFilter( searchFilterRef.value )"
/>
</p>
<!--
When we output the list, we're outputting the FILTERED search results, not the ALL
search results.
-->
<ul class="results">
<li
*ngFor="let result of filteredSearchResults"
class="results__result"
[class.results__result--highlight]="result.friend.isBFF">
<div class="results__name">
{{ result.friend.name }}
</div>
<div *ngIf="result.friend.hobbies.length" class="hobbies">
<span class="hobbies__label">
Hobbies:
</span>
<span
*ngFor="let hobby of result.friend.hobbies"
class="hobbies__hobby">
{{ hobby }}
</span>
</div>
</li>
</ul>
<div
*ngIf="( ! filteredSearchResults.length )"
class="no-results">
None of your {{ allSearchResults.length }} friends match
your current search query.
</div>
Now, if we run this Angular 10 application and we try to search for the following keywords:
brooke
-friend.name
based search.bff
-friend.isBFF
based search.golf
-friend.hobbies
based search.
... we get the following browser output:
As you can see, we were able to successfully locate matching friend results using the keyword-based search. Also note that the BFF results always bubbled to the top in the sort.
Obviously, keyword-based searching provides "fuzzy" matches. As such, you may want other techniques in place to provide more exact matching on specific properties. But, for a simple, open-ended search in Angular 10.1.5, this seems like a really easy and elegant solution.
Want to use code from this post? Check out the license.
Reader Comments
@All,
A quick follow-up post to this, once you have two different arrays - one for the "all" results and one for the "filtered" results - it means that you can start to add a progressive-search optimization with next-to-no effort:
www.bennadel.com/blog/3910-using-a-progressive-search-optimization-when-filtering-arrays-in-angular-10-1-6.htm
This approach uses the filtered search results as the target for each subsequent search operation as long as the user is "typing forward".