Filtering an Array of Nested Arrays and Objects Using Angular Pipes and Highlighting the Results

Angular&NodeEnthusiast
The Startup
Published in
7 min readAug 21, 2020

If you would like to check the implementation of how to filter and sort an array of nested objects only, you can check the below story:

Searching through an array of objects and arrays is very similar to the above implementation,but with small changes.

Consider the below array of JSON objects and arrays exported from cakes.ts

//cakes.ts
export const cakes=[
{
“id”: “0001”,
“type”: “donut”,
“name”: “Cake”,
“ppu”: 0.56,
“batters”:{
“batter”:[
{ “id”: “1001”, “type”: “Regular” },
{ “id”: “1002”, “type”: “Chocolate” },
{ “id”: “1003”, “type”: “Blueberry” },
{ “id”: “1004”, “type”: “Devil’s Food” }]
},
“topping”:[
{ “id”: “5001”, “type”: “None” },
{ “id”: “5002”, “type”: “Glazed” },
{ “id”: “5005”, “type”: “Sugar” },
{ “id”: “5007”, “type”: “Powdered Sugar” },
{ “id”: “5006”, “type”: “Chocolate with Sprinkles” },
{ “id”: “5003”, “type”: “Chocolate” },
{ “id”: “5004”, “type”: “Maple” }]
},
{
“id”: “0002”,
“type”: “donut”,
“name”: “Raised”,
“ppu”: 0.12,
“batters”:{
“batter”:[
{ “id”: “1001”, “type”: “Strawberry” }]
},
“topping”:[
{ “id”: “5001”, “type”: “Vanilla” },
{ “id”: “5002”, “type”: “Mango” },
{ “id”: “5005”, “type”: “Cherry” },
{ “id”: “5003”, “type”: “Chocolate” },
{ “id”: “5004”, “type”: “Butterscotch” }]
},
{
“id”: “0003”,
“type”: “donut”,
“name”: “Old Fashioned”,
“ppu”: 0.34,
“batters”:{
“batter”:[
{ “id”: “1001”, “type”: “Regular” },
{ “id”: “1002”, “type”: “Vanilla” }
]
},
“topping”:[
{ “id”: “5001”, “type”: “None” },
{ “id”: “5002”, “type”: “Chocolate Chips” },
{ “id”: “5003”, “type”: “Black Currant” },
{ “id”: “5004”, “type”: “Pista” }
]}
]

It is an array of 3 JSON objects. Each object describes the batter and topping for a different type of Cake.

Each object contains properties having 4 types of values: String,Number,Object and Array.

Template:

<input type=”text” [(ngModel)]=”searchTerm” name=”searchTerm” placeholder=”Search”><ul>
<li *ngFor="let x of cakes|filter:searchTerm">
<div>
<span appHighlight [search]="searchTerm" [elemValue]="x.name">{{x.name}}-</span>
Type:<span appHighlight [search]="searchTerm" [elemValue]="x.type">{{x.type}}-</span>
PPU:<span appHighlight [search]="searchTerm" [elemValue]="x.ppu">{{x.ppu}}</span>
</div><span>Batters</span>
<ul>
<li *ngFor="let y of x.batters.batter">
<span appHighlight [search]="searchTerm" [elemValue]="y.id" >{{y.id}}:</span>
<span appHighlight [search]="searchTerm" [elemValue]="y.type">{{y.type}}</span>
</li>
</ul>
<span>Toppings</span>
<ul>
<li *ngFor="let z of x.topping">
<span appHighlight [search]="searchTerm" [elemValue]="z.id">{{z.id}}:</span>
<span appHighlight [search]="searchTerm" [elemValue]="z.type">{{z.type}}</span>
</li>
</ul>
</li>
</ul>

We have constructed a list with the above JSON data.

appHighlight is the selector for a directive that highlights the search results in the list below.

List Search

Component Class:

import {cakes} from '../cakes';export class AppComponent {
searchTerm:string=””;
cakes=[...cakes];
}

