Book Image

KNOCKOUTJS BLUEPRINTS

By : Carlo Russo
Book Image

KNOCKOUTJS BLUEPRINTS

By: Carlo Russo

Overview of this book

Table of Contents (12 chapters)
KnockoutJS Blueprints
Credits
About the Author
About the Reviewers
www.PacktPub.com
Preface
Index

Filters and product details


Now that you know how KnockoutJS works, we can continue working on the website.

The next step in the requirements is to add a way to filter the products by the category or by the name.

At the moment, we are showing all the products; to show only filtered products we have to:

  • Save the data from the server in a new variable called allCategories

  • Add a new observable to keep the selected category, and a new array to keep the list of the names of categories; we will add the "All" value using the optionsCaption binding handler (a binding handler dependent on the selected binding handler).

  • Add a new observable to keep the selected name

  • Create a computed observable to return the array of categories based on the selected category and the list of all categories

Before we start to write the code, I want to point out that there are two different ways to manage the last point in the preceding list. You can:

  • Use an observable array and, with an external computed observable, update that array

  • Use a computed observable which will do the job of filtering and then return an array

If you look at the documentation of KnockoutJS you will find:

Assuming your array is an observable array, whenever you later add, remove, or re-order array entries, the binding will efficiently update the UI to match - inserting or removing more copies of the markup, or re-ordering existing DOM elements, without affecting any other DOM elements. This is far faster than regenerating the entire foreach output after each array change.

The first observation after reading this is that the foreach binding handler uses the notification from the observable array to discover what changed.

But if you check the code of the binding, you will be astonished, because you will find an algorithm to check the difference between the current array and the previous one, ignoring all the notifications from the observable array.

For this reason, the previously pointed solutions work in the same way.

Let's update the View Model (js/index.js) with all the changes we need for the filters:

var myViewModel = {
  allCategories: ko.observableArray([]),
  selectedCategory: ko.observable(),
  selectedName: ko.observable("")
};

We will rename the old observable array, categories, as allCategories, and also add two observables to keep the new filter variable: selectedCategory and selectedName.

Note

ko.observable is another kind of Observable KnockoutJS gives us; when you declare an observable it keeps a list of observers, subscribed to any change of that value.

Other than using (getting and setting) the value in the observable, you can subscribe (using the method subscribe) to execute a callback each time the value changes. The result is like the computed observable, but you are responsible for deciding when to dispose the subscription.

To find more information you can look at the excellent documentation of this functionality.

Then, we will create our computed observable to return the filtered array. We cannot insert this code directly in the object creation because KnockoutJS will execute the code to pre-assign the value, so we need to define the other properties before this one:

myViewModel.categories = ko.computed(function() {
  var results = myViewModel.allCategories(),
      filterByCategory = myViewModel.selectedCategory();
  if (filterByCategory) {
    results = ko.utils.arrayFilter(results, function(category) {
      return category.name === filterByCategory;
    });
  }
  return results;
});

Tip

A good, easy way to improve the code, solving also this last point, is the use of the JavaScript Module Pattern. We are not using it here because this project is really simple, but if you don't know it, now is a good time to study it; it will help you to understand the next chapter better.

This code takes the list of categories (allCategories) and the selected filter (selectedCategory).

If a filter was selected, we assume that the first item in the list having a name equal to the selected category which we return.

Here, you can see we are using the function, ko.utils.arrayFilter. This is just one of the many helper functions you can find in the namespace, ko.utils. It is unlikely that they are not documented at all, so you can:

  • Search information on the web

  • Look at the non-minified source file (but pay attention, a few functions you find there are not accessible when you use the minified version)

With this function, we get a filtered array with any item having a name the same as our selected category.

Add the following property after the myViewModel.categories definition:

myViewModel.categoryName = ko.computed(function() {
  var results = ko.utils.arrayMap(myViewModel.allCategories(), function(category) {
    return category.name;
  });
  return results;
});

We will now add a list of the names of all the categories using the mapping function (ko.utils.arrayMap is good for "flattening" an array or extracting a subset of data into an array).

Then, we will put back the code to take the products from the server, but this time we will save them in the allCategories property.

