knockout cart
By eidias on (tags: javascript, knockout, categories: code, web)I recently had an opportunity to put some new knockout knowledge into use. Tekpub has a very nice training video on the subject, which was a great starting point, so let me show you the result.
First some quick requirements:
- Every product has a button that adds a fixed number of items (in my case 1) to the cart
- There is a separate cart page
- The cart has a possibility to change the number of items of the same type/product
- The cart has a possibility to remove items from it
- The cart has a possibility to finalize the purchase (obviously)
So these are pretty common and straightforward e-commerce requirements, here I’m going to focus on the front end part only.
First thing we need is the add to cart functionality. This can be done in a few ways – cookies, session, local storage, user account (if user is logged in) but ultimately it boils down to making a single call - make it a http/ajax request or javascript method call. In my case, it’s an ajax call that looks like this:
1: $('.add-to-cart').click(function () {
2: var $this = $(this);
3:
4: $.get($this.attr('href'), null, function (data) {
5: $this.replaceWith(data);
6: });
7:
8: return false;
9: });
You can use POST instead of GET, I figured that it makes more sense in this case, though there’s not much difference anyway (semantic wars aside), as long as you handle it properly on the server side.
The next thing to handle is the cart. Let’s start with the markup
1: <table>
2: <thead>
3: <tr>
4: <th>@Literals.Product</th>
5: <th>@Literals.Price</th>
6: <th>@Literals.Quantity</th>
7: <th></th>
8: </tr>
9: </thead>
10: <tbody data-bind="foreach: items">
11: <tr>
12: <td data-bind="text: description"></td>
13: <td data-bind="text: price"></td>
14: <td><input type="text" class="digits" data-bind="value: quantity, valueUpdate: 'keyup'"/></td>
15: <td><a class="remove" data-bind="click: $parent.removeClicked" href="#" title="@Literals.RemoveFromCart">x</a></td>
16: </tr>
17: </tbody>
18: <tfoot>
19: <tr>
20: <td>Total</td>
21: <td data-bind="text: total()"></td>
22: <td></td>
23: <td></td>
24: </tr>
25: </tfoot>
26: </table>
As you can see, this displays the product description, its price and the quantity that can be changed. It also has a button to remove a product entirely.
To be able to handle that, we need to create a view model and some logic to handle the cart itself. Here’s the view model:
1: var site = site || {};
2: site.CartItem = function (options, callback) {
3:
4: var item = {};
5: var qty = (options.quantity || 1);
6:
7: item.id = options.id || '';
8: item.productId = options.productId || '';
9: item.quantity = ko.observable(qty).asPositiveInteger(1);
10: item.description = options.description || '';
11: item.price = options.price || 0;
12:
13: item.lineTotal = ko.computed(function () {
14: return item.quantity() * item.price;
15: });
16:
17: if (callback) {
18: item.quantity.subscribe(function () {
19: callback.call(item, item);
20: });
21: }
22:
23: return item;
24: };
and the Cart:
1: site.Cart = function () {
2: var self = this,
3: stored = JSON.parse($('#cart-content').val()) || [];
4:
5: self.items = ko.observableArray();
6:
7: //remove an item if quantity is 0, passed to CartItem
8: self.itemQuantityCheck = function (item) {
9: $.get(site.data.updateCartUrl + '?id=' + item.productId + '&configId=' + item.id + '&quantity=' + item.quantity(), function() {
10: if (item && item.quantity() === 0) {
11: self.items.remove(item);
12: }
13: });
14: };
15:
16: //send the items that we load from storage through the CartItem constructor
17: self.items(ko.utils.arrayMap(stored, function (item) {
18: return site.CartItem(item, self.itemQuantityCheck);
19: }));
20:
21: self.remove = function (id) {
22: self.items.remove(function (item) {
23: return item.id == id;
24: });
25: };
26:
27: self.removeClicked = function (item) {
28: $.get(site.data.removeFromCartUrl + '?id=' + item.productId + '&configId=' + item.id, function(data) {
29: if (data) {
30: self.remove(item.id);
31: }
32: });
33: };
34:
35: self.total = function () {
36: var sum = 0;
37: ko.utils.arrayForEach(self.items(), function (item) {
38: sum += item.lineTotal();
39: });
40: return sum;
41: };
42:
43: self.hasItems = ko.computed(function () {
44: return self.items().length > 0;
45: });
46:
47: return self;
48: };
There are 2 pieces of code missing in order to make the whole thing work. The first is asPositiveInteger extension for ko.observable. You can get it here.
The second thing is code that puts all this into action – the knockout binding.
1: var cart = new site.Cart();
2: ko.applyBindings(cart);
Now, I won’t go into detail on how all this works – see the tekpub video – I really recommend that.
There is one thing that I would like to debate.
This solution is quite robust, nicely structured and easily extendible and all in all, I like it. There is one thing that’s bothering me though. The total lines of code required to get the result is a bit over 100 + the code for ko extension (that’s 20). This doesn’t seem like much, but keep in mind that this is a ridiculously simple cart. In earlier projects, I’ve implemented the same functionality by writing a jquery plugin which had a total of 50 lines of code – the exact same functionality, just using a different framework.
It’s safe to assume that when the scope of the cart grows, so will the codebase. In case of knockout implementation, this will most likely grow much slower than in case a pure jquery plugin – that’s because knockout does all the wiring for you, so it would make sense to invest in a MV* front end framework for bigger projects, but for things like this, I’m not sure.
Don’t get me wrong – I think knockout is great, and I highly recommend to get to know it, but even though I know that the knockout solution is more flexible, I’m wondering if it’s worth all the hassle. I mean, you can do the same with code that fits on one screen. I guess I’ll need to sleep on the problem to decide which way to go in future projects.
Cheers