<>

#Tutorial: ViewModel Injection

Knockback.js provides an a few helpers to inject ViewModels into your HTML Views in a similar way to AngularJS's ng-app so you can build up an application by dynamically binding observables directly from your HTML.

The following examples show how you might port AngularJS samples to Knockback to help you compare approaches. There's nothing complicated here, just a kb-inject attribute and a custom inject Knockout binding to add observables to your ViewModel.

The main way to bind a View when your page is loaded, is to use a kb-inject attribute on your HTML elements. You can optionally provide some hooks if you like:

For more examples and information, please see the API Documentation.

<!-- BIND WITHOUT PARAMETERS - create a new view model and bind the hierachy -->
<html kb-inject>
  <!-- YOUR VIEW HERE !! -->
</html>

<!-- BIND WITH PARAMETERS - create a custom view model and callback after bind -->
<html kb-inject="view_model: {name: ko.observable('')}, afterBinding: yourFunction">
  <!-- YOUR VIEW HERE !! -->
</html>

In addition to help you inject observables to your ViewModel, you can use the 'inject' custom binding. It accepts an object to extend your ViewModel or a function of the form yourFunction(view_model, element) to allow you to extend the view model by hand:

<!-- INJECT A TO EXTEND YOUR VIEW MODEL -->
<div data-bind="inject: {name: ko.observable('')}"></div>

<!-- INJECT A USING A CUSTOM FUNCTION -->
<div data-bind="inject: ProjectAppController"></div>

That's basically it...it's really a lot easier than memorizing or looking up a large number of custom ng-{something} bindings and you can reuse your knowledge of Knockback.js, Knockout.js, and Backbone.js!

Getting Started - Hello World!

There are a few helpers to get us started:

# helper to toggle classes
ko.bindingHandlers['classes'] =
  update: (element, value_accessor) ->
    for key, state of ko.utils.unwrapObservable(value_accessor())
      $(element)[if ko.utils.unwrapObservable(state) then 'addClass' else 'removeClass'](key)
    return

# helpers to manage push state
window.loadUrl = (url) -> Backbone.history.loadUrl(url)
window.loadUrlFn = (url) -> return -> Backbone.history.loadUrl(url)
// helper to toggle classes
ko.bindingHandlers['classes'] = {
  update: function(element, value_accessor) {
    var classes = ko.utils.unwrapObservable(value_accessor());
    for (var key in classes) {
      $(element)[ko.utils.unwrapObservable(classes[key]) ? 'addClass' : 'removeClass'](key);
    }
}};

// helpers to manage push state
window.loadUrl = function(url) { return Backbone.history.loadUrl(url); };
window.loadUrlFn = function(url) { return function() { return Backbone.history.loadUrl(url); }; };

Hello Example

This example uses the 'kb-inject' attribute to seed a root application ViewModel with a yourName observable (like a more flexible ng-model binding).

View (HTML)

<div kb-inject="yourName: ko.observable('')">
  <label>Name:</label>
  <input type="text" data-bind="value: yourName, valueUpdate: 'keyup'" placeholder="Enter a name here">
  <hr>
  <h1 data-bind="text: 'Hello ' + yourName() + '!'"></h1>
</div>

Live Result


You could have just as easily written the first line like:

<div data-bind="inject: {yourName: ko.observable('')}" kb-inject>

Todo Example

The todo example uses a custom 'TodoCtrl' inject function to add some functions and observables to your view model. Then you can bind them to your HTML using standard Knockout bindings.

<div kb-inject>
  <h2>Todo</h2>
  <div data-bind="inject: TodoCtrl">
    <span data-bind="text: remaining() + ' of ' + todos().length + ' remaining'"></span>
    [ <a href="" data-bind="click: archive">archive</a> ]
    <ul class="unstyled" data-bind="foreach: todos">
      <li>
        <input type="checkbox" data-bind="checked: done">
        <span data-bind="css: {done: done}, text: text"></span>
      </li>
    </ul>
    <form>
      <input type="text" data-bind="value: todoText"  size="30"
             placeholder="add new todo here">
      <button class="btn-primary" data-bind="click: addTodo">add</button>
    </form>
  </div>
</div>
TodoCtrl = (view_model) ->
  view_model.todos = ko.observableArray([
    {text:'learn knockback', done:ko.observable(true)},
    {text:'build a knockback app', done:ko.observable(false)}]
  )

  view_model.todoText = ko.observable('')
  view_model.addTodo = ->
    view_model.todos.push({text:view_model.todoText(), done:ko.observable(false)})
    view_model.todoText('')

  view_model.remaining = ko.computed(->
    return _.reduce(view_model.todos(), ((count, todo) -> return count + (if todo.done() then 0 else 1)), 0)
  )

  view_model.archive = ->
    view_model.todos.remove((todo) -> return todo.done())
  return
