Saturday 2 March 2013

Data-bind a Knockout.js model infinitely deep

Knockout logo

This post will show you how to data-bind a hierarchical model that can go infinitely deep onto a page using Knockout.js. In this post, I will make a screen that will be used to build an organisation chart as an example, I chose this as it's a really simple example to illustrate the technique that I'll be using.

Model

First we create the model that we want to bind to the UI. Our model is of an employee that will have a name, a link to it's boss and array of employees (subordinates).

function Employee(name, boss) {
    this.name = name;
    this.boss = boss;
    this.employees = ko.observableArray([]);
}

Template

The template is used to display the model, our template will iterate over an array of employees, show their name and point back to itself to do it again with each of the employee's subordinates.

<div data-bind="template: { name: 'organisation-template', foreach: employees }"></div>

<script id="organisation-template"  type="text/html">
    <label data-bind="attr: { 'for': nameId }">Name:</label>
    <input data-bind="attr: { 'id': nameId }, value: name" />
    <div data-bind="template: { name: 'organisation-template', foreach: employees }"></div>
</script>

The nameId property on the Employee model is included to give the name textbox a unique id so that it can have a label. Accessibility is good!

function Employee(name, boss) {
    /* the rest of the code */

    this.nameId = ko.computed(function() {
        return name.split(' ').join('_')
                   .split('.').join('_');
    });
}

Let's also go ahead and add a little CSS so we can differentiate the hierarchy levels.

.employee {
    border:1px solid #ccc;
    padding:.3em;
    margin-bottom:.3em;
}

.employee:last-child {
    margin-bottom:0;
}

Test data

Now let's create some test JSON to display on the UI initially, in practice this would normally come from a server.

var jsonModel = '{"employees":[{"name":"Jason Alexander","employees":[{"name": "George Costanza","employees":[{"name":"Art Vandelay"}]}]},{"name":"Michael Richards","employees":[{"name":"Cosmo Kramer","employees":[{"name":"H.E. Pennypacker"},{"name":"Bob Sacamano"}]}]},{"name":"Jerry Seinfeld","employees":[{"name":"Kel Varnsen"}]}]}';

Parse the JSON

Now we will create the model from the JSON model that was defined in the above section.

function OrganisationViewModel() {
    initialiseModel(this, jsonModel);
}

function initialiseModel(model, jsonModel) {
    parsedModel = JSON.parse(jsonModel);
    model.employees = ko.observableArray([]);
    addEmployees(model, parsedModel.employees);
    console.log(model);
}

function addEmployees(bossModel, employees) {
    if (typeof employees === "undefined")
        return;

    for (var i = 0; i < employees.length; i++) {
        var employee = employees[i];
        var employeeModel = new Employee(employee.name, bossModel);
        bossModel.employees.push(employeeModel);
        addEmployees(employeeModel, employee.employees);
    }
}

Data bind

Finally everything is ready to data bind!

ko.applyBindings(new OrganisationViewModel());

It should look like this now:

Databind

Add/delete buttons

We also want to be able to add and delete employees, start by creating the JavaScript functions and attaching them to the model. Notice that we're reusing the addEmployee function on OrganisationViewModel as well to create out root-level employees.

function addRule() {
    var newRule = new Rule('Fresh rule', this);
    this.rules.push(newRule);
}

function deleteRule() {
    var parent = this.parent;
    parent.rules.splice(parent.rules.indexOf(this), 1);
}

OrganisationViewModel.prototype.addEmployee = addEmployee;
Employee.prototype.addEmployee = addEmployee;
Employee.prototype.deleteEmployee = deleteEmployee;

To hook this is up the UI we need a

<button data-bind="click: addEmployee">Add employee</button>
<div data-bind="template: { name: 'organisation-template', foreach: employees }"></div>

<script id="organisation-template"  type="text/html">
    <div class="employee">
        <label data-bind="attr: { 'for': nameId }">Name:</label>
        <input data-bind="attr: { 'id': nameId }, value: name" />
        <button data-bind="click: addEmployee">Add employee</button>
        <button data-bind="click: deleteEmployee">Delete employee</button>
        <div data-bind="template: { name: 'organisation-template', foreach: employees }"></div>
    </div>
</script>

End result

And it's done! Click here to see a working example. It should look like this

End result
End result 2