Build a Headless Ecommerce Product Filter with Angular and Cosmic

Community Articles
Community Articles Build a Headless Ecommerce Product Filter with Angular and Cosmic

* 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 - a headless ecommerce website - the purpose of which 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 product 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:

<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 headless 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.

Was this article helpful?

Thank you! Your input helps us improve our articles and resources. If you have any other questions or comments, please contact support.

You may also like


In this article I’m going demonstrate how to add new form submissions to Hubspot when you Publish Objects in your Cosmic Bucket.
Now editors can use the new Unsplash Photos Extension to find and add stock photography to their media library, all from the comfort of their Bucket Dashboard without having to interface with a third-party website like Unsplash.
Todo List app built using Svelte that utilizes the power of Cosmic.
In addition to Import / Export Features and Revision History, Cosmic has added Automatic Bucket Backups to deliver even more peace of mind to you and your content editing team. To add daily Backups to your Buckets go to Your Bucket Dashboard > Settings > Backups.
Cosmic allows developers to deploy their codebases to the Cosmic App Server for staging, testing and demo purposes, and is now offering new production deployment options available with some of your favorite deployment services.

Ready to Get Started?

Build personal projects for free. Add your team at unbeatable prices.
Start Now Contact Sales