$.getJSON("products.json", function(data) {
  myViewModel.allCategories(data.categories);
});

ko.applyBindings(myViewModel);

Now, we will use all these new properties in our View (index.html). We will add these rows after the first tag H2, to keep the filter on the top of the page:

  <div class="filter">
    <h2>Filter by</h2>
    <div class="category">
      Category: 
      <select data-bind="options: categoryName,
                         optionsCaption: 'All', 
                         value: selectedCategory"></select>
    </div>
    <div class="name">
      Name:
      <input type="text" data-bind="textInput: selectedName" />
    </div>
  </div>

We are binding three handlers to the select:

  • options: categories: A new option tag will be created for each item inside the array

  • optionsCaption: 'All': Another option with the selected text and undefined value will be added as first child of the select tag

  • value: selectedCategory: The value of the selected option will be bound to that property

We also bound the input with textInput: selectedName; this binding handler is a new introduction, added in version 3.2. In the previous version, you used a combination of value and updateValue:'input' but in the current version, as you can read in the documentation, this is the best way to get a two-way binding into the input field.

Tip

When you use the options binding handler, you can use two additional useful binding handlers: optionsText and optionsValue; they are useful when the items inside the array used with the options binding handler are JavaScript objects and you want to choose what to use as value (optionsValue) and as text (optionsText) of the option tag.

To complete the filtering code, we will change the following row:

      <div class="jewel">

By adding this data binding:

data-bind="visible: title.indexOf($root.selectedName()) !== -1"

Now, it will hide all the items not containing the text of the selected item inside the name.

The reason we are using $root is because here we are inside the foreach context, so we have to move to the parent context to find the property, selectedName.

Everything is done, so we can continue with the product details, right?

Wrong!

Look again at the last data bind: visible: title.indexOf($root.selectedName()) !== -1.

What's the problem with this code? I'm sure you have seen it many times in the past; so, why am I telling you it is wrong?

Do you remember when I suggested to you to avoid putting JavaScript code inside the HTML? It's the same reason.

KnockoutJS will take the code you put inside the binding and will evaluate it, so that code will work; but now it will become hard to test.

A good practice with data binding is to keep the logic inside the View Model (the main reason we have it) and to keep the View as dumb as you can.

We don't want to modify all the products to keep the information, and our models are simply objects, so we cannot modify the prototype. The easiest way to solve the problem is with a new function inside the View Model, referring the current object; we use it inside a data-bind context, so it will automatically become a computed one.

Let's change the data-bind we added earlier with this code:

data-bind="visible: $root.shouldShow($data)"

Then, we add this function in the object, myViewModel:

shouldShow: function(item) {
  return item.title.indexOf(myViewModel.selectedName()) !== -1;
}

Now, everything should work again, and our View is dumb again and your browser should show something like this:

If everything is working, you can change the selected category and the category sections should change.

We used the indexOf method to check for the text; if you want to make it case un-sensitive you can replace the following code:

return item.title.indexOf(myViewModel.selectedName()) !== -1;

With this code:

return new RegExp(myViewModel.selectedName(),"gi").test(item.title);

Product details

Now, we can add the page for product details. To keep this chapter simple, we will add a box with all the details, and we will show it when the customer hovers or clicks on the image of a product.

In this box, we will show all the details we decided earlier: the description, one or more big images, the price, and a button to add it to the cart.

First of all, we have to add a new property to keep the selected products. Add the following row to the View Model (js/index.js), after the allProducts definition:

selectedProduct: ko.observable(),

Then, add the code of the box to the View (index.html); put these lines before the first script tag:

<div class="selectedProduct" data-bind="with: selectedProduct">
  Prodotto selezionato:
  <div class="jewel">
    <div data-bind="text: title"></div>
    <!-- ko foreach: images -->
      <img data-bind="attr: { src: 'images/' + $data }">
    <!-- /ko -->
    <div data-bind="text: description"></div>
    <div data-bind="text: price"></div>
    <button>Add to cart</button>
  </div>
</div>

