Advertisement
Ruby

Singing with Sinatra - The Recall App

by

Welcome to Track 2 of Singing with Sinatra. In part one, we reviewed Routes, how to work with URI parameters, working with forms, and how Sinatra differentiates routes by the HTTP method they were requested by. Today, we're going to extend our knowledge of Sinatra by building a small database-driven app, "Recall," for taking notes/making a to-do list.

We're going to be using a SQLite database to store the notes, and we'll use the DataMapper RubyGem to communicate with the database. Run the following inside a shell to install the relevant Gems:

gem install sqlite3 datamapper dm-sqlite-adapter

Depending on how you have RubyGems set up on your system, you may need to prefix gem install with sudo.


The Warm-Up

Let's jump right in by creating a new directory for the project, and creating the application file, recall.rb. Start it off by requiring the relevant gems:

require 'rubygems'
require 'sinatra'
require 'datamapper'

Note: If you're running Ruby 1.9 (which you should be), you can drop the "require 'rubygems'" line as Ruby automatically loads RubyGems anyway.

And set up the database with the following:

DataMapper::setup(:default, "sqlite3://#{Dir.pwd}/recall.db")

class Note
  include DataMapper::Resource
  property :id, Serial
  property :content, Text, :required => true
  property :complete, Boolean, :required => true, :default => false
  property :created_at, DateTime
  property :updated_at, DateTime
end

DataMapper.finalize.auto_upgrade!

On the first line we're setting up a new SQLite3 database in the current directory, named recall.db. Below that, we're actually setting up a 'Notes' table in the database.

While we're calling the class 'Note', DataMapper will create the table as 'Notes'. This is in keeping with a convention which Ruby on Rails and other frameworks and ORM modules follow.

Inside the class, we're setting up the database schema. The 'Notes' table will have 5 fields. An id field which will be an integer primary key and auto-incrementing (this is what 'Serial' means). A content field containing text, a boolean complete field and two datetime fields, created_at and updated_at.

The very last line instructs DataMapper to automatically update the database to contain the tables and fields we have set, and to do so again if we make any changes to the schema.


The Home Page

Now, let's create our home page:

At the top is a form to add a new note, and below it is all the notes in the database. To get started, add the following to the application file, recall.rb:

get '/' do
  @notes = Note.all :.order => :id.desc
  @title = 'All Notes'
  erb :home
end

Important Note: Remove the dot ('.') in :.order. (WordPress is interfering with the code sample.)

On the second line you see how we retrieve all the notes from the database. If you've used ActiveRecord (the ORM used in Rails) before, DataMapper's syntax will feel very familiar. The notes are assigned to the @notes instance variable. It's important to use instance variables (that's variables beginning with an @) so that they'll be accessible from within the view file.

We set the @title instance variable, and load the views/home.erb view file through the ERB parser.

Create the views/home.erb view file and start it off with the following:

<section id="add">
  <form action="/" method="post">
    <textarea name="content" placeholder="Your note&hellip;"></textarea>
    <input type="submit" value="Take Note!">
  </form>
</section>

<% # display notes %>

We have a simple form which POSTs to the home page ('/'), and below that is some ERB code serving as a placeholder for now.


Layouts

The HTML standards lot amongst you may have suffered a minor stroke after seeing that our home view file contains no doctype or other HTML tags. Well, there's a reason for that. Create a layout.erb file in your views/ directory containing the following:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf8">
  <title><%= @title + ' | Recall' %></title>
  <link href="/reset.css" rel="stylesheet">
  <link href="/style.css" rel="stylesheet">
</head>
<body>
  <header>
    <hgroup>
      <h1><a href="/">Recall</a></h1>
      <h2>'cause you're too busy to remember</h2>
    </hgroup>
  </header>

  <div id="main">
    <%= yield %>
  </div>

  <footer>
    <p><small>An app for <a href="http://net.tutsplus.com">Nettuts+</a>.</small></p>
  </footer>
</body>
</html>

The two interesting parts here are lines 5 and 18. On line 5 you see the first use of the <%= … %> ERB tags. <%= is different from the ordinary <% as it prints what is inside. So here we're displaying the whatever's in the @title instance variable followed by | Recall for the page's <title> tag.

