Using KnockoutJS to Create an Edit-in-Place Data Grid

To chain off my post about using KnockoutJS and Twitter Bootstrap to create a data pager, this time I’ll elaborate a little more and demonstrate how to create an entire editable data grid. I’ve picked a fairly simple example for this. We’re going to build a grid that allows the see a list of all states and edit one of the rows inline. Here’s what the full version of the grid looks like:

Knockout.js edit in place data grid with pager

Let’s start by setting up our view.

<h2>States</h2>  
<p><a class="btn btn-primary" data-bind="click: $root.add" href="#" title="edit"><i class="icon-plus"></i> Add State</a></p>

<table class="table table-striped table-bordered">  
    <thead>
        <tr>
            <th>Name</th>
            <th>Short Name</th>
            <th style="width: 100px; text-align:right;" />
        </tr>
    </thead>
   <tbody data-bind=" template:{name:templateToUse, foreach: pagedList }"></tbody>
</table>

<div class="pagination">  
    <ul><li data-bind="css: { disabled: pageIndex() === 0 }"><a href="#" data-bind="click: previousPage">Previous</a></li></ul>
    <ul data-bind="foreach: allPages">
        <li data-bind="css: { active: $data.pageNumber === ($root.pageIndex() + 1) }"><a href="#" data-bind="text: $data.pageNumber, click: function() { $root.moveToPage($data.pageNumber-1); }"></a></li>
    </ul>
    <ul><li data-bind="css: { disabled: pageIndex() === maxPageIndex() }"><a href="#" data-bind="click: nextPage">Next</a></li></ul>
</div>

<script id="itemsTmpl" type="text/html">  
   <tr>
        <td data-bind="text: name"></td>
        <td data-bind="text: shortName"></td>
        <td class="buttons">
            <a class="btn" data-bind="click: $root.edit" href="#" title="edit"><i class="icon-edit"></i></a>
            <a class="btn" data-bind="click: $root.remove" href="#" title="remove"><i class="icon-remove"></i></a>
        </td>
    </tr>
</script>

 <script id="editTmpl" type="text/html">
   <tr>
       <td><input data-bind="value: name"/></td>
       <td><input data-bind="value: shortName"/></td>
        <td class="buttons">
            <a class="btn btn-success" data-bind="click: $root.save" href="#" title="save"><i class="icon-ok"></i></a>
            <a class="btn" data-bind="click: $root.cancel" href="#" title="cancel"><i class="icon-trash"></i></a>
        </td>
   </tr>
</script>  

I know, I know. That’s a bit to digest, huh? Okay, we’ll break it down a little:

The first couple lines just create our “States” heading and the “Add State” button. Pretty simple.

Now we start our table. This is our container, with headings, for all the data we’re going to display. The tbody tag specifies our KnockoutJS data binding. That comes from our ViewModel, which we’ll get to shortly.

You’ll notice the data-bind attribute for the tbody specifies a template parameter. The next two script blocks contain our templates. The templateToUse property, specified in the data-bind, will be a function on our ViewModel that determines if we should render the editable row, or just the read-only row.
The next section is just the pagination block. Check out my post on creating pagination for more info.

Alright, so we have our view. Now let’s create our Knockout Model for States.

function State(id,name, shortName){  
    this.id = ko.observable(id);
    this.name = ko.observable(name);
    this.shortName = ko.observable(shortName);
}

Okay, that’s not so bad. It’s a pretty standard model as far as knockout goes. The ViewModel, however, gets a little more complex:

var ListViewModel = function (initialData) {  
    var self = this;
    window.viewModel = self;

    self.list = ko.observableArray(initialData);
    self.pageSize = ko.observable(10);
    self.pageIndex = ko.observable(0);
    self.selectedItem = ko.observable();
    self.saveUrl = '/api/state/save';
    self.deleteUrl = '/api/state/delete';

    self.edit = function (item) {
        self.selectedItem(item);
    };

    self.cancel = function () {
        self.selectedItem(null);
    };

    self.add = function () {
        var newItem = new State();
        self.list.push(newItem);
        self.selectedItem(newItem);
        self.moveToPage(self.maxPageIndex());
    };
    self.remove = function (item) {
        if (item.id()) {
            if (confirm('Are you sure you wish to delete this item?')) {
                $.post(self.deleteUrl, item).complete(function (result) {
                    self.list.remove(item);
                    if (self.pageIndex() > self.maxPageIndex()) {
                        self.moveToPage(self.maxPageIndex());
                    }
                });
            }
        }
        else {
            self.list.remove(item);
            if (self.pageIndex() > self.maxPageIndex()) {
                self.moveToPage(self.maxPageIndex());
            }
        }
    };
    self.save = function () {
        var item = self.selectedItem();
        $.post(self.saveUrl, item, function (result) {
            self.selectedItem().id(result);
            self.selectedItem(null);
        });

    };

    self.templateToUse = function (item) {
        return self.selectedItem() === item ? 'editTmpl' : 'itemsTmpl';
    };

    self.pagedList = ko.dependentObservable(function () {
        var size = self.pageSize();
        var start = self.pageIndex() * size;
        return self.list.slice(start, start + size);
    });
    self.maxPageIndex = ko.dependentObservable(function () {
        return Math.ceil(self.list().length / self.pageSize()) - 1;
    });
    self.previousPage = function () {
        if (self.pageIndex() > 0) {
            self.pageIndex(self.pageIndex() - 1);
        }
    };
    self.nextPage = function () {
        if (self.pageIndex() < self.maxPageIndex()) {
            self.pageIndex(self.pageIndex() + 1);
        }
    };
    self.allPages = ko.dependentObservable(function () {
        var pages = [];
        for (i = 0; i <= self.maxPageIndex() ; i++) {
            pages.push({ pageNumber: (i + 1) });
        }
        return pages;
    });
    self.moveToPage = function (index) {
        self.pageIndex(index);
    };
};

The ListViewModel is pretty similar to the version from the pagination article, but there are a few additional functions for handling inline editing. The edit function sets our selectedItem property, which is observed by the templateToUse function. Setting the selectedItem then causes the data grid to redraw, reevaluating the template for the row we just clicked on. The cancel function does the opposite.

I’ve also included some basic saving and deleting functions in here. This, of course, will require you to implement API calls server-side that handle these actions. That is beyond the scope of this article.

Okay, so now we just have to hook it all together. With knockout, that’s pretty easy. We’ll just create an instance of our ListViewModel, seed it with a little fake data, and then call ko.applyBindings:

var initialData = [new State('WA', 'Washington', 'WA'), new State('AK', 'Alaska', 'AK')];  
ko.applyBindings(new ListViewModel(initialData));  

And there you have it. A data grid with inline editing using KnockoutJS and Twitter Bootstrap. That wasn’t so bad, right?