Build an Ecommerce Product Filter with Angular and Cosmic
Diego Perez
Posted on October 13, 2020
* This article will assume some basic knowledge of Angular and CMS so it can focus on the specific task at hand. Feel free to ask me about any specifics on the implementation you may find unclear
TL; DR
Take a look at:
What are we going to build?
This site will be based on a previous example: an ecommerce website which purpose is to show how we can offer a customized experience for everyone. I strongly recommend that you read the first article, as we will work on top of what was built there. This time, we will add filtering functionality to showcase the Cosmic Advanced Queries feature. Our data will be stored and served by Cosmic and we will use Angular for our Front-End.
Preparing our bucket
The first thing we'll do is prepare our Cosmic bucket. We already have the following three object types:
- Categories
- Products
- Users
Each product
now will include a color
attribute, and each category
will include a isRoot
attribute. These attributes will give us more to work with when building the filters.
We will also create a new type:
- Price filters
Each price filter
will have a min
and max
attribute. This new type will allow us to define price ranges to then use in the filter. There are other options to do this, as we could directly filter by all the different prices contained in the products, but this approach will give us (and the potential editor/merchandiser setting everything up) more flexibility on what we want to show the customer.
If you are as lazy as I am, you can always replicate the demo bucket by installing the app.
Updating the models
We need to reflect the changes to the bucket into our models. This will be the model for the price filters:
export class PriceFilter {
_id: string;
slug: string;
title: string;
max: number;
min: number;
constructor(obj) {
this._id = obj._id;
this.slug = obj.slug;
this.title = obj.title;
this.max = obj.metadata.max;
this.min = obj.metadata.min;
}
}
And, of course, we need to also update our product and category models:
import { Category } from './category';
export class Product {
_id: string;
slug: string;
title: string;
price: string;
categories: Category[];
image: string;
color: string;
constructor(obj) {
this._id = obj._id;
this.slug = obj.slug;
this.title = obj.title;
this.price = obj.metadata.price;
this.image = obj.metadata.image.url;
this.color = obj.metadata.color;
this.categories = [];
if (obj.metadata && obj.metadata.categories) {
obj.metadata.categories.map(category => this.categories.push(new Category(category)));
}
}
}
export class Category {
_id: string;
slug: string;
title: string;
isRoot: boolean;
constructor(obj) {
this._id = obj._id;
this.slug = obj.slug;
this.title = obj.title;
this.isRoot = obj.metadata ? obj.metadata.root : false;
}
}
Modifying the service
In order to take full advantage of the advanced queries, we will create a new method on our service:
getProductsByQuery(query?: string): Observable<Product[]> {
if (!this.products$.get(query)) {
const querystring = query ? '&query=' + query : '';
const response = this.http.get<Product[]>(this.productObjectsUrl + '&sort=random' + querystring).pipe(
tap(_ => console.log('fetched products')),
map(_ => {
if (_['objects']) {
return _['objects'].map(element => new Product(element));
}
}),
shareReplay(1),
catchError(this.handleError('getProducts', []))
);
this.products$.set(query, response);
}
return this.products$.get(query);
}
*Note that the only difference with the old getProducts()
is the inclusion of the optional query
parameter.
Also, let's create a method to get our new price filters:
private priceFiltersUrl = this.objectTypePath + '/pricefilters';
private priceFilters$: Observable<PriceFilter[]>;
getPriceFilters(): Observable<PriceFilter[]> {
if (!this.priceFilters$) {
this.priceFilters$ = this.http.get<PriceFilter[]>(this.priceFiltersUrl).pipe(
tap(_ => console.log('fetched price filters')),
map(_ => {
return _['objects'].map(element => new PriceFilter(element));
}),
shareReplay(1),
catchError(this.handleError('getPriceFilters', []))
);
}
return this.priceFilters$;
}
Creating the filter component
Now we have a method to query products in an advanced manner, but we still need to construct the query, so let's build a component to allow the user to select the different filtering options.
We want to allow the customer to select different categories, colors and price ranges, for that, we will subscribe to our service and assign the results to a map that will store a pair of object, boolean
; that way we can keep track of the user selections.
export class FilterComponent implements OnInit {
public rootCategoryList: Map<Category, boolean> = new Map<Category, boolean>();
public categoryList: Map<Category, boolean> = new Map<Category, boolean>();
public colorList: Map<string, boolean> = new Map<string, boolean>();
public priceList: Map<PriceFilter, boolean> = new Map<PriceFilter, boolean>();
@Output() selectedFilters = new EventEmitter<string>();
constructor(private cosmicService: CosmicService) {}
ngOnInit() {
forkJoin(this.cosmicService.getCategories(), this.cosmicService.getProducts(), this.cosmicService.getPriceFilters()).subscribe(
([categories, products, priceFilters]) => {
// categories
categories.forEach(cat => {
cat.isRoot ? this.rootCategoryList.set(cat, false) : this.categoryList.set(cat, false);
});
// colors
const colorSet = new Set<string>(); // Using a Set will automatically discard repeated colors
products.forEach(p => colorSet.add(p.color));
colorSet.forEach(c => {
this.colorList.set(c, false);
});
// prices
priceFilters.forEach(pf => this.priceList.set(pf, false));
this.updateSelectedFilters();
}
);
}
...
*The reasoning behind dividing categories between root/no-root is because I want to provide the user with a visual hint as to what this categories model looks like, but it's not relevant to the task.
Now, this is how the html will look like:
<ul>
<li class="mb-3" *ngFor="let category of rootCategoryList | keyvalue">
<label class="radio is-size-4" >
<input type="checkbox" value="{{category.key.slug}}" [checked]="category.value" (change)="filterRootCategory(category)">
<span class="pl-2">{{category.key.title}}</span>
</label>
</li>
</ul>
<hr/>
<ul>
<li class="mb-3" *ngFor="let category of categoryList | keyvalue">
<label class="checkbox is-size-4" >
<input type="checkbox" value="{{category.key.slug}}" [checked]="category.value" (change)="filterCategory(category)">
<span class="pl-2">{{category.key.title}}</span>
</label>
</li>
</ul>
<hr/>
<ul>
<li class="mb-3 color-item" *ngFor="let color of colorList | keyvalue">
<label class="checkbox is-size-4">
<input type="checkbox" value="{{color.key}}" [checked]="color.value" (change)="filterColor(color)">
<span [style.background-color]="color.key"></span>
</label>
</li>
</ul>
<hr/>
<ul>
<li class="mb-3" *ngFor="let price of priceList | keyvalue">
<label class="checkbox is-size-4" >
<input type="checkbox" value="{{price.key.slug}}" [checked]="price.value" (change)="filterPrice(price)">
<span class="pl-2">{{price.key.title}}</span>
</label>
</li>
</ul>
All the change events look the same, they just mark the element as selected/unselected on the map (this is bound to the checkbox value, so there is no need to modify the DOM manually) and trigger a filter update:
filterCategory(entry: { key: Category; value: boolean }) {
this.categoryList.set(entry.key, !entry.value);
this.updateSelectedFilters();
}
* And so on...
Now, let's look at updateSelectedFilters()
. This method will review what's currently selected on the maps (thanks to the help of aux methods setCategoryFilterSelection()
, etc. and build our query.
updateSelectedFilters() {
// categories
const catInSelection: string[] = [];
const catNotInSelection: string[] = [];
this.setCategoryFilterSelection(this.categoryList, catInSelection, catNotInSelection);
this.setCategoryFilterSelection(this.rootCategoryList, catInSelection, catNotInSelection);
// colors
const colorInSelection: string[] = this.setColorFilterSelection(this.colorList);
// price
const pricesInSelection: number[][] = this.setPriceFilterSelection(this.priceList);
// query
let jsonObj = {};
if (catInSelection.length > 0 && catNotInSelection.length > 0) {
jsonObj['metadata.categories'] = {
$in: catInSelection,
$nin: catNotInSelection
};
}
if (colorInSelection.length > 0) {
jsonObj['metadata.color'] = { $in: colorInSelection };
}
if (pricesInSelection.length > 0) {
jsonObj['$or'] = [];
pricesInSelection.forEach(price => {
jsonObj['$or'].push({
$and: [
{
'metadata.price': {
$gte: price[0]
}
},
{
'metadata.price': {
$lte: price[1]
}
}
]
});
});
// Introducing "$or" means we need to combine with an "$and" for the other conditions
const auxObj = { $and: [] };
auxObj.$and.push(
{ "'metadata.categories": jsonObj['metadata.categories'], 'metadata.color': jsonObj['metadata.color'] },
{ $or: jsonObj['$or'] }
);
jsonObj = auxObj;
}
const query = encodeURIComponent(JSON.stringify(jsonObj));
this.selectedFilters.emit(query);
}
Wrapping it all together
Did you notice we are emitting the query? Now it's time to go to our product listing and modify how it request the products to accommodate all the changes we made. First of all, let's update the HTML to include our new filter component.
<div class="columns">
<div class="column is-one-fifth filters">
<app-filter (selectedFilters)="onChangeFilters($event)"></app-filter>
</div>
<div class="column columns" *ngIf="productList && user">
<ng-container *ngFor="let product of (productList | customSort:user.interests)">
<div class="product-tile column is-one-third">
<img src="{{ product.image }}" class="image"/>
<div class="level is-size-4 is-uppercase">
<span class="level-item">{{product.title}}</span>
<span class="level-item has-text-weight-bold">${{product.price}}</span>
</div>
<app-actions [product]="product"></app-actions>
</div>
</ng-container>
<div *ngIf="productList.length === 0">
<span>There are no products that match your search, try something else.</span>
</div>
</div>
</div>
Now we just need to define the method for our selectedFilters
event, it looks like this:
onChangeFilters(selectedFilters: string) {
this.cosmicService.getProductsByQuery(selectedFilters).subscribe(products => {
this.productList = products ? products : [];
});
}
And that's all. With just a couple updates on our previous eCommerce application, we've been able to add a pretty powerful filter component that would help our customers find the product they are looking for.
Interested in more articles like this? Check out Cosmic articles for more tutorials like this one, or join us in the Slack community, where hundreds of devs like you are discussing the future of Headless websites.
Posted on October 13, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.