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…"></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">↯</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.
Comments