Here, we are using the with binding handler. This binding handler checks for the parameter, and creates a new child context with the parameter, only if the parameter is not null. In this way, we can be sure it will show the selected product only if we select one, and we avoid binding errors if the object does not exist.

In the block, we show the title, all the images, the description, and the price of the product.

With this code we can render the selected product, but we didn't put any way to select it. To do that we will add a new function in the View Model to select the item, another two to show/remove the item under the cursor, a click binding, a mouseout event binding, and a mouseover event binding to the preview tag to call these functions.

Let's add all these functions to the myViewModel object:

selectProduct: function(product) {
  myViewModel.selectedProduct.current = product;
},
showProduct: function(product) {
  myViewModel.selectedProduct.current =
    myViewModel.selectedProduct();
  myViewModel.selectedProduct(product);
},
hideProduct: function() {
  myViewModel.selectedProduct(
    myViewModel.selectedProduct.current);
}

Here, we are adding a property (named current) to the observable property, selectedProduct, to track which item the customer clicked on. In this way, each time the customer clicks on a preview, we will record it, and we will show it in the detail box; when the customer moves the cursor over a preview she/he will see the new detail, but it will go back to the selected one when the mouse exits from the preview.

Now, add the three binding handlers to the preview. Replace the following row:

<div class="jewel" data-bind="visible: $root.shouldShow($data)">

With these rows:

<div class="jewel"
     data-bind="visible: $root.shouldShow($data),
                click: $root.selectProduct,
                event: { 
                    mouseover: $root.showProduct,
                    mouseout: $root.hideProduct
                }">

The click binding handler registers a click event handler and when the event is fired you will get the View Model and the event as parameters. This is really useful when we are working in a child context, because we get the current item.

We should add here the click handler to the button, Add to Cart, but we will do this in the next section.

Now, when you move the mouse over a preview, or you click it, your browser should show something like this:

Managing a Cart

When you realize a web application, you can follow two different flows:

  • Classic web application

  • Single Page Application

If you realize an SPA, you'll probably need something like a library to manage the routing client-side, a way to load an external page with a View Model, and so on.

The best library you can find at the moment to create an SPA with KnockoutJS is DurandalJS. We will see this in another chapter.

If you go to the classic web application, you'll have many different pages, and you will have to apply the binding of KnockoutJS in each page needing it.

In this project, we will follow the classic web application flow, so we will add two new pages to manage the Cart and the Contact form.

Using the Cart on the home page

The modifications we make in the index page include:

  • Showing the links to the other pages

  • Creating a basket

  • Adding a handler to the Add to cart button

Adding a link to the other pages is easy to do, so we will start with this.

Add the following rows as the first child of the body (in index.html):

<div class="topbar">
  <a href="index.html">Home page</a>
  <a href="cart.html">Cart</a>
  <a href="contact_us.html">Contact us</a>
</div>

Then, to have a cleaner interface, move the whole following block before the H1 tag:

    <div class="filter">…</div>

Now, we have a navigation bar at the top with the link to the three pages of our application.

To keep this application simple, we will save the basket in the local storage. We are building a really simple application, but this is not a good reason to use a bad pattern, so we will not mix the model of the basket and the storage.

For this reason, we will create two new files:

  • js/basket.js

  • js/basket-localStorage.js

We do this because the best practice with Model is that it shouldn't have knowledge of its storage.

We can start with the model of the basket (js/basket.js), because at this time it is really simple:

function Basket() {
  this.products = ko.observableArray([]);
}

Basket.prototype.addToCart = function(product) {
  this.products.push(product);
};

Basket.prototype.removeFromCart = function(product) {
  this.products.remove(product);
};

Here, we are just enclosing an observable array to hide the implementation from outside.

Now, we will create the local storage file (js/basket-localStorage.js):

var basketLocalStorage = (function() {
  return {
    fetch: function(basket) {
      var json = localStorage.getItem("SimpleShowCase"),
          savedData = JSON.parse(json || "[]");

      ko.utils.arrayForEach(savedData, function(product) {
        basket.addToCart(product);
      });
    },
    save: function(basket) {
      var data = ko.toJSON(basket.products);
      localStorage.setItem("SimpleShowCase", data);
    }
  }; 
}());