On line 18 is <%= yield %>. Sinatra will display this layout.erb file on all Routes. And the actual content for that route will be inserted wherever the yield is. yield is a term which essentially means "stop here, insert whatever's waiting, then continue on".

Start up the server with shotgun recall.rb in the shell, and take a look at the home page in the browser. You should see content from the layout file, and the form from the actual home.erb view.


CSS

In the layout file we included two CSS files. Sinatra can load static files (eg. your CSS, JS, images etc.) from a folder named public/ in the root directory. So create that directory, and inside it two files: reset.css and style.css. The reset contains the HTML5 Boilerplate CSS reset:

/*
	HTML5 ✰ Boilerplate

	style.css contains a reset, font normalization and some base styles.

	credit is left where credit is due.
	much inspiration was taken from these projects:
		yui.yahooapis.com/2.8.1/build/base/base.css
		camendesign.com/design/
		praegnanz.de/weblog/htmlcssjs-kickstart
*/

/*
	html5doctor.com Reset Stylesheet (Eric Meyer's Reset Reloaded + HTML5 baseline)
	v1.6.1 2010-09-17 | Authors: Eric Meyer & Richard Clark
	html5doctor.com/html-5-reset-stylesheet/
*/

html, body, div, span, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
abbr, address, cite, code, del, dfn, em, img, ins, kbd, q, samp,
small, strong, sub, sup, var, b, i, dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, figcaption, figure,
footer, header, hgroup, menu, nav, section, summary,
time, mark, audio, video {
	margin:0;
	padding:0;
	border:0;
	font-size:100%;
	font: inherit;
	vertical-align:baseline;
}

article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
		display:block;
}

blockquote, q { quotes:none; }

blockquote:before, blockquote:after,
q:before, q:after { content:''; content:none; }

