Advertisement
JavaScript & AJAX

Building Single Page Web Apps With Sinatra: Part 2

by

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:

  1. The box is checked if complete is true.
  2. 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!

Related Posts
  • Code
    JavaScript & AJAX
    Working With IndexedDB - Part 3Indexeddb retina preview
    Welcome to the final part of my IndexedDB series. When I began this series my intent was to explain a technology that is not always the most... friendly one to work with. In fact, when I first tried working with IndexedDB, last year, my initial reaction was somewhat negative ("Somewhat negative" much like the Universe is "somewhat old."). It's been a long journey, but I finally feel somewhat comfortable working with IndexedDB and I respect what it allows. It is still a technology that can't be used everywhere (it sadly missed being added to iOS7), but I truly believe it is a technology folks can learn and make use of today. In this final article, we're going to demonstrate some additional concepts that build upon the "full" demo we built in the last article. To be clear, you must be caught up on the series or this entry will be difficult to follow, so you may also want to check out part one.Read More…
  • Code
    JavaScript & AJAX
    Working With IndexedDB - Part 2Indexeddb retina preview
    Welcome to the second part of my IndexedDB article. I strongly recommend reading the first article in this series, as I'll be assuming you are familiar with all the concepts covered so far. In this article, we're going to wrap up the CRUD aspects we didn't finish before (specifically updating and deleting content), and then demonstrate a real world application that we will use to demonstrate other concepts in the final article.Read More…
  • Web Design
    HTML/CSS
    Foundation for Beginners: Joyride, Interchange, Tables and PanelsFoundation thumb retina
    Let's further add to our Foundation arsenal by looking at the Joyride plugin, which helps guide users through your sites. We'll also look at pricing tables, standard tables and interchange; a novel responsive image tool. We'll cover the implementation of these features with a simple template which you can download and play with.Read More…
  • Code
    Plugins
    Integrating Multiple Choice Quizzes in WordPress – Creating the FrontendIntegrating multiple choice quizzes in wordpress
    This is the second part of the series on developing a multiple choice quiz plugin for WordPress. In the first part we created the backend of our plugin to capture the necessary data to store in the database. In this final part, we will be creating the frontend of the plugin where the users can take quizzes and evaluate their knowledge.Read More…
  • Code
    Plugins
    Integrating Multiple Choice Quizzes in WordPress - Creating the BackendIntegrating multiple choice quizzes in wordpress
    Multiple choice questions are something that most of us have faced at least once in our life. We love them because we can provide correct answers by logically thinking about provided possibilities, even if we don't exactly know the correct answer. Also answering takes less time which makes it so popular. Creating a multiple choice quiz in WordPress can be a very exciting and profitable task. You can use it in your personal blog to attract more visitors, or you can create a premium section with advanced quizzes, or you can create quizzes focusing on popular certification exams. There are numerous possibilities for making it profitable.Read More…
  • Code
    JavaScript & AJAX
    Building Single Page Web Apps with Sinatra: Part 1Sinatra logo
    Have you ever wanted to learn how to build a single page app with Sinatra and Knockout.js? Well, today is the day you learn! In this first section of a two-part series, we'll review the process fo building a single page to-do application where users can view their tasks, sort them, mark them as complete, delete them, search through them, and add new tasks.Read More…