17: Resources Page: Links, Categories, and HABTM




Learning Rails show

Summary: <h2>Goals</h2> <p>In this lesson, we’re creating the database-driven Resources page, with links shown by category. Along the way, we look at join tables and <span class="caps">HABTM</span> associations.</p> <p>Please note that, while we’ve tried to make these notes complete, they aren’t the full tutorial; that’s in the screencast, which you can access via the link on the left.</p> <h2>Setup</h2> <p>We begin with the code with which we ended Lesson 16. These zip files contain the beginning and ending states of the code:</p> <ul> <li><a href="/learningrails_16.zip">Learning Rails example app code as of the start of this lesson</a></li> <li><a href="/learningrails_17.zip">Learning Rails example app code as of the end of this lesson</a></li> </ul> <h2>Adding the Links and Categories Models</h2> <p>For each link, we want to have a title and a description, and of course we need the <span class="caps">URL</span>. Let’s use the scaffold generator to create the model and the admin page:</p> <pre> script/generate scaffold link url:string title:string description:text </pre> <p>The “text” field type can hold longer strings that the “string” type, which is why we used it for the description.</p> <p>We also need a model for the categories, so let’s run another scaffold command for that simple model, which requires only a title and a description for each category:</p> <pre> script/generate scaffold category title:string description:text </pre> <h2>Creating the Join Table</h2> <p>When you have an association where one model has a <code>belongs_to</code> declaration, you know that model must have a field to store the foreign key that is what creates the association. In the case of Categories and Links, however, a category can have many links, and a link can belong to many categories, so what we need here is a <code>has_and_belongs_to_many</code> association.</p> <p>In this type of association, neither of the associated tables stores any foreign keys; instead, a separate join table stores just the pairs of foreign keys that define each association (i.e., associate one link with one category).</p> <p>We need to explicitly create this join table. There is no model associated with this table; it is just a database table that is use automatically along with the two associated models.</p> <p>Let’s generate an empty migration file with the migration generator:</p> <pre> script/generate migration LinkCategoryJoin </pre> <p>Note that the name is entirely arbitrary; we just want something that reminds us of what this migration is for.</p> <p>Now we define the migration by writing the <code>self.up</code> method:</p> <pre> def self.up create_table :categories_links, :id =&gt; false do |t| t.integer :category_id t.integer :link_id end end </pre> <p>This creates a table with two columns, each of which is one of the foreign keys. This table doesn’t get an id column of its own, so we add the option <code>:id =&gt; false</code> to the <code>create_table</code> method call.</p> <p>There’s several Rails naming defaults that come into play here, and you need to know what they are to write this code correctly:</p> <ol> <li>Join tables are always named with the names of the two associated tables, in alphabetical order, separated by an underscore. That’s why the table is called <code>categories_links</code>, and not <code>links_categories</code> (which won’t work because of this default).</li> <li>The foreign key fields are named with the name of the table they are referencing, with <code>_id</code> appended.</li> <li>The foreign key is referencing a single element in that table, so it uses the singular name (e.g., <code>category_id</code>, not <code>categories_id</code>).</li> </ol> <p>To keep our migrations reversible, we’ll add the <code>down</code> method:</p> <pre> def self.down drop_table :categories_links end </pre> <h2>Migrate!</h2> <p>We’ve now created three new migrations, one for categories, one for links, and one for the join table. A single command runs them all:</p> <pre> rake db:migrate </pre> <h2>Model Associations</h2> <p>We’ve defined the foreign keys when creating our models, but we still need to specify the associations for the model classes. In models/link.rb, we need to add to the empty <code>Link</code> class:</p> <pre> has_and_belongs_to_many :categories </pre> <p>This statement creates the association “link has and belongs to many categories.” Its presence allows us to write elsewhere in our code <code>link.categories</code> to retrieve the list of categories that has been assigned to this link. The join table that represents these assignments is managed for us automatically by the Rails framework.</p> <p>While we’re modifying the Link class, let’s require that at least a title be entered; there’s not much use to having a record without one:</p> <pre> validates_presence_of :title </pre> <p>Now we need to make a corresponding set of additions to the Category model. We’ll add to that empty class:</p> <pre> has_and_belongs_to_many :links validates_presence_of :title </pre> <p>This <span class="caps">HABTM</span> declaration allows us to write elsewhere in our code <code>category.links</code>, to find all the links associated with a category.</p> <h2>Finishing up the Admin Interface</h2> <p>You can now browse to <code>localhost:3000/links</code> to see the link admin page, but it still needs a bit of work.</p> <p>The Rails scaffold generator generates an empty layout file for every scaffold, assuming for some reason that we probably want a unique layout for each one. We don’t — we want to use the standard application layout. So we need to delete the extra files that the scaffold dumped into views/layouts.</p> <p>To protect the admin pages from public users, add to links_controller and categories_controller:</p> <pre> before_filter :login_required </pre> <p>For ease of access, let’s add links to our new admin pages to the admin home page. Log into the site, click the Admin button, and then click the Page Admin link. Click the Edit link for the Admin page, and add the following to the links that make up the page body:</p> <pre> "Category Admin":/categories "Link Admin":/links </pre> <p>(You can also use the Edit link on the Admin page, which triggers the in-place editor we created a couple lessons ago.)</p> <h2>Setting Categories</h2> <p>Now you can use the newly-created Category Admin link on the admin home page to create some categories; the scaffolded interface does everything we need. Go ahead and make a few.</p> <p>You can also add a link, using Link Admin, but there’s no place on the scaffolded link admin pages to specify the category. That’s because when we created the scaffold, the association didn’t exist. To use the standard Rails scaffolding, you need to manually modify the scaffold-generated view files to display or modify fields that come from associations.</p> <p>To create a category selector control on the new link form, we use the following slightly messy bit of code:</p> <pre> &lt;p&gt; &lt;b&gt;Category&lt;/b&gt;&lt;br /&gt; &lt;%= f.collection_select :category_ids, Category.find(:all, :order =&gt; 'title'), :id, :title, {}, :multiple =&gt; true %&gt; &lt;/p&gt; </pre> <p>The <code>collection_select</code> method creates an <span class="caps">HTML</span> form element that allows the user to choose from a list of items. The parameters passed are:</p> <ul> <li> <code>:category_ids</code> — the attribute this element is setting</li> <li> <code>Category.find(:all, :order =&gt; 'title')</code> — an array of objects that creates the list of choices</li> <li> <code>:id</code> — the value field of the objects in the list array</li> <li> <code>:title</code> — the name field of the objects in the list array</li> <li> <code>{}</code> — placeholder for an options hash that we’re not using</li> <li> <code>:multiple =&gt; true</code> — option to allow user to select multiple items</li> </ul> <p>With this code inserted in views/links/new.html.erb, you should now have a list of categories from which to choose (provided that you have added some categories to the database already).</p> <p>The same bit of code for the category selector needs to be added to the edit view as well. Even better, you could pull out the form guts into a partial, and invoke the same partial from both the new and edit view, as we did in a previous lesson.</p> <p>Now you can create some links and assign them to categories.</p> <h2>The Resources Page</h2> <p>For the Resources page, we want to show a list of all the links, sorted by category. The index action of the links controller is already use as part of the admin interface, so we need another action. We’ll add this <code>list</code> action to links_controller.rb:</p> <pre> def list @categories = Category.find(:all, :order =&gt; 'title') end </pre> <p>This action is simply providing the view with a list of categories.</p> <p>We also need to change the before_filter we set for login, so this action won’t require login. Change the line at the top of the controller to:</p> <pre> before_filter :login_required, :except =&gt; [:list] </pre> <p>Now, we need to create a new file, views/links/list.html.erb, to respond to this action; here’s the basic code:</p> <pre> &lt;h1&gt;Resources&lt;/h1&gt; &lt;% for category in @categories %&gt; &lt;h2&gt;&lt;%=h category.title %&gt;&lt;/h2&gt; &lt;p&gt;&lt;%=h category.description %&gt;&lt;/p&gt; &lt;ul&gt; &lt;% for link in category.links %&gt; &lt;li&gt;&lt;%= link_to link.title, link.url %&gt;&lt;/li&gt; &lt;% end %&gt; &lt;/ul&gt; &lt;% end %&gt; </pre> <p>Finally, since we’ve added another method to a RESTful controller, we need to declare that method in the route. In config/routes.rb, there is already a set of standard routes declared:</p> <pre> map.resources :links</pre> <p>To add the route, we modify this as follows:</p> <pre> map.resources :links, :collection =&gt; {:list =&gt; :get}</pre> <p>This specifies that the additional route operates on the entire collection of objects (not on a specific object), that its name is <code>:list</code>, and it responds to <span class="caps">HTTP</span> <span class="caps">GET</span> requests.</p> <p>Our list now works if you access it at <code>localhost:3000/links/list</code>.</p> <p>One small issue: if there’s an empty category (a category with no links) the category heading is still displayed. To make that go away, add a conditional around the loop in the list view:</p> <pre> &lt;% for category in @categories %&gt; &lt;% unless category.links.empty? %&gt; &lt;h2&gt;&lt;%=h category.title %&gt;&lt;/h2&gt; </pre> <p> </p> <h2>Redirecting Navigation</h2> <p>Now we have our Resources page being created from the database, and admin pages to add categories and links. Our last task is to connect it up the the navigation buttons.</p> <p>So far, our Resources page is a text page in our <span class="caps">CMS</span>. We’d like the Resources button to go to our list of links (/links/list), but the <span class="caps">CMS</span> doesn’t currently give us a way to do that. We want to keep using the page model to control navigation buttons, but allow page content to come from another controller, rather than from the page viewer.</p> <p>There’s many different ways to tackle this problem. The one we’ve chosen here, as the simplest to implement, is to add attributes to the Page model so any page can be specified as a “redirect” page, which should not be rendered by the viewer but instead by another controller and action.</p> <p>Let’s create a migration to add these elements to the model:</p> <pre> script/generate migration PageRedirect</pre> <p>The <code>up</code> method adds three attributes:</p> <pre> def self.up add_column :pages, :redirect, :boolean add_column :pages, :action_name, :string add_column :pages, :controller_name, :string end </pre> <p>And what up giveth, down taketh away:</p> <pre> def self.down remove_column :pages, :redirect remove_column :pages, :action_name remove_column :pages, :controller_name end </pre> <p>Now make the changes to the database:</p> <pre> rake db:migrate</pre> <h2>Updating the Page form</h2> <p>And now we just need to add the new fields to views/pages/_form.html.erb:</p> <pre> &lt;p&gt; &lt;b&gt;Redirect?&lt;/b&gt;&lt;br /&gt; &lt;%= f.check_box :redirect %&gt; &lt;/p&gt; &lt;p&gt; &lt;b&gt;Action&lt;/b&gt;&lt;br /&gt; &lt;%= f.text_field :action_name %&gt; &lt;/p&gt; &lt;p&gt; &lt;b&gt;Controller&lt;/b&gt;&lt;br /&gt; &lt;%= f.text_field :controller_name %&gt; &lt;/p&gt; </pre> <h2>Acting Upon Redirect Pages</h2> <p>Now that we have this redirect information in the page model, we need to use it!</p> <p>We replace the link_to that generates the nav buttons, in the application layout, to:</p> <pre> &lt;% if page.redirect? %&gt; &lt;%= link_to page.navlabel, :action =&gt; page.action_name, :controller =&gt; page.controller_name, :name =&gt; page.name %&gt; &lt;% else %&gt; &lt;%= link_to page.navlabel, view_page_path(page.name) %&gt; &lt;% end %&gt; </pre> <p>We pass the page name as a parameter so the action to which we’ve redirected can load the appropriate page object to get the page title and control the tab highlighting. So in the list action in the links controller, we add:</p> <pre> @page = Page.find_by_name(params[:name]) @pagetitle = @page.title </pre> <p>Now we need to edit the Resources page’s entry in the <span class="caps">CMS</span> to set it to redirect to the list action and the links controller, and voila! Our Resources button now takes us to the database-generated page.</p>