Knockout or Grails with jQuery

Sep 26 2012

Knowing when to use Knockout or not is an art. Plain jQuery works fine for keeping track of unchecked users that were checked before.

var existingUsers;
function setExistingUsers () {
    existingUsers = [];
    $('#userList input[type="checkbox"]').each(function () {
        if($(this).attr('checked')) existingUsers.push($(this).val())
    });
}

$('form').submit(function(){
    var unselectedUsers = [];
    $('#userList input[type="checkbox"]:not(:checked)').each(function () {
        unselectedUsers.push($(this).val())
    });
    $('[name="_deletedUsers"]').val(JSON.stringify(_.intersect(existingUsers, unselectedUsers)));
});
$('#allUsers').click(function(){
    if ($(this).is(":checked")) {
        $('#userList input[type="checkbox"]').prop('checked', true);
    } else {
        $('#userList input[type="checkbox"]').prop('checked', false);
    }
})

jQuery is used here for the “All” checkbox to select all users and to find the deleted users when submitting the form. Another part of the page displays the selected users for ordering with a sortable interface, so that users can be assigned priorities. Whenever a user is checked, he is added to the bottom of list. Whenever he is unchecked, he is removed from the sortable interface. With two views on the same data that need to be maintained, code without a proper MVC structure gets messy as the application grows. I even had to change Underscore‘s default “<% %>” templating to avoid conflicts with GSP.

var existingUsers;
_.templateSettings = {
  interpolate : /\{\{(.+?)\}\}/g
};
var escalationUserTemplate =  _.template("<span>{{ name }}</span>");
function setExistingUsers () {
    existingUsers = [];
    $('#userList input[type="checkbox"]').each(function () {
        if($(this).attr('checked')) existingUsers.push($(this).val())
    });
    $('#sortable').html(_.map(existingUsers, function(user) {
        escalationUserTemplate({name: user})
    }).join(''));
    $('#userList input[type="checkbox"]').click(function () {
        setExistingUsers()
    });
}

$('form').submit(function(){
    var unselectedUsers = [];
    $('#userList input[type="checkbox"]:not(:checked)').each(function () {
        unselectedUsers.push($(this).val())
    });
    $('[name="_deletedUsers"]').val(JSON.stringify(_.intersect(existingUsers, unselectedUsers)));
});
$('#allUsers').click(function(){
    if ($(this).is(":checked")) {
        $('#userList input[type="checkbox"]').prop('checked', true);
    } else {
        $('#userList input[type="checkbox"]').prop('checked', false);
    }
});

Setting the HTML for the other view each time a checkbox state changes is a lot less efficient than using Knockout. It even comes with a templating system that uses the HTML DOM, which is much better than having it in a string. Knockout’s MVC structure facilitated with managing users. I wrote a model to handle user filtering, checking/unchecking all users, and for showing different sections of the view depending on a radio box group.

User = (data) ->
  @name = ko.observable(data.name)
  @selected = ko.observable(_.include(existingUsers, data.id))
  @id = data.id
  @departments = data.departments
  @

ViewModel = ->
  @filter = ko.observable('')
  @selectedDepartment = ko.observable(pagingGroup)
  @users = ko.observableArray([])
  @departments = ko.observableArray([])
  @pagingGroupType = ko.observable('broadcast')

  @filteredUsers = ko.computed( =>
    filter = @filter().toLowerCase()
    department = @selectedDepartment()
    unless filter or department
      @users()
    else
      usersByName = @users()
      if filter
        usersByName = ko.utils.arrayFilter usersByName, (item) ->
          ko.utils.stringStartsWith item.name().toLowerCase(), filter
      if department
        usersByName = ko.utils.arrayFilter usersByName, (item) ->
          _.include(item.departments, department)
      usersByName
  )

  @selectedUsers = ko.computed( =>
    department = @selectedDepartment()
    _.chain(@users())
      .filter((user) =>
        user.selected()
      )
      .filter((user) =>
        if department
          _.include(user.departments, department)
        else
          true
      )
      .value()
  )

  @selectAllFilteredUsers = =>
    _.each(@filteredUsers(), (user) ->
      user.selected(true)
    )

  @deselectAllFilteredUsers = =>
    _.each(@filteredUsers(), (user) ->
      user.selected(false)
    )

  mappedUsers = $.map(accountUsers, (item) ->
    new User(item)
  )
  @users mappedUsers
  allDepartments = _.union.apply({}, _(mappedUsers).map( (user) ->
    user.departments
  ))

  @departments allDepartments
  @

window.viewModel = new ViewModel()

ko.applyBindings(viewModel)

The views for user selection and ordering was also very straightforward. I used jQuery UI’s sortable lists for ordering users.

<div class="select-block flexcroll" data-bind="foreach: filteredUsers">
    <div class="select-box" id="userList">
        <label><input type="checkbox" data-bind="value: id, checked: selected, attr: { index: $index }" /><span data-bind="text: name"></span></label>
    </div>
</div>
...
<div id="sortable" class="connectedSortable select-block flexcroll selectors" data-bind="foreach: selectedUsers">
    <span data-bind="text: name"></span>
</div>

The best part is the old code for finding deleted users is still relevant.

$('form').submit(function(){
    var unselectedUsers = [];
    $('#userList input[type="checkbox"]:not(:checked)').each(function () {
        unselectedUsers.push(parseInt($(this).val()))
    });
    $('#userList input[type="checkbox"]').each(function(index){
        $(this).attr('name', 'members[' + index + '].user.id'); // Grails automatically saves one to many relationships
    })
    $('[name="_deletedUsers"]').val(JSON.stringify(_.intersect(existingUsers, unselectedUsers)));
});
$('#allUsers').click(function(){
    if ($(this).is(":checked")) {
        viewModel.selectAllFilteredUsers()
    } else {
        viewModel.deselectAllFilteredUsers()
    }
})

Now the AJAX call for users in a department has been replaced with front-end filtering, which also allows for searching by names using the filter observable.

No responses yet