var ko = kb.ko;

var TodoCtrl = function(view_model) {
  view_model.todos = ko.observableArray([
    {text: 'learn knockback', done: ko.observable(true)},
    {text: 'build a knockback app', done: ko.observable(false)}
  ]);

  view_model.todoText = ko.observable('');
  view_model.addTodo = function() {
    view_model.todos.push({text: view_model.todoText(), done: ko.observable(false)});
    view_model.todoText('');
  };

  view_model.remaining = ko.computed(function() {
    return _.reduce(view_model.todos(), (function(count, todo) {return count + (todo.done() ? 0 : 1);}), 0);
  });

  view_model.archive = function() {
    return view_model.todos.remove(function(todo) {return todo.done();});
  };
};
.done {
  text-decoration: line-through;
  color: grey;
}

Live Result

Todo

[ archive ]

Project Example

Before we start the example, you should review the validations documentation. Now on with the example! This example shows:

<div kb-inject="options: {afterBinding: projectAppStartRouting}">
  <h2>JavaScript Projects</h2>
  <div data-bind="inject: ProjectAppController"></div>
<div>
<script type="text/x-jquery-tmpl" id="list.html">
  <div>
    <input type="text" class="search-query" placeholder="Search" data-bind="value: filter, valueUpdate: 'keyup'">
    <table>
      <thead>
      <tr>
        <th>Project</th>
        <th>Description</th>
        <th><a data-bind="click: loadUrlFn('new')"><i class="icon-plus-sign"></i></a></th>
      </tr>
      </thead>
      <tbody>
      <!-- ko foreach: projects -->
        <tr>
          <td><a data-bind="attr: {href: site}, text: name"target="_blank"></a></td>
          <td data-bind="text: description"></td>
          <td>
            <a data-bind="click: loadUrlFn('edit/' + id())"><i class="icon-pencil"></i></a>
          </td>
        </tr>
      <!-- /ko -->
      </tbody>
    </table>
  </div>
</script>
<script type="text/x-jquery-tmpl" id="detail.html">
  <form name="myForm" data-bind="inject: kb.formValidator">
    <div class="control-group" data-bind="classes: {error: $myForm.name().$error_count}">
      <label>Name</label>
      <input type="text" name="name" data-bind="value: name, valueUpdate: 'keyup'" required>
      <span data-bind="visible: $myForm.name().required" class="help-inline">
          Required</span>
    </div>

    <div class="control-group" data-bind="classes: {error: $myForm.site().$error_count}">
      <label>Website</label>
      <input type="url" name="site" data-bind="value: site, valueUpdate: 'keyup'" required>
      <span data-bind="visible: $myForm.site().required" class="help-inline">
          Required</span>
      <span data-bind="visible: $myForm.site().url" class="help-inline">
          Not a URL</span>
    </div>

    <label>Description</label>
    <textarea name="description" data-bind="value: description"></textarea>

    <br>
    <a class="btn" data-bind="click: loadUrlFn('')">Cancel</a>
    <button data-bind="click: save, disable: isClean() || $myForm.$error_count()"
            class="btn btn-primary">Save</button>
    <button data-bind="click: onDelete"
            class="btn btn-danger">Delete</button>
  </form>
</script>
ProjectAppController = (view_model, element) ->
  knockback_model = {id: 'id_kb', name: 'Knockback.js', description: 'Backbone.js + Knockout.js is amazingly!', site: 'http://kmalakoff.github.com/knockback/'}
  projects = new ProjectCollection([knockback_model])
  projects.fetch()

  active_el = null
  loadPage = (el) ->
    ko.removeNode(active_el) if active_el
    element.appendChild(active_el = el)

  router = new Backbone.Router()
  router.route('*path', null, -> _.defer(loadUrlFn('')))
  router.route('', null, ->
    loadPage(kb.renderTemplate('list.html', new ProjectListViewModel(projects)))
  )
  router.route('new', null, ->
    loadPage(kb.renderTemplate('detail.html', new ProjectViewModel(new Project(), projects)))
  )
  router.route('edit/:projectId', null, (project_id) ->
    (loadUrl(''); return) unless project = projects.get(project_id) # not a valid project
    loadPage(kb.renderTemplate('detail.html', new ProjectViewModel(project)))
  )
  return

# start outside of the binding loop
projectAppStartRouting = ->
  Backbone.history.start({pushState: true, root: window.location.pathname}) unless Backbone.History.started
