DEV Community

Diego Perez
Diego Perez

Posted on • Edited on

Build an Ecommerce Product Filter with Angular and Cosmic

Build an 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: 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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)));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
Enter fullscreen mode Exit fullscreen mode

*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$;
  }
Enter fullscreen mode Exit fullscreen mode

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();
      }
    );
  }
...
Enter fullscreen mode Exit fullscreen mode

*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>

Enter fullscreen mode Exit fullscreen mode

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();
  }
Enter fullscreen mode Exit fullscreen mode

* 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);
  }
Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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 : [];
    });
  }
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)