web bookmarks on Rails: step 10
January 13th, 2009 Posted in Ruby on Rails, planning, web bookmarks | 4,252 Comments »Want to start at the beginning?
- web bookmarks on Rails: step zero
- web bookmarks on Rails: step one
- web bookmarks on Rails: step two
- web bookmarks on Rails: step three
- web bookmarks on Rails: step four
- web bookmarks on Rails: step five
- web bookmarks on Rails: step six
- web bookmarks on Rails: step seven
- web bookmarks on Rails: step eight
- web bookmarks on Rails: step nine
It’s finally time to add some bookmarks to our web bookmark database.
In the final version of this app, I expect bookmarks to be fetched on a per-folder basis (or as a result of a search) via some AJAX interaction with the server. Since we’re postponing the fancy javascript for later, this iteration will involve setting up the bookmark support stuff - models and controllers - and we’ll put some stub code in the views just to show the bookmarks for now. Once all the pieces are in place, we’ll be able to devote some attention to the look and feel of the interface and integrate some javascript to smooth out the user interactions.
Our first step is to get rails to help us get started with some bookmark code. The scaffold generator will set up most of what we need to start off with.
$ cd $HOME/projects/webmarks $ ./script/generate scaffold bookmark
This will give us a new file in db/migrate called something like “20090102163151_create_bookmarks.rb”. Open up this file in your editor and make it look like this:
class CreateBookmarks < ActiveRecord::Migration def self.up create_table :bookmarks do |t| t.integer :user_id, :null => false t.integer :folder_id, :null => false t.integer :position t.string :name t.string :url, :limit => 1024 t.string :notes, :limit => 4096 t.timestamps t.foreign_key :user_id, :users, :id, :on_delete => :cascade, :on_update => :cascade end add_index :bookmarks, :user_id add_index :bookmarks, :folder_id add_index :bookmarks, :position end def self.down drop_table :bookmarks end end
Much of this new table is self-explanatory. A few pieces bear elaboration: “position” helps us set an arbitrary order on the bookmarks, so the user can re-order them at will. “user_id” and “folder_id” establish a parent-child relationships so we can track ownership and hierarchy. “notes” is a field that I’ve learned is useful from my prior bookmark application. It’s often useful to add some comment to a bookmark - a hint to the login credentials, or a note about how the bookmark is only valid until such-and-such a date, etc.
Next, implement the migration:
$ rake db:migrate
If you’re paranoid, you can log into your database and verify that the new table is in place before you continue.
The position column in our new table will be used to help us order the bookmarks within each folder. There is a standard rails plugin that can help us with that: acts_as_list. This plugin is pretty basic so in future steps will probably need to add some more methods to our model, but that doesn’t mean we can’t take advantage of what functions the plugin does provide.
A word of caution, though: acts_as_list used to be a plugin that was installed by default in any rails app when you say “rails someAppName”. However, since Rails 2.0, those plugins are not installed to cut down on application bloat, and the developer must install them by hand when needed. Also, it appears that the usual method of “./script/plugin install acts_as_list” doesn’t work either since this particular plugin has moved out of the standard repository and is exclusively served from github. You can install a plugin directly from a git repository, but only if your rails version is recent enough (circa 2.0.2 or higher). Here’s the command:
$ cd $HOME/projects/webmarks $ ./script/plugin install git://github.com/rails/acts_as_list.git
This will install “acts_as_list” in your “vendor” directory and it’s ready for use.
One other note: The “acts_as_list” plugin has a quirk that you should know about. When a list item is deleted, the first thing that happens is it is removed from the list. The remove_from_list code looks like this:
# Removes the item from the list. def remove_from_list if in_list? decrement_positions_on_lower_items update_attribute position_column, nil end end
Notice anything interesting? The position column is set to null in the database. This means we can’t set a “NOT NULL” constraint on our position column, or this code will throw active record exceptions all over the place. So, while it might make sense that the position column should never be null, if you’re going to use acts_as_list, you should not set that constraint. Note that the migration code above follows this recommendation.
Now we can update the stock model code that the scaffold generator created for our bookmark object. The new code looks like this:
class Bookmark < ActiveRecord::Base belongs_to :user belongs_to :folder acts_as_list :scope => :folder validates_presence_of :name, :url, :folder_id validates_format_of :url, :with => /\A[a-zA-Z]+\:\/\/[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*(\:\d+)?(\/.*)?\Z/, :message => "invalid URL format" validates_length_of :name, :maximum => 255 validates_length_of :url, :maximum => 1024 validates_length_of :notes, :maximum => 4096 end
Notes:
- The Bookmark object “belongs_to” user and also to folder. This will allow us to easily make associated references from either perspective later.
- The “acts”as_list” declaration adds the options hash for :scope, which tells the list management code that each folder has an independed counter for bookmarks.
- There are several data validations included: some required fields are designated, a fairly simple-minded URL formatting regex is used, and then some limits are set on text field lengths.
- We don’t dictate any rules for “position” since acts_as_list will be managing that column for us.
Since we establish that a bookmark belongs to a user and a folder, we need to update those respective models to show the other side of the association.
require 'digest/sha1' class User < ActiveRecord::Base has_many :folders has_one :root_folder, :class_name => 'Folder', :conditions => "parent_id IS null" has_many :bookmarks
class Folder < ActiveRecord::Base belongs_to :user has_many :bookmarks, :order => 'position'
Note the folder model includes a definition for “order” - this tells rails that, when it selects a bookmarks collection for a folder, it should order the results by the given column. In our case, that’s “position”.
There’s one other change we can make to the folder model. Our “empty?” method should take bookmarks into account when determining emptiness. Here’s the new method:
# Return true if this folder has no contents - either folders # or bookmarks. def empty? self.leaf? && self.bookmarks.count == 0 end
Next we need to modify the bookmarks controller. The changes we’re making are the same kinds of things we’ve updated in the folders controller already: Remove the “index” and “show” methods; Remove XML format responders; Set all methods to find/build bookmark through current_user association; Do current_user lookups within rescue blocks; On success, redirect back to “my” controller. Here’s the new controller code:
class BookmarksController < ApplicationController before_filter :require_active # GET /bookmarks/new def new @bookmark = Bookmark.new end # GET /bookmarks/1/edit def edit begin @bookmark = current_user.bookmarks.find(params[:id]) rescue Exception => exc logger.error("Invalid bookmark ID lookup: #{exc.message}") flash[:error] = 'Invalid bookmark ID.' redirect_to :controller => "my", :action => "index" end end # POST /bookmarks def create # create new bookmark through association without saving it to DB right away @bookmark = current_user.bookmarks.build(params[:bookmark]) begin # ensure that parent folder ID is really owned by current_user before saving @parent_folder = current_user.folders.find_by_id(params[:bookmark][:folder_id]) rescue Exception => exc logger.error("Invalid parent folder lookup: #{exc.message}") flash[:error] = 'Invalid parent folder.' render :action => "new" end if @bookmark.save flash[:notice] = 'Bookmark was successfully created.' redirect_to :controller => "my", :action => "index" else render :action => "new" end end # PUT /bookmarks/1 def update begin @bookmark = current_user.bookmarks.find(params[:id]) rescue Exception => exc logger.error("Invalid bookmark ID lookup: #{exc.message}") flash[:error] = 'Invalid bookmark ID.' redirect_to :controller => "my", :action => "index" end begin # ensure that parent folder ID is really owned by current_user before saving @parent_folder = current_user.folders.find_by_id(params[:bookmark][:folder_id]) rescue Exception => exc logger.error("Invalid parent folder lookup: #{exc.message}") flash[:error] = 'Invalid parent folder.' render :action => "edit" end if @bookmark.update_attributes(params[:bookmark]) flash[:notice] = 'Bookmark was successfully updated.' redirect_to :controller => "my", :action => "index" else render :action => "edit" end end # DELETE /bookmarks/1 def destroy begin @bookmark = current_user.bookmarks.find(params[:id]) rescue Exception => exc logger.error("Invalid bookmark ID lookup: #{exc.message}") flash[:error] = 'Invalid bookmark ID.' redirect_to :controller => "my", :action => "index" end @bookmark.destroy redirect_to :controller => "my", :action => "index" end end
Next we can update the views from the scaffold-created code and some of our existing pages. Some of the default functions won’t be needed in our interface model. For instance, the bookmarks will be displayed in the “my” views, so we won’t need “show” or “index” views that the scaffold generator gave us. Our “new” and “edit” views will still be used, though, at least until we replace them with some AJAX forms in the “my/index” view, so we should check those two out and make sure they’re doing what we need.
<h1>New bookmark</h1> <% form_for(@bookmark) do |f| %> <%= f.error_messages %> Name: <%= f.text_field :name %> <br /> URL: <%= f.text_field :url %> <br /> Notes: <%= f.text_field :notes %> <br /> Parent folder: <%= select("bookmark", "folder_id", current_user.root_folder.full_set.collect {|fol| [ fol.name, fol.id ] }) %> <p> <%= f.submit "Create" %> </p> <% end %> <%= link_to 'Back', :controller => "my" %>
<h1>Editing bookmark</h1> <% form_for(@bookmark) do |f| %> <%= f.error_messages %> Name: <%= f.text_field :name %> <br /> URL: <%= f.text_field :url %> <br /> Notes: <%= f.text_field :notes %> <br /> Parent folder: <%= select("bookmark", "folder_id", current_user.root_folder.full_set.collect {|fol| [ fol.name, fol.id ] }) %> <p> <%= f.submit "Update" %> </p> <% end %> <%= link_to 'Back', :controller => "my" %>
These two screens are nearly identical. The parent folder chooser isn’t terribly efficient, but it’s already scheduled for deprecation due to AJAX, so we won’t spend any more energy on it now. Note that the “Back” links have been updated to return to our “my/index” page instead of the scaffold default bookmark view.
We also need to update the “my” views - the index and partials - to incorporate our new bookmark data. First, the index page:
<h1>My Stuff</h1>
<div style="border:1px; padding:10px;">
<ol id="folderlist_<%= @root.id %>_subfolders" class="folderlist">
<%= render :partial => "subfolder", :collection => @folders %>
</ol>
<!-- Replace with AJAX display -->
<% marks = @root.bookmarks %>
<% if marks.length > 0 %>
<ul id="bookmarklist_<%= @root.id %>" class="bookmarklist">
<% marks.each() do |bookmark| %>
<li class="bookmark">
<a href="<%= bookmark.url %>" class="bookmark" target="bookmarkWindow"><%= bookmark.name %></a>
(<%= link_to 'Edit', edit_bookmark_path(bookmark) %>)
(<%= link_to 'Destroy', bookmark, :confirm => 'Are you sure?', :method => :delete %>)
</li>
<% end %>
</ul>
<% end %>
</div>
<br />
<%= link_to 'New folder', new_folder_path %> <br />
<%= link_to 'New bookmark', new_bookmark_path %>
<br />
<%= link_to 'Edit', edit_user_path(@user) %> |
<%= link_to 'Back', users_path %>There are a couple of additions here. First, the bookmarks are displayed in the block that begins with the “Replace with AJAX display” comment. Each folder will handle the display of its own bookmark set, so this page is only displaying the bookmarks owned by the root folder. Each bookmark has a “target” set that will cause each bookmark to open in a second window, a window which will be re-used by other bookmark clicks (instead of opening a new window for each bookmark). Each bookmark link also includes a link to the Edit and Destroy actions. Finally, at the end, we add a link to the New Bookmark page. None of this code is final, it’s only there so we can see our system working and run it through its paces. We’ll move things around in the interface later.
You might have noticed that there’s no interface for moving a bookmark to a new spot in the order within its folder. As with our folder views earlier, this is an interface task that I’d rather accomplish through drag and drop, so we’re postponing it for the time being.
Next, we add a similar bookmarks list block to our _subfolder partial:
<li id="folderlist_<%= subfolder.id %>" > <% if !subfolder.leaf? -%> <% if subfolder.open? -%> [ <%= link_to " - ", close_folder_path(subfolder), :method => :post %> ] <% else -%> [ <%= link_to " + ", open_folder_path(subfolder), :method => :post %> ] <% end -%> <% end -%> <%= subfolder.name %> (<%= link_to 'Edit', edit_folder_path(subfolder) %>) <% if subfolder.empty? -%> (<%= link_to 'Destroy', subfolder, :confirm => 'Are you sure?', :method => :delete %>) <% elsif subfolder.open? -%> <% if !subfolder.leaf? -%> <ol id="folderlist_<%= subfolder.id %>_subfolders" class="folderlist"> <%= render :partial => "subfolder", :collection => subfolder.children %> </ol> <% end -%> <!-- Replace with AJAX display --> <% marks = subfolder.bookmarks %> <% if marks.length > 0 -%> <ul id="bookmarklist_<%= subfolder.id %>" class="bookmarklist"> <% marks.each() do |bookmark| -%> <li class="bookmark"> <a href="<%= bookmark.url %>" class="bookmark" target="bookmarkWindow"><%= bookmark.name %></a> (<%= link_to 'Edit', edit_bookmark_path(bookmark) %>) (<%= link_to 'Destroy', bookmark, :confirm => 'Are you sure?', :method => :delete %>) </li> <% end -%> </ul> <% else -%> [ no marks ] <br /> <% end -%> <% end -%> </li>
The routes.rb file was already updated by the scaffold generator, which added the line “map.resources :bookmarks”, enabling all the REST-oriented URL handling we need for our actions. That done, our application should be ready for one last test before we commit. So it’s time to fire up the server again and create some bookmarks in the site.
$ cd $HOME/projects/webmarks $ ./script/server
Try making bookmarks in different levels of the folder hierarchy and then edit them, moving them around. You can also test out the URL validation by trying to create bookmarks with bogus URLs (like “http://google.”). At this point, you may find yourself wishing you could just type “google.com” in the URL bar, and expect the application to change it to “http://google.com/” instead of spitting out an error. That’s a convenience feature worth adding, but we’ll do it in another iterative step. For now, we just want bookmarks modeled and functional within our system - we’ll make them nice and easy later.
Is everything working? Then it’s time to do a commit. First, let’s check subversion status:
$ cd $HOME/projects/webmarks $ svn status -u
Look through the list of new and modified files. One entry that stood out for me was “scaffold.css”. This is a CSS stylesheet that was created by our scaffold generator, but we don’t need it. It’s easier and cleaner to get rid of it now so it doesn’t clutter up our version control codebase. Don’t forget to “svn add” all the other new files to version control tracking.
$ rm public/stylesheets/scaffold.css $ svn add test/unit/bookmark_test.rb $ svn add test/functional/bookmarks_controller_test.rb $ svn add test/fixtures/bookmarks.yml $ svn add app/helpers/bookmarks_helper.rb $ svn add app/models/bookmark.rb $ svn add app/controllers/bookmarks_controller.rb $ svn add app/views/bookmarks/ $ svn add db/migrate/[SomeTimestampHere]_create_bookmarks.rb $ svn add vendor/plugins/acts_as_list/
Now we’re ready to commit.
$ svn commit -m "Added bookmark object class with db migration, model, controller, views; Updated my/index views to display bookmarks; Added acts_as_list plugin"
Now we have a functional bookmark tracking application. In some ways that app is still minimal - we’ve intentionally postponed or ignored several useful features - but in other ways the application is surprisingly advanced given the small codebase. Have a look at your project, in the models, controllers, and views folders, and you’ll realize that we haven’t typed in that much code, especially when you consider how much was given to us in scaffolding and how much we copy/pasted from other places. In fact, the descriptions in this tutorial are quite a bit longer than the code itself. (Sorry.)
Now is a good time to evaluate where we stand and make up a list of items we want to tackle to take this web app from “functional” to “how did I ever live without it”.
- Rearrange my/index interface - different display panes for folders and bookmarks, make room for future features (see below)
- Tags for bookmarks
- Bookmarks search box (substring search on title, url, notes, tags)
- Click to select a folder, making it “active” - the active folder is the one whose bookmarks are showing. Active state is preserved across sessions.
- Replace New and Edit views with in-page popup panels, connected to server through AJAX, for both folders and bookmarks.
- Make folders and bookmarks both draggable to change order, hierarchy.
- “Remember search” feature: saves the search terms, not the result set, for later use
- “Sort by name” option for bookmarks in a folder - orders a folder’s bookmarks by name all at once
- Import/export bookmarks in popular browser formats (Firefox, IE, Safari, Chrome)
- Open/Close All option for folder tree
- Improved display of folder tree: small image for open/close toggle (little triangle?), nicer tree view
Those are the things that come to mind at the moment. I’m sure more will occur to me as the app progresses. Above all, though, the interface should remain clean, uncluttered, and highly utilitarian. This web app is not a high-bandwidth graphics showcase, it’s a utility that a serious user would refer to a hundred times a day. So it needs to stay clean and present the most-used interface elements front and center. I’ll probably work on the my/index layout next since that’s a prerequisite to implementing any AJAX features. Stay tuned.