This object now exposes two functions:

  • fetch: This takes from the local storage the array of products and adds it to the product list

  • save: This puts into the local storage the product list using the JSON format

A few notes about this last source code:

  • We are using localStorage; it's an easy way to save data locally, but it works only in modern browsers (so it's good for a simple project, but think twice about using it when you want to support IE6/IE7)

  • We are calling push on an observable array inside a loop; a better solution should put all the data in one call, because each call to push notifies all the observers, resulting in poor performance.

Now, in our main page (index.html) we can add a reference to these two new scripts; add these two rows just before the tag with the import of js/index.js:

<script type="text/javascript" src="js/basket-localStorage.js"></script>
<script type="text/javascript" src="js/basket.js"></script>

As the last step on this page, we have to bind the click on the button, Add to cart, to the function. Add the following data bind to the button Add to cart:

data-bind="click: $root.addToCart"

We have to make two modifications to the View Model, js/index.js. Add these two rows into the myViewModel object:

basket: new Basket(),
addToCart: function(product) {
  myViewModel.basket.addToCart(product);
  basketLocalStorage.save(myViewModel.basket);
}

In this way, we keep a basket ready and when the user adds an item to the cart we add it to the basket and then save changes in the storage.

We should also load the data from the basket, so add this row at the end of the file:

basketLocalStorage.fetch(myViewModel.basket);

Here, we ask the storage to load all the data into the basket.

With this last modification our main View is completed, managing all the requirements we had for it.

The Cart page

The cart page will be simpler than the index page. We will show all the elements we have inside the basket, and a button will give the user the option to remove elements from the cart.

Let's start with the Cart View (cart.html):

<!DOCTYPE html>
<html>
<head>
  <title>Jewelry Show Case - Cart</title>
  <link rel="stylesheet" href="css/styles.css" />
</head>
<body>
<div class="topbar">
  <a href="index.html">Home page</a>
  <a href="cart.html">Cart</a>
  <a href="contact_us.html">Contact us</a>
</div>

This is the standard boilerplate code for the View.

Let's continue with the container for the products:

<h1>Here you can find all the items in the cart.</h1>
<div data-bind="foreach: products">
  <div class="jewel" 
    <div data-bind="text: title"></div>
    <img data-bind="attr: { src: "images/" + thumb }">
    <div data-bind="text: description"></div>
    <div data-bind="text: price"></div>
    <button data-bind="click: $root.removeFromCart">
      Remove from the cart</button>
  </div>
</div>

Here, we are showing a list of products with their details, and a button to remove the jewelry from the cart.

<script type="text/javascript" src="js/jquery.js"></script>
<script type="text/javascript" src="js/knockout.js"></script>
<script type="text/javascript" src="js/basket-localStorage.js"></script>
<script type="text/javascript" src="js/basket.js"></script>
<script type="text/javascript" src="js/cart.js"></script>
</body>
</html>

Then, we will add all the scripts we need (it's really similar to the index page, isn't it?).

Now, let's see the View Model, js/cart.js. Here, you can find a few similarities to the other one, but not enough to justify a refactoring of the common code into a parent class:

var myViewModel = {
  basket: new Basket(),
  removeFromCart: function(product) {
    myViewModel.basket.removeFromCart(product);
    basketLocalStorage.save(myViewModel.basket);
        
    if (myViewModel.products().length === 0) {
      window.location.href = 'index.html';
    }
  }
};
myViewModel.products = myViewModel.basket.products;

basketLocalStorage.fetch(myViewModel.basket);

ko.applyBindings(myViewModel);

Our View Model references a Basket and exposes a removeFromCart function.

In this function, we are asking the basket to remove the product, and then saving the modification in the local storage. As the last step, when we empty the cart, we are redirecting the user to the home page.

We are exposing the products property of basket inside myViewModel because we want a dumb View; so, we should not ask the View to know the internal structure of the basket Model.

With this, we finally get the product list from the basket storage, with the call to basketLocalStorage.fetch.

Now we have a page to add the products to the basket, a page to look at and remove them from the basket. We are only missing a page to send a request to our friends to buy them.