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.
What is Sinatra?
According to their website:
Sinatra is a DSL for quickly creating web applications in Ruby with minimal effort.
Sinatra allows you to do things, like:
get "/task/new" do erb :form end
This is a route that handles GET requests for "/task/new" and renders an erb
form named form.erb
. We won't be using Sinatra to render Ruby templates; instead, we'll use it only to send JSON responses to our Knockout.js managed front end (and some utility functions from jQuery like $.ajax
). We will be using erb only to render the main HTML file.
What is Knockout?
Knockout is a Model-View-ViewModel (MVVM) JavaScript framework that allows you to keep your models in special "observable" objects. It also keeps your UI up to date, based on those observed objects.
-ToDo/ -app.rb -models.rb --views/ -index.erb -- public / --- scripts/ - knockout.js - jquery.js - app.js --- styles/ - styles.css
Here is what you'll be building:
We'll get started by defining our model and then our CRUD actions in Sinatra. We'll rely on DataMapper and SQLite for persistent storage, but you can use any ORM that you prefer.
Let's add a task model to the models.rb
file:
DataMapper.setup(:default, 'sqlite:///path/to/project.db') class Task include DataMapper::Resource property :id, Serial property :complete, Boolean property :description, Text property :created_at, DateTime property :updated_at, DateTime end DataMapper.auto_upgrade!
This task model essentially consists of a few different properties that we want to manipulate in our to-do application.
Next, let's write our Sinatra JSON server. In the app.rb
file, we'll start by requiring a few different modules:
require 'rubygems' require 'sinatra' require 'data_mapper' require File.dirname(__FILE__) + '/models.rb' require 'json' require 'Date'
The next step is to define some global defaults; in particular, we need a MIME type sent with each of our response headers to specify that every response is JSON.
before do content_type 'application/json' end
The before
helper function runs before every route match. You can also specify matching routes after before
; if you, for instance, wanted to only run JSON responses if the URL ended in ".json", you would use this:
before %r{.+\.json$} do content_type 'application/json' end
Next, we define our CRUD routes, as well as one route to serve our index.erb
file:
get "/" do content_type 'html' erb :index end get "/tasks" do @tasks = Task.all @tasks.to_json end post "/tasks/new" do @task = Task.new @task.complete = false @task.description = params[:description] @task.created_at = DateTime.now @task.updated_at = null end put "/tasks/:id" do @task = Task.find(params[:id]) @task.complete = params[:complete] @task.description = params[:description] @task.updated_at = DateTime.now if @task.save {:task => @task, :status => "success"}.to_json else {:task => @task, :status => "failure"}.to_json end end delete "/tasks/:id" do @task = Task.find(params[:id]) if @task.destroy {:task => @task, :status => "success"}.to_json else {:task => @task, :status => "failure"}.to_json end end
So the app.rb
file now looks like this:
require 'rubygems' require 'sinatra' require 'data_mapper' require File.dirname(__FILE__) + '/models.rb' require 'json' require 'Date' before do content_type 'application/json' end get "/" do content_type 'html' erb :index end get "/tasks" do @tasks = Task.all @tasks.to_json end post "/tasks/new" do @task = Task.new @task.complete = false @task.description = params[:description] @task.created_at = DateTime.now @task.updated_at = null if @task.save {:task => @task, :status => "success"}.to_json else {:task => @task, :status => "failure"}.to_json end end put "/tasks/:id" do @task = Task.find(params[:id]) @task.complete = params[:complete] @task.description = params[:description] @task.updated_at = DateTime.now if @task.save {:task => @task, :status => "success"}.to_json else {:task => @task, :status => "failure"}.to_json end end delete "/tasks/:id" do @task = Task.find(params[:id]) if @task.destroy {:task => @task, :status => "success"}.to_json else {:task => @task, :status => "failure"}.to_json end end
Each of these routes maps to an action. There is only one view (the "all tasks" view) that houses every action. Remember: in Ruby, the final value returns implicitly. You can explicitly return early, but whatever content these routes return will be the response sent from the server.
Knockout: Models
Next, we start by defining our models in Knockout. In app.js
, place the following code:
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); }
As you can see, these properties are directly mapped to our model in models.rb
. A ko.observable
keeps the value updated across the UI when it changes without having to rely on the server or on the DOM to keep track of its state.
Next, we will add a TaskViewModel
.
function TaskViewModel() { var t = this; t.tasks = ko.observableArray([]); $.getJSON("/tasks", function(raw) { var tasks = $.map(raw, function(item) { return new Task(item) }); self.tasks(tasks); }); } ko.applyBindings(new TaskListViewModel());
This is the start of what will be the meat of our application. We begin by creating a TaskViewModel
constructor function; a new instance of this function is passed to the Knockout applyBindings()
function at the end of our file.
Inside our TaskViewModel
is an initial call to retrieve tasks from the database, via the "/tasks" url. These are then mapped into the ko.observableArray
, which is set to t.tasks
. This array is the heart of our application's functionality.
So, now, we have a retrieval function that shows tasks. Let's make a creation function, and then create our actual template view. Add the following code to the TaskViewModel
:
t.newTaskDesc = ko.observable(); 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.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); }); }
Knockout provides a convenient iteration ability...
First, we set newTaskDesc
as an observable. This allows us to use an input field easily to type a task description. Next, we define our addTask()
function, which adds a task to the observableArray
; it calls the saveTask()
function, passing in the new task object.
The saveTask()
function is agnostic of what kind of save it performs. (Later, we use the saveTask()
function to delete tasks or mark them as complete.) An important note here: we rely on a convenience function to grab the current timestamp. This will not be the exact timestamp saved into the database, but it provides some data to drop into the view.
The route is very simple:
get "/getdate" do {:date => DateTime.now}.to_json end
It should also be noted that the task's id is not set until the Ajax request completes, as we need to assign it based on the server's response.
Let's create the HTML that our newly created JavaScript controls. A large portion of this file comes from the HTML5 boilerplate index file. This goes into the index.erb
file:
<!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"> <input> <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> <tr> <td></td> <td></td> <td></td> <td></td> <td><input type="checkbox"> </td> <td class="destroytask"><a>X</a></td> </tr> </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>
Let's take this template and fill in the bindings that Knockout uses to keep the UI in sync. For this part, we cover the creation of To-Do items. In the part two, we will cover more advanced functionality (including searching, sorting, deleting, and marking as complete).
Before we move on, let's give our page a little bit of style. Since this tutorial isn't about CSS, we'll just drop this in and move right along. The following code is inside the HTML5 Boilerplate CSS file, which includes a reset and a few other things.
section { width: 800px; margin: 20px auto; } table { width: 100%; } th { cursor: pointer; } tr { border-bottom: 1px solid #ddd; } tr.complete, tr.complete:nth-child(odd) { background: #efffd7; color: #ddd; } tr:nth-child(odd) { background-color: #dedede; } td { padding: 10px 20px; } td.destroytask { background: #ffeaea; color: #943c3c; font-weight: bold; opacity: 0.4; } td.destroytask:hover { cursor: pointer; background: #ffacac; color: #792727; opacity: 1; } .fifty { width: 50%; } input { background: #fefefe; box-shadow: inset 0 0 6px #aaa; padding: 6px; border: none; width: 90%; margin: 4px; } input:focus { outline: none; box-shadow: inset 0 0 6px rgb(17, 148, 211); -webkit-transition: 0.2s all; background: rgba(17, 148, 211, 0.05); } input[type=submit] { background-color: #1194d3; background-image: -webkit-gradient(linear, left top, left bottom, from(rgb(17, 148, 211)), to(rgb(59, 95, 142))); background-image: -webkit-linear-gradient(top, rgb(17, 148, 211), rgb(59, 95, 142)); background-image: -moz-linear-gradient(top, rgb(17, 148, 211), rgb(59, 95, 142)); background-image: -o-linear-gradient(top, rgb(17, 148, 211), rgb(59, 95, 142)); background-image: -ms-linear-gradient(top, rgb(17, 148, 211), rgb(59, 95, 142)); background-image: linear-gradient(top, rgb(17, 148, 211), rgb(59, 95, 142)); filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr='#1194d3', EndColorStr='#3b5f8e'); padding: 6px 9px; border-radius: 3px; color: #fff; text-shadow: 1px 1px 1px #0a3d52; border: none; width: 30%; } input[type=submit]:hover { background: #0a3d52; } .floatleft { float: left; } .floatright { float: right; }
Add this code to your styles.css
file.
Now, let's cover the "new task" form. We will add data-bind
attributes to the form to make the Knockout bindings work. The data-bind
attribute is how Knockout keeps the UI in sync, and allows for event binding and other important functionality. Replace the "new task" form with the following code.
<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>
We'll step through these one by one. First, the form element has a binding for the submit
event. When the form is submitted, the addTask()
function defined on the TaskViewModel
executes. The first input element (which is implicitly of type="text") contains the value
of the ko.observable newTaskDesc
that we defined earlier. Whatever is in this field when submitting the form becomes the Task's description
property.
So we have a way to add tasks, but we need to display those tasks. We also need to add each of the task's properties. Let's iterate over the tasks and add them into the table. Knockout provides a convenient iteration ability to facilitate this; define a comment block with the following syntax:
<!-- ko foreach: tasks --> <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"></td> <td> <a>X</a></td> <!-- /ko -->
In Ruby, the final value is returns implicitly.
This uses Knockout's iteration capability. Each task is specifically defined on the TaskViewModel
(t.tasks
), and it stays in sync across the UI. Each task's ID is added only after we've finished the DB call (as there is no way to ensure that we have the correct ID from the database until it is written), but the interface does not need to reflect inconsistencies like these.
You should now be able to use shotgun app.rb
(gem install shotgun
) from your working directory and test your app in the browser at http://localhost:9393. (Note: make sure you have gem install
'd all of your dependencies/required libraries before you try to run your application.) You should be able to add tasks and see them immediately appear.
Until Part Two
In this tutorial, you learned how to create a JSON interface with Sinatra, and subsequently how to mirror those models in Knockout.js. You also learned how to create bindings to keep our UI in sync with our data. In the next part of this tutorial, we will talk solely about Knockout, and explain how to create sorting, searching, and updating functionality.
Comments