The above class is self-explanatory. We have declared and initialized the ngModel searchTerm and the cakes array.

Now lets check the HighlightDirective.

@Directive({selector: ‘[appHighlight]’})export class HighlightDirective {
@Input() search:any;
@Input('elemValue') elemValue: any;

@HostBinding('style.backgroundColor') bgColor: string = '';
constructor() {}setBackgroundColor(search){
if(search){
let value = this.elemValue;
let newValue=
isNaN(value)?value.toString().toUpperCase():value.toString();
let newSearch=
isNaN(search) ? search.toString().toUpperCase():search.toString();
this.bgColor = newValue.indexOf(newSearch) > -1 ? 'yellow' : 'transparent';
}
else{
this.bgColor = 'transparent';
}
}
ngOnChanges(change:SimpleChanges){
if(change.search){
this.setBackgroundColor(change.search?.currentValue);
}}
}

The searchTerm value is passed to the Directive via Input binding. Whenever we enter any searchTerm, ngOnChanges hook is triggered, which calls setBackgroundColor().

In this method,we are checking if the list element content(elemValue) matches the searchTerm or not. If there is a match, we are highlighting the entire element with yellow color.

We have not accessed the list element content via ElementRef in order to avoid accessing the DOM directly. Instead I have passed the list content via @Input() elemValue property binding.

Lets now check the Filtering functionality.

import { PipeTransform, Pipe} from ‘@angular/core’;
import { filter } from ‘rxjs/operators’;
import * as _ from ‘lodash/fp’;
@Pipe({name:’filter’})export class FilterPipe implements PipeTransform{
transform(items,searchTerm){
if(searchTerm){let newSearchTerm=!isNaN(searchTerm)? searchTerm.toString(): searchTerm.toString().toUpperCase();return items.filter(item=>{
return this.lookForNestedObject(item,newSearchTerm);
})
}
else{return items;}
}
lookForNestedObject(item,newSearchTerm){
let origItem={...item};
let that=this;
let count=0;
parseNestedObject(item);
function parseNestedObject(item){
for(let key in item){
if(_.isPlainObject(item[key])){
if(origItem[key]) { delete origItem[key]}
parseNestedObject(item[key]);
}
else if(Array.isArray(item[key])){
if(origItem[key]) { delete origItem[key]}
parseNestedObject(item[key]);
}
else{
count++;
if(origItem[key]) { delete origItem[key]}
origItem[key+count]=item[key];
}}
}
return that.search(item,origItem,newSearchTerm);
}
search(item,origItem,newSearchTerm){
let filteredList=[];
let prop=””;
for(let koy in origItem){prop=isNaN(origItem[koy]) ? origItem[koy].toString().toUpperCase() : origItem[koy].toString();if(prop.indexOf(newSearchTerm) > -1){
filteredList.push(item);
return filteredList;
}}
}
}

To help us in this task, I have used the lodash package. You can install the package using:

npm install — save lodash

Import the package in the FilterPipe Class as:

import * as _ from ‘lodash/fp’;

The class has defined 3 methods.

The transform() needs to be defined because the class implements the PipeTransform Interface. It accepts the cakes array(named as items)and the searchTerm as arguments.

transform(items,searchTerm){
if(searchTerm){let newSearchTerm=!isNaN(searchTerm)? searchTerm.toString(): searchTerm.toString().toUpperCase();return items.filter(item=>{
return this.lookForNestedObject(item,newSearchTerm);
})
}
else{return items;}
}
  1. Only if the searchTerm has a non-null value, we shall be performing a search, else we shall return the original users list as it is back to the component.
if(searchTerm){
......Search functionality......
}
else{
return items;
}

Now lets check when there is a non-null searchTerm. We have the below logic.

let newSearchTerm=!isNaN(searchTerm)? searchTerm.toString(): searchTerm.toString().toUpperCase();return items.filter(item=>{
return this.lookForNestedObject(item,newSearchTerm);
})