ins { background-color:#ff9; color:#000; text-decoration:none; }

mark { background-color:#ff9; color:#000; font-style:italic; font-weight:bold; }

del { text-decoration: line-through; }

abbr[title], dfn[title] { border-bottom:1px dotted; cursor:help; }

table { border-collapse:collapse; border-spacing:0; }

hr { display:block; height:1px; border:0; border-top:1px solid #ccc; margin:1em 0; padding:0; }

input, select { vertical-align:middle; }

/* END RESET CSS */

/* font normalization inspired by  from the YUI Library's fonts.css: developer.yahoo.com/yui/ */
body { font:13px/1.231 sans-serif; *font-size:small; } /* hack retained to preserve specificity */
select, input, textarea, button { font:99% sans-serif; }

/* normalize monospace sizing
 * en.wikipedia.org/wiki/MediaWiki_talk:Common.css/Archive_11#Teletype_style_fix_for_Chrome */
pre, code, kbd, samp { font-family: monospace, sans-serif; }

/*
 * minimal base styles
 */

body, select, input, textarea {
	/* #444 looks better than black: twitter.com/H_FJ/statuses/11800719859 */
	color: #444;
	/* set your base font here, to apply evenly */
	/* font-family: Georgia, serif;  */
}

/* headers (h1,h2,etc) have no default font-size or margin. define those yourself. */
h1,h2,h3,h4,h5,h6 { font-weight: bold; }

/* always force a scrollbar in non-IE: */
html { overflow-y: scroll; }

/* accessible focus treatment: people.opera.com/patrickl/experiments/keyboard/test */
a:hover, a:active { outline: none; }

a, a:active, a:visited { color: #607890; }
a:hover { color: #036; }

ul, ol { margin-left: 2em; }
ol { list-style-type: decimal; }

/* remove margins for navigation lists */
nav ul, nav li { margin: 0; list-style:none; list-style-image: none; }

small { font-size: 85%; }
strong, th { font-weight: bold; }

td { vertical-align: top; }

/* set sub, sup without affecting line-height: gist.github.com/413930 */
sub, sup { font-size: 75%; line-height: 0; position: relative; }
sup { top: -0.5em; }
sub { bottom: -0.25em; }

pre {
	/* www.pathf.com/blogs/2008/05/formatting-quoted-code-in-blog-posts-css21-white-space-pre-wrap/ */
	white-space: pre; white-space: pre-wrap; white-space: pre-line; word-wrap: break-word;
	padding: 15px;
}

textarea { overflow: auto; } /* www.sitepoint.com/blogs/2010/08/20/ie-remove-textarea-scrollbars/ */

.ie6 legend, .ie7 legend { margin-left: -7px; } /* thnx ivannikolic! */

/* align checkboxes, radios, text inputs with their label by: Thierry Koblentz tjkdesign.com/ez-css/css/base.css  */
input[type="radio"] { vertical-align: text-bottom; }
input[type="checkbox"] { vertical-align: bottom; }
.ie7 input[type="checkbox"] { vertical-align: baseline; }
.ie6 input { vertical-align: text-bottom; }

/* hand cursor on clickable input elements */
label, input[type="button"], input[type="submit"], input[type="image"], button { cursor: pointer; }

/* webkit browsers add a 2px margin outside the chrome of form elements */
button, input, select, textarea { margin: 0; }

/* colors for form validity */
input:valid, textarea:valid   {  }
input:invalid, textarea:invalid {
			border-radius: 1px; -moz-box-shadow: 0px 0px 5px red; -webkit-box-shadow: 0px 0px 5px red; box-shadow: 0px 0px 5px red;
}
.no-boxshadow input:invalid, .no-boxshadow textarea:invalid { background-color: #f0dddd; }

/* These selection declarations have to be separate.
	 No text-shadow: twitter.com/miketaylr/status/12228805301
	 Also: hot pink. */
::-moz-selection{ background: #FF5E99; color:#fff; text-shadow: none; }
::selection { background:#FF5E99; color:#fff; text-shadow: none; }

/*  j.mp/webkit-tap-highlight-color */
a:link { -webkit-tap-highlight-color: #FF5E99; }

/* make buttons play nice in IE:
	 www.viget.com/inspire/styling-the-button-element-in-internet-explorer/ */
button {  width: auto; overflow: visible; }

/* bicubic resizing for non-native sized IMG:
	 code.flickr.com/blog/2008/11/12/on-ui-quality-the-little-things-client-side-image-resizing/ */
.ie7 img { -ms-interpolation-mode: bicubic; }

And style.css contains some basic styling to make the app look pretty:

body {
	margin: 35px auto;
	width: 640px;
}

header {
	text-align: center;
	margin: 0 0 20px;
}

header h1 {
	display: inline;
	font-size: 32px;
}

header h1 a:link, header h1 a:visited {
	color: #444;
	text-decoration: none;
}

header h2 {
	font-size: 16px;
	font-style: italic;
	color: #999;
}

#main {
	margin: 0 0 20px;
}

#add {
	margin: 0 0 20px;
}

#add textarea {
	height: 30px;
	width: 510px;
	padding: 10px;
	border: 1px solid #ddd;
}

#add input {
	height: 50px;
	width: 100px;
	margin: -50px 0 0;
	border: 1px solid #ddd;
	background: white;
}

#edit textarea {
	height: 30px;
	width: 480px;
	padding: 10px;
	border: 1px solid #ddd;
}

#edit input[type=submit] {
	height: 50px;
	width: 100px;
	margin: -50px 0 0;
	border: 1px solid #ddd;
	background: white;
}

#edit input[type=checkbox] {
	height: 50px;
	width: 20px;
}

article {
	border: 1px solid #eee;
	border-top: none;
	padding: 15px 10px;
}

article:first-of-type {
	border: 1px solid #eee;
}

article:nth-child(even) {
	background: #fafafa;
}

article.complete {
	background: #fedae3;
}

article span {
	font-size: 0.8em;
}

p {
	margin: 0 0 5px;
}

.meta {
	font-size: 0.8em;
	font-style: italic;
	color: #888;
}

.links {
	font-size: 1.8em;
	line-height: 0.8em;
	float: right;
	margin: -10px 0 0;
}

.links a {
	display: block;
	text-decoration: none;
}

Refresh the page in your browser and everything should be more styled. Don't worry about this CSS too much; it just makes things look a bit prettier!


Adding a Note to the Database

Right now if you try submitting the form on the home page, you're going to get a route error. Let's create the POST route for the home page now:

post '/' do
  n = Note.new
  n.content = params[:content]
  n.created_at = Time.now
  n.updated_at = Time.now
  n.save
  redirect '/'
end

So when a post request is made on the homepage, we create a new Note object in n (thanks to the DataMapper ORM, Note.new represents a new row in the notes table in the database). The content field is set to the submitted data from the textarea and the created_at and updated_at datetime fields are set to the current timestamp.

The new note is then saved, and the user redirected back to the homepage where the new note will be displayed.


Displaying the Notes

So we've added a new note, but we can't see it on the homepage yet as we haven't wrote the code for it. Inside the views/home.erb view file, replace the <%# display notes %> line with:

<% @notes.each do |note| %>
  <article <%= 'class="complete"' if note.complete %>>
    <p>
      <%= note.content %>
      <span><a href="/<%= note.id %>">[edit]</a></span>
    </p>
    <p class="links">
      <a href="/<%= note.id %>/complete">&#8623;</a>
    </p>
    <p class="meta">Created: <%= note.created_at %></p>
  </article>
<% end %>

On the first line we begin a loop through each of the @notes (alternatively, we could have wrote for note in @notes, but using a block, as we are here, is a better practice). On line 2, we give the <article> a class of complete if the current note is set to complete. The rest should be pretty straight forward.


Editing a Note

So we can add and view notes. Now we just need the ability to edit and delete them.

You may have noticed that in our home.erb view we set an [edit] link for each note to what is essentially /:id, so let's create that route now:

get '/:id' do
  @note = Note.get params[:id]
  @title = "Edit note ##{params[:id]}"
  erb :edit
end

We retrieve the requested note from the database using the ID provided, set up a @title variable, and load the views/edit.erb view file through the ERB parser.

Enter the following for the views/edit.erb view:

<% if @note %>
  <form action="/<%= @note.id %>" method="post" id="edit">
    <input type="hidden" name="_method" value="put">
    <textarea name="content"><%= @note.content %></textarea>
    <input type="checkbox" name="complete" <%= "checked" if @note.complete %>>
    <input type="submit">
  </form>
  <p><a href="/<%= @note.id %>/delete">Delete</a></p>
<% else %>
  <p>Note not found.</p>
<% end %>

This is a fairly simple view. A form which points back to the current page, a textarea containing the content of the note and a checkbox which gets checked if the note is set to complete.

But look at the third line. Mysterious. To explain this, we need to side-track a little.

RESTful Services

You've heard of the two terms GET and POST.

  • GET: The most common. It's generally for requesting a page, and can be bookmarked.
  • POST: Used for submitting data and can not be bookmarked.

But GET and POST aren't the only "HTTP verbs" - there's two more you should know about: PUT and DELETE.

Technically, POST should only be used for creating something - like creating a new Note in your awesome new web app, for example.

PUT is the verb for modifying something. And DELETE, you guessed it, is for deleting something.

Having these four verbs is a great way to separate an app up. It's logical. Unfortunately, web browsers don't actually support PUT or DELETE requests, which is why you've likely never heard of them before.

So, getting back on track here, if we want to logically split our app up (which Sinatra encourages), we have to fake these PUT and DELETE requests. You'll see our form's action is set to post. The hidden _method input field which we've set to put on the third line lets Sinatra fake this PUT request, while actually using a POST. Rails, among other frameworks, do things a similar way.


Let us PUT

Now we've faked our PUT request, we can create a route for it:

put '/:id' do
  n = Note.get params[:id]
  n.content = params[:content]
  n.complete = params[:complete] ? 1 : 0
  n.updated_at = Time.now
  n.save
  redirect '/'
end

It's all pretty simple. We get the relevant note using the ID in the URI, set the fields to the new values, save, and redirect home. Notice how on the fourth line we're using a ternary operator to set n.complete to 1 if params[:complete] exists, or 0 otherwise. This is because the value of a checkbox is only submitted with a form if it is checked, so we're simply checking for the existence of it.


Deleting a Note

In our edit.erb view, we added a 'Delete' link to what is essentially the path /:id/delete. Add this to your application file:

get '/:id/delete' do
  @note = Note.get params[:id]
  @title = "Confirm deletion of note ##{params[:id]}"
  erb :delete
end

On this page we'll get confirmation from the user that they actually want to delete this note. Create the view file at views/delete.erb with the following:

<% if @note %>
  <p>Are you sure you want to delete the following note: <em>"<%= @note.content %>"</em>?</p>
  <form action="/<%= @note.id %>" method="post">
    <input type="hidden" name="_method" value="delete">
    <input type="submit" value="Yes, Delete It!">
    <a href="/<%= @note.id %>">Cancel</a>
  </form>
<% else %>
  <p>Note not found.</p>
<% end %>

Note that just like how we faked a PUT request by setting a hidden _method input field, we're now faking a DELETE request.


The DELETE Route

I'm sure you're getting the hang of this by now. The delete route is:

delete '/:id' do
  n = Note.get params[:id]
  n.destroy
  redirect '/'
end

Try it out! You should now be able to view, add, edit and remove notes. There's just one more thing…


Marking a Note as "Complete"

Right now if you want to set a note as complete you have to go into the Edit view and check the box on that page. Let's make that process a bit simpler.

Back when we set up the main home page, we included a /:id/complete link on each note. Let's make that route now, which will simply set a note as complete (or incomplete if it was already set to complete):

get '/:id/complete' do
  n = Note.get params[:id]
  n.complete = n.complete ? 0 : 1 # flip it
  n.updated_at = Time.now
  n.save
  redirect '/'
end

Conclusion

You and Sinatra pull off one crackin' duet! You've very quickly written a simple web app which performs all the CRUD operations you'd expect an app to do. It's written in super-sexy-clean Ruby code, and is separated into its logical parts.

In the final part of Singing with Sinatra, the Encore, we'll improve on error handling, secure the app from XSS and create an RSS feed for the notes.

Note: You can browse the final project files for this tutorial over at GitHub.

Related Posts
  • Code
    HTML5
    HTML5: Battery Status APIPdl54 preview image@2x
    The number of people browsing the web using mobile devices grows every day. It's therefore important to optimize websites and web applications to accommodate mobile visitors. The W3C (World Wide Web Consortium) is well aware of this trend and has introduced a number of APIs that help with this challenge. In this article, I will introduce you to one of these APIs, the Battery Status API.Read More…
  • Web Design
    UX
    Implementing the Float Label Form PatternForm float input retina
    Using Matt Smith’s mobile form interaction design as a guide, we will create a stunning form interaction for the web that’s both beautiful and accessible using HTML, CSS and JavaScript.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…
  • Code
    Scala
    Building Ribbit in ScalaRibbit scala retina preview
    In this tutorial we will implement the Ribbit application in Scala. We'll be covering how to install the Play web framework, a NetBeans plugin for it, and finally the code in Scala. If you are new to Scala, check out this previous tutorial which will help you set up your environment and provides you with a general platform that you can build upon. Even though the essence of Ribbit is to create/send/read Ribbits (our version of tweets), we will spend a large part of this tutorial explaining how Play works, authentication, and persistence. After these are in place, the rest becomes much easier. We will also implement ribbit creation, submission and listing out all ribbits. Following someone, advanced user settings, and direct messages will be an extra assignment for you to complete on your own. I am sure if you manage to follow along with this tutorial and create Ribbit as explained below, these three functionalities will be easily accomplished as homework.Read More…
  • Code
    JavaScript & AJAX
    Building Ribbit in MeteorRibbit meteor preview retina
    This is a continuation of the Twitter clone series with building Ribbit from scratch, this time using Meteor.Read More…
  • Code
    Ruby
    Building Ribbit in RailsRibbit rails
    Welcome to the next installment in our Twitter clone series! In this tutorial, we'll build Ribbit from scratch, not using PHP, but with Ruby on Rails. Let's get started!Read More…