var ProjectAppController = function(view_model, element) {
  var knockback_model = {id: 'id_kb', name: 'Knockback.js', description: 'Backbone.js + Knockout.js is amazingly!', site: 'http://kmalakoff.github.com/knockback/'};
  var projects = new ProjectCollection([knockback_model]);
  projects.fetch();

  var active_el = null;
  var loadPage = function(el) {
    if (active_el) ko.removeNode(active_el);
    return element.appendChild(active_el = el);
  };
  router = new Backbone.Router();
  router.route('*path', null, function() {_.defer(loadUrlFn('')); });
  router.route('', null, function() {
    return loadPage(kb.renderTemplate('list.html', new ProjectListViewModel(projects)));
  });
  router.route('new', null, function() {
    return loadPage(kb.renderTemplate('detail.html', new ProjectViewModel(new Project(), projects)));
  });
  router.route('edit/:projectId', null, function(project_id) {
    var project;
    if (!(project = projects.get(project_id))) {loadUrl(''); return; } // not a valid project
    return loadPage(kb.renderTemplate('detail.html', new ProjectViewModel(project)));
  });
};

// start outside of the binding loop
var projectAppStartRouting = function() {
  if (!Backbone.History.started) {
    Backbone.history.start({pushState: true, root: window.location.pathname});
  }
};
PROJECTS_BASE_URL = 'https://api.mongolab.com/api/1/databases/angularjs/collections/projects'
PROJECTS_API_KEY_PARAM = 'apiKey=4f847ad3e4b08a2eed5f3b54'

Project = Backbone.Model.extend({
  url: -> "#{PROJECTS_BASE_URL}/#{@id}?#{PROJECTS_API_KEY_PARAM}"
})
ProjectCollection = Backbone.Collection.extend({
  url: "#{PROJECTS_BASE_URL}?#{PROJECTS_API_KEY_PARAM}"
  parse: (data) -> return _.map(data, (item) -> item.id = item._id.$oid; return item) # remap the ids
  model: Project
})
var PROJECTS_BASE_URL = 'https://api.mongolab.com/api/1/databases/angularjs/collections/projects';
var PROJECTS_API_KEY_PARAM = 'apiKey=4f847ad3e4b08a2eed5f3b54';

var Project = Backbone.Model.extend({
  url: function() { return "" + PROJECTS_BASE_URL + "/" + this.id + "?" + PROJECTS_API_KEY_PARAM;}
});var ProjectCollection = Backbone.Collection.extend({
  url: "" + PROJECTS_BASE_URL + "?" + PROJECTS_API_KEY_PARAM,
  parse: function(data) { return _.map(data, function(item) { item.id = item._id.$oid; return item; }); },
  model: Project
});
ProjectListViewModel = (projects) ->
  @filter = ko.observable('')
  @projects = kb.collectionObservable(projects, {
    view_model: ProjectViewModel
    sort_attribute: 'name'
    filters: [(model) =>
      return true unless filter = @filter()
      return model.get('name') and ((model.get('name').search(filter) >= 0))
    ]
  })
  return
var ko = kb.ko;

var ProjectListViewModel = function(projects) {
  var _this = this;
  this.filter = ko.observable('');
  this.projects = kb.collectionObservable(projects, {
    view_model: ProjectViewModel,
    sort_attribute: 'name',
    filters: [function(model) {
      var filter = _this.filter();
      if (!filter) return true;
      return model.get('name') && (model.get('name').search(filter) >= 0);
    }]
  });
};
ProjectViewModel = kb.ViewModel.extend({
  constructor: (project, projects) ->
    kb.ViewModel.prototype.constructor.call(@, project, {requires: ['id', 'name', 'site', 'description']})

    start_attributes = _.clone(project.attributes)
    @model_changed = kb.triggeredObservable(project, 'change')
    @isClean = ko.computed(=>
      @model_changed() # create a depdendency
      return _.isEqual(start_attributes, project.attributes)
    )
    @onDelete = -> # destroy() is reserved for ViewModel lifecycle
      project.destroy() unless project.isNew()
      loadUrl('')
      return false
    @save = ->
      projects.add(project) if project.isNew()
      project.save()
      loadUrl('')
      return false
    return
})
var ProjectViewModel = kb.ViewModel.extend({
  constructor: function(project, projects) {
    var _this = this;
    kb.ViewModel.prototype.constructor.call(this, project, {requires: ['id', 'name', 'site', 'description']});

    var start_attributes = _.clone(project.attributes);
    this.model_changed = kb.triggeredObservable(project, 'change');
    this.isClean = ko.computed(function() {
      _this.model_changed(); //create a depdendency
      return _.isEqual(start_attributes, project.attributes);
    });
    this.onDelete = function() { // destroy() is reserved for ViewModel lifecycle
      if (!project.isNew()) project.destroy();
      loadUrl('');
      return false;
    };
    this.save = function() {
      if (project.isNew()) projects.add(project);
      project.save();
      loadUrl('');
      return false;
    };
  }
});

Live Result

JavaScript Projects