2. If the searchTerm is numeric, convert it into a string and if it is non-numeric,convert it into a string and into uppercase.We assign the modified value to a variable newSearchTerm.

3. Next we are applying the filter method to the users array. The argument item of the filter method will be an element of the array. Each item along with the newSearchTerm is passed into the next method lookForNestedObject().

This method could be slightly tricky, where the concept of closures has been used.

lookForNestedObject(item,newSearchTerm){
let origItem={...item};
let that=this;
let count=0;
parseNestedObject(item);
function parseNestedObject(item){
for(let key in item){
if(_.isPlainObject(item[key])){
if(origItem[key]) { delete origItem[key]}
parseNestedObject(item[key]);
}
else if(Array.isArray(item[key])){
if(origItem[key]) { delete origItem[key]}
parseNestedObject(item[key]);
}
else{
count++;
if(origItem[key]) { delete origItem[key]}
origItem[key+count]=item[key];
}}
}
return that.search(item,origItem,newSearchTerm);
}
  1. We are storing a copy of the item object into another block scope variable origItem, because we want to keep the item object intact. Any kind of manipulation will be done to origItem. count will help us later in creating unique properties when restructuring the origItem.
let origItem={…item};
let count=0;

2. Closures revolve around the concept of inner functions. We have defined another function named parseNestedObject() within the lookForNestedObject(). The purpose of this function is to identify all the nested objects and and arrays in the parent object, delete the nested object and array from the parent object but add the nested object’s properties and array values back to the parent object.

This gives us a single object with properties having only string or numeric or date values. There are no properties having objects or arrays as values.

parseNestedObject(item);function parseNestedObject(item){
for(let key in item){
if(_.isPlainObject(item[key])){
if(origItem[key]) { delete origItem[key]}
parseNestedObject(item[key]);
}
else if(Array.isArray(item[key])){
if(origItem[key]) { delete origItem[key]}
parseNestedObject(item[key]);
}
else{
count++;
if(origItem[key]) { delete origItem[key]}
origItem[key+count]=item[key];
}}
}

The function iterates over the item object and checks if any property contains another object or an array as a value.

Lodash method _.isPlainObject() helps us identify if the property’s value is a plain object literal or not.

Array.isArray() helps us identify if the property’s value is an array or not.

If its an array or an object, we perform 2 actions:

=>Delete the property from the origItem.

=>Call the parseNestedObject() again passing the property’s value i.e object literal or the array as argument.

Keep repeating this until we reach a point where no property has object or array as value. Once that point is reached, control goes to the else condition and we again perform 3 actions:

count++;
if(origItem[key]) { delete origItem[key]}
origItem[key+count]=item[key];

=>Increment the count.

=>Delete the property from the origItem.

=>Again add the property and its string/numeric/date value to the origItem. Its important to append the count value to the original property value because the nested objects in each JSON cake object have same property names:id and type.

We really dont want the property values to get overwritten.

The purpose is to reconstruct the entire object to help us in the search task.

3. Finally call the search() passing the intact item object, reconstructed origItem object and the newsearchTerm as argument.

search(item,origItem,newSearchTerm){
let filteredList=[];
let prop=””;
for(let koy in origItem){prop=isNaN(origItem[koy]) ? origItem[koy].toString().toUpperCase() : origItem[koy].toString();if(prop.indexOf(newSearchTerm) > -1){
filteredList.push(item);
return filteredList;
}}
}

Here are are iterating through the reconstructed origItem to check if a property’s value is non-numeric. If yes, convert it into string and then uppercase. If not, just convert it into string. We assign the modified property’s value to a block variable prop.

We are checking if the prop contains a portion or the entire newsearchTerm using the indexOf operator.

If yes, we add the object item whose property value includes the newsearchTerm to an array filteredList and return it back.

Searching for string “va”
Searching for string “sp”
Searching for number 12

You can check the entire working below:

--

--

Angular&NodeEnthusiast
The Startup

Loves Angular and Node. I wish to target issues that front end developers struggle with the most.