In the first part of this mini-series, we created the basic structure of a to-do application using a Sinatra JSON interface to a SQLite database, and a Knockout-powered front-end that allows us to add tasks to our database. In this final part, we'll cover some slightly more advanced functionality in Knockout, including sorting, searching, updating, and deleting.
Let's start where we left off; here is the relevant portion of our index.erb
file.
<div id="container"> <section id="taskforms" class="clearfix"> <div id="newtaskform" class="floatleft fifty"> <h2>Create a New Task</h2> <form id="addtask" data-bind="submit: addTask"> <input data-bind="value: newTaskDesc"> <input type="submit"> </form> </div> <div id="tasksearchform" class="floatright fifty"> <h2>Search Tasks</h2> <form id="searchtask"> <input> </form> </div> </section> <section id="tasktable"> <h2>Incomplete Tasks remaining: <span></span></h2> <a>Delete All Complete Tasks</a> <table> <tbody><tr> <th>DB ID</th> <th>Description</th> <th>Date Added</th> <th>Date Modified</th> <th>Complete?</th> <th>Delete</th> </tr> <!-- ko foreach: tasks --> <tr> <td data-bind="text: id"></td> <td data-bind="text: description"></td> <td data-bind="text: created_at"></td> <td data-bind="text: updated_at"></td> <td><input type="checkbox" data-bind="checked: complete, click: $parent.markAsComplete"> </td> <td data-bind="click: $parent.destroyTask" class="destroytask"><a>X</a></td> </tr> <!-- /ko --> </tbody></table> </section> </div>
Sort
Sorting is a common task used in many applications. In our case, we want to sort the task list by any header field in our task-list table. We will start by adding the following code to the TaskViewModel
:
t.sortedBy = []; t.sort = function(field){ if (t.sortedBy.length && t.sortedBy[0] == field && t.sortedBy[1]==1){ t.sortedBy[1]=0; t.tasks.sort(function(first,next){ if (!next[field].call()){ return 1; } return (next[field].call() < first[field].call()) ? 1 : (next[field].call() == first[field].call()) ? 0 : -1; }); } else { t.sortedBy[0] = field; t.sortedBy[1] = 1; t.tasks.sort(function(first,next){ if (!first[field].call()){ return 1; } return (first[field].call() < next[field].call()) ? 1 : (first[field].call() == next[field].call()) ? 0 : -1; }); } }
Knockout provides a sort function for observable arrays
First, we define a sortedBy
array as a property of our view model. This allows us to store if and how the collection is sorted.
Next is the sort()
function. It accepts a field
argument (the field we want to sort by) and checks if the tasks are sorted by the current sorting scheme. We want to sort using a "toggle" type of process. For example, sort by description once, and the tasks arrange in alphabetical order. Sort by description again, and the tasks arrange in reverse-alphabetical order. This sort()
function supports this behavior by checking the most recent sort scheme and comparing it to what the user wants to sort by.
Knockout provides a sort function for observable arrays. It accepts a function as an argument that controls how the array should be sorted. This function compares two elements from the array and returns 1
, 0
, or -1
as a result of that comparison. All like values are grouped together (which will be useful for grouping complete and incomplete tasks together).
Note: the properties of the array elements must be called rather than simply accessed; these properties are actually functions that return the value of the property if called without any arguments.
Next, we define the bindings on the table headers in our view.
<th data-bind="click: function(){ sort('id') }">DB ID</th> <th data-bind="click: function(){ sort('description') }">Description</th> <th data-bind="click: function(){ sort('created_at') }">Date Added</th> <th data-bind="click: function(){ sort('updated_at') }">Date Modified</th> <th data-bind="click: function(){ sort('complete') }">Complete?</th> <th>Delete</th>
These bindings allow each of the headers to trigger a sort based on the passed string value; each of these directly maps to the Task
model.
Mark As Complete
Next, we want to be able to mark a task as complete, and we'll accomplish this by simply clicking the checkbox associated with a particular task. Let's start by defining a method in the TaskViewModel
:
t.markAsComplete = function(task) { if (task.complete() == true){ task.complete(true); } else { task.complete(false); } task._method = "put"; t.saveTask(task); return true; }
The markAsComplete()
method accepts the task as an argument, which is automatically passed by Knockout when iterating over a collection of items. We then toggle the complete
property, and add a ._method="put"
property to the task. This allows DataMapper
to use the HTTP PUT
verb as opposed to POST
. We then use our convenient t.saveTask()
method to save the changes to the database. Finally, we return true
because returning false
prevents the checkbox from changing state.
Next, we change the view by replacing the checkbox code inside the task loop with the following:
<input type="checkbox" data-bind="checked: complete, click: $parent.markAsComplete">
This tells us two things:
- The box is checked if
complete
is true. - On click, run the
markAsComplete()
function from the parent (TaskViewModel
in this case). This automatically passes the current task in the loop.
Deleting Tasks
To delete a task, we simply use a few convenience methods and call saveTask()
. In our TaskViewModel
, add the following:
t.destroyTask = function(task) { task._method = "delete"; t.tasks.destroy(task); t.saveTask(task); };
This function adds a property similar to the "put" method for completing a task. The built-in destroy()
method removes the passed-in task from the observable array. Finally, calling saveTask()
destroys the task; that is, as long as the ._method
is set to "delete".
Now we need to modify our view; add the following:
<td data-bind="click: $parent.destroyTask" class="destroytask"><a>X</a></td>
This is very similar in functionality to the complete checkbox. Note that the class="destroytask"
is purely for styling purposes.
Delete All Completed
Next, we want to add the "delete all complete tasks" functionality. First, add the following code to the TaskViewModel
:
t.removeAllComplete = function() { ko.utils.arrayForEach(t.tasks(), function(task){ if (task.complete()){ t.destroyTask(task); } }); }
This function simply iterates over the tasks to determine which of them are complete, and we call the destroyTask()
method for each complete task. In our view, add the following for the "delete all complete" link.
<a data-bind="click: removeAllComplete, visible: completeTasks().length > 0 ">Delete All Complete Tasks</a>
Our click binding will work correctly, but we need to define completeTasks()
. Add the following to our TaskViewModel
:
t.completeTasks = ko.computed(function() { return ko.utils.arrayFilter(t.tasks(), function(task) { return (task.complete() && task._method != "delete") }); });
This method is a computed property. These properties return a value that is computed "on the fly" when the model is updated. In this case, we return a filtered array that contains only complete tasks that are not marked for deletion. Then, we simply use this array's length
property to hide or show the "Delete All Completed Tasks" link.
Incomplete Tasks Remaining
Our interface should also display the amount of incomplete tasks. Similar to our completeTasks()
function above, we define an incompleteTasks()
function in TaskViewModel
:
t.incompleteTasks = ko.computed(function() { return ko.utils.arrayFilter(t.tasks(), function(task) { return (!task.complete() && task._method != "delete") }); });
We then access this computed filtered array in our view, like this:
<h2>Incomplete Tasks remaining: <span data-bind="text: incompleteTasks().length"></span></h2>
Style Completed Tasks
We want to style the completed items differently from the tasks in the list, and we can do this in our view with Knockout's css
binding. Modify the tr
opening tag in our task arrayForEach()
loop to the following.
<tr data-bind="css: { 'complete': complete }, visible: isvisible">
This adds a complete
CSS class to the table row for each task if its complete
property is true
.
Clean Up Dates
Let's get rid of those ugly Ruby date strings. We'll start by defining a dateFormat
function in our TaskViewModel
:
t.MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; t.dateFormat = function(date){ if (!date) { return "refresh to see server date"; } var d = new Date(date); return d.getHours() + ":" + d.getMinutes() + ", " + d.getDate() + " " + t.MONTHS[d.getMonth()] + ", " + d.getFullYear(); }
This function is fairly straightforward. If for any reason the date is not defined, we simply need to refresh the browser to pull in the date in the initial Task
fetching function. Otherwise, we create a human readable date with the plain JavaScript Date
object with the help of the MONTHS
array. (Note: it is not necessary to capitalize the name of the array MONTHS
, of course; this is simply a way of knowing that this is a constant value that shouldn't be changed.)
Next, we add the following changes to our view for the created_at
and updated_at
properties:
<td data-bind="text: $root.dateFormat(created_at())"></td> <td data-bind="text: $root.dateFormat(updated_at())"></td>
This passes the created_at
and updated_at
properties to the dateFormat()
function. Once again, it's important to remember that properties of each task are not normal properties; they are functions. In order to retrieve their value, you must call the function (as shown in the above example). Note: $root
is a keyword, defined by Knockout, that refers to the ViewModel. The dateFormat()
method, for instance, is defined as a method of the root ViewModel (TaskViewModel
).
Searching Tasks
We can search our tasks in a variety of ways, but we'll keep things simple and perform a front-end search. Keep in mind, however, that it is likely that these search results will be database driven as the data grows for the sake of pagination. But for now, let's define our search()
method on TaskViewModel
:
t.query = ko.observable(''); t.search = function(task){ ko.utils.arrayForEach(t.tasks(), function(task){ if (task.description() && t.query() != ""){ task.isvisible(task.description().toLowerCase().indexOf(t.query().toLowerCase()) >= 0); } else if (t.query() == "") { task.isvisible(true); } else { task.isvisible(false); } }) return true; }
We can see that this iterates through the array of tasks and checks to see if t.query()
(a regular observable value) is in the task description. Note that this check actually runs inside the setter function for the task.isvisible
property. If the evaluation is false
, the task isn't found and the isvisible
property is set to false
. If the query is equal to an empty string, all tasks are set to be visible. If the task doesn't have a description and the query is a non-empty value, the task is not a part of the returned data set and is hidden.
In our index.erb
file, we set up our searching interface with the following code:
<form id="searchtask"> <input data-bind="value: query, valueUpdate: 'keyup', event : { keyup : search}"> </form>
The input value is set to the ko.observable query
. Next, we see that the keyup
event is specifically identified as a valueUpdate
event. Lastly, we set a manual event binding to keyup
to execute the search (t.search()
) function. No form submission is necessary; the list of matching items will display and can still be sortable, deletable, etc. Therefore, all interactions work at all times.
Final Code
index.erb
<!DOCTYPE html > <html> <!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7"> <![endif]--> <!--[if IE 7]> <html class="no-js lt-ie9 lt-ie8"> <![endif]--> <!--[if IE 8]> <html class="no-js lt-ie9"> <![endif]--> <!--[if gt IE 8]><!--> <!--<![endif]--> <body> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> <title>ToDo</title> <meta name="description" content=""> <meta name="viewport" content="width=device-width"> <!-- Place favicon.ico and apple-touch-icon.png in the root directory --> <link rel="stylesheet" href="styles/styles.css"> <script src="scripts/modernizr-2.6.2.min.js"></script> <!--[if lt IE 7]> <p class="chromeframe">You are using an outdated browser. <a href="http://browsehappy.com/">Upgrade your browser today</a> or <a href="http://www.google.com/chromeframe/?redirect=true">install Google Chrome Frame</a> to better experience this site.</p> <![endif]--> <!-- Add your site or application content here --> <div id="container"> <section id="taskforms" class="clearfix"> <div id="newtaskform" class="floatleft fifty"> <h2>Create a New Task</h2> <form id="addtask" data-bind="submit: addTask"> <input data-bind="value: newTaskDesc"> <input type="submit"> </form> </div> <div id="tasksearchform" class="floatright fifty"> <h2>Search Tasks</h2> <form id="searchtask"> <input data-bind="value: query, valueUpdate: 'keyup', event : { keyup : search}"> </form> </div> </section> <section id="tasktable"> <h2>Incomplete Tasks remaining: <span data-bind="text: incompleteTasks().length"></span></h2> <a data-bind="click: removeAllComplete, visible: completeTasks().length > 0 ">Delete All Complete Tasks</a> <table> <tbody><tr> <th data-bind="click: function(){ sort('id') }">DB ID</th> <th data-bind="click: function(){ sort('description') }">Description</th> <th data-bind="click: function(){ sort('created_at') }">Date Added</th> <th data-bind="click: function(){ sort('updated_at') }">Date Modified</th> <th data-bind="click: function(){ sort('complete') }">Complete?</th> <th>Delete</th> </tr> <!-- ko foreach: tasks --> <tr data-bind="css: { 'complete': complete }, visible: isvisible"> <td data-bind="text: id"></td> <td data-bind="text: description"></td> <td data-bind="text: $root.dateFormat(created_at())"></td> <td data-bind="text: $root.dateFormat(updated_at())"></td> <td><input type="checkbox" data-bind="checked: complete, click: $parent.markAsComplete"> </td> <td data-bind="click: $parent.destroyTask" class="destroytask"><a>X</a></td> </tr> <!-- /ko --> </tbody></table> </section> </div> <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.8.1/jquery.min.js"></script> <script>window.jQuery || document.write('<script src="scripts/jquery.js"><\/script>')</script> <script src="scripts/knockout.js"></script> <script src="scripts/app.js"></script> <!-- Google Analytics: change UA-XXXXX-X to be your site's ID. --> <script> var _gaq=[['_setAccount','UA-XXXXX-X'],['_trackPageview']]; (function(d,t){var g=d.createElement(t),s=d.getElementsByTagName(t)[0]; g.src=('https:'==location.protocol?'//ssl':'//www')+'.google-analytics.com/ga.js'; s.parentNode.insertBefore(g,s)}(document,'script')); </script> </body> </html>
app.js
function Task(data) { this.description = ko.observable(data.description); this.complete = ko.observable(data.complete); this.created_at = ko.observable(data.created_at); this.updated_at = ko.observable(data.updated_at); this.id = ko.observable(data.id); this.isvisible = ko.observable(true); } function TaskViewModel() { var t = this; t.tasks = ko.observableArray([]); t.newTaskDesc = ko.observable(); t.sortedBy = []; t.query = ko.observable(''); t.MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; $.getJSON("http://localhost:9393/tasks", function(raw) { var tasks = $.map(raw, function(item) { return new Task(item) }); t.tasks(tasks); }); t.incompleteTasks = ko.computed(function() { return ko.utils.arrayFilter(t.tasks(), function(task) { return (!task.complete() && task._method != "delete") }); }); t.completeTasks = ko.computed(function() { return ko.utils.arrayFilter(t.tasks(), function(task) { return (task.complete() && task._method != "delete") }); }); // Operations t.dateFormat = function(date){ if (!date) { return "refresh to see server date"; } var d = new Date(date); return d.getHours() + ":" + d.getMinutes() + ", " + d.getDate() + " " + t.MONTHS[d.getMonth()] + ", " + d.getFullYear(); } t.addTask = function() { var newtask = new Task({ description: this.newTaskDesc() }); $.getJSON("/getdate", function(data){ newtask.created_at(data.date); newtask.updated_at(data.date); t.tasks.push(newtask); t.saveTask(newtask); t.newTaskDesc(""); }) }; t.search = function(task){ ko.utils.arrayForEach(t.tasks(), function(task){ if (task.description() && t.query() != ""){ task.isvisible(task.description().toLowerCase().indexOf(t.query().toLowerCase()) >= 0); } else if (t.query() == "") { task.isvisible(true); } else { task.isvisible(false); } }) return true; } t.sort = function(field){ if (t.sortedBy.length && t.sortedBy[0] == field && t.sortedBy[1]==1){ t.sortedBy[1]=0; t.tasks.sort(function(first,next){ if (!next[field].call()){ return 1; } return (next[field].call() < first[field].call()) ? 1 : (next[field].call() == first[field].call()) ? 0 : -1; }); } else { t.sortedBy[0] = field; t.sortedBy[1] = 1; t.tasks.sort(function(first,next){ if (!first[field].call()){ return 1; } return (first[field].call() < next[field].call()) ? 1 : (first[field].call() == next[field].call()) ? 0 : -1; }); } } t.markAsComplete = function(task) { if (task.complete() == true){ task.complete(true); } else { task.complete(false); } task._method = "put"; t.saveTask(task); return true; } t.destroyTask = function(task) { task._method = "delete"; t.tasks.destroy(task); t.saveTask(task); }; t.removeAllComplete = function() { ko.utils.arrayForEach(t.tasks(), function(task){ if (task.complete()){ t.destroyTask(task); } }); } t.saveTask = function(task) { var t = ko.toJS(task); $.ajax({ url: "http://localhost:9393/tasks", type: "POST", data: t }).done(function(data){ task.id(data.task.id); }); } } ko.applyBindings(new TaskViewModel());
Note the rearrangement of property declarations on the TaskViewModel
.
Conclusion
You now have the techniques to create more complex applications!
These two tutorials have taken you through the process of creating a single-page application with Knockout.js and Sinatra. The application can write and retrieve data, via a simple JSON interface, and it has features beyond simple CRUD actions, like mass deletion, sorting, and searching. With these tools and examples, you now have the techniques to create much more complex applications!
Comments