How to Build Forums in Rails 3 Posted on October 20th, 2010
This tutorial will go over the basic steps to create a forums app. The forums will extend from my previous blog post about integrating Rails 3 with AuthLogic. If you are new to rails I'd high recommend reading it, otherwise continue. First download the source code from the AuthLogic App and move the code to more logically named directory for this project.
$ mkdir forums $ git init
$ git clone git://github.com/baileylo/login_app.git forums
$ cd forums
$ rake db:migrate $ rails -s
Feel free to verify that everything is working, you should be able to register and login at /users
.
The Backend
Rails has reserved "thread" has a model name so this will create a weird model name but the other two model names are straight forward: Board, Conversation, Comment.
Board is a set of boards, groupings of conversations. Conversation, probably more commonly referred to as "Thread", is list of Comments made by users. In our forum we'll keep it simple, A Board will contain only a name and an id, a Conversation will belong to a board. A Conversation will have a title and a creating user. Comments will have only a body a posting user and a reference to the conversation they belong to.
$ rails g scaffold board id:integer title:string
$ rails g scaffold conversation id:integer title:string board_id:integer user_id:integer
$ rails g scaffold comment id:integer user_id:integer conversation_id:integer body:text
This created all the controllers, views, models, and migration files needed. Open up the three migration files created, and edit them to look as follows.
class CreateBoards < ActiveRecord::Migration
def self.up
create_table :boards do |t|
t.integer :id
t.string :title, :limit => 50
t.timestamps
end
end
def self.down
drop_table :boards
end
end
class CreateComments < ActiveRecord::Migration
def self.up
create_table :comments do |t|
t.integer :id
t.integer :user_id
t.integer :conversation_id
t.text :body
t.timestamps
end
add_index :comments, :conversation_id
add_index :comments, :user_id
end
def self.down
drop_table :comments
end
end
class CreateConversations < ActiveRecord::Migration
def self.up
create_table :conversations do |t|
t.integer :id
t.string :title, :limit => 50
t.integer :board_id
t.integer :user_id
t.timestamps
end
add_index :conversations, :board_id
add_index :conversations, :user_id
end
def self.down
drop_table :conversations
end
end
These changes add indexes to our Conversations and Comments tables. These will add a much needed performance boost when querying the database. They also added size limitations to board.title and conversation.title of 50 characters.
Now create the database tables by running:
$ rake db:migrate
Some Quick Backend Additions
Feel free to browse around the site, you'll notice all the forms work correctly, but none of the objects are linked together. To tell active record that these models are linked we must edit the Model Classes. Open up your newly created model classes and edit them to look like this:
class Board < ActiveRecord::Base
has_many :conversations
end
class Comment < ActiveRecord::Base
belongs_to :conversation
belongs_to :user
validates_presence_of :body
end
class Conversation < ActiveRecord::Base
has_many :comments
belongs_to :user
belongs_to :board
validates_presence_of :title
end
In this we see two very important ideas. We've added form validation as well as ORM hooks that define relationships between our models. In board.rb and conversation.rb we added has_many :conversations
and has_many :comments
, respectively, this informs Rails these are 1 to many relationships. You can see the rails docs for has_many here. This makes the relationship from one conversation to many comments. In conversation.rb
and comments.rb
you can see the belongs_to function, this function tells rails that these objects belong to another specific object, you can see the docs for belongs_to here. validates_presence_of
is called when a save, update, or create are called on an object. This function makes sure that there is data stored in these member variables. We specified that conversation.title and comment.body must be required, we don't want any empty posts. Board.title was purposefully skipped, it is not for general use.
If you navigate to your conversations page: http://localhost:3000/conversations
and try to create a message, not including a title, you will get a nice error saying that it is a required field. You will how ever notice that you can make this field as long as you like even though we specified it should only be 50 characters long. Lets fix this, add the following line of code to your conversation.rb file:
validates_length_of :title, :maximum=>50
If you try again you will see an error message stating that the:
the input must be less than 50 characters
If you've created any data, you may want to clean it up now, it may cause problems later in this demonstration.
Routes!
Now that we have the backend in order, lets create the routes needed to use this message board. Open up your config/routes.rb
file and change it to look like this:
LoginApp::Application.routes.draw do
resources :boards do
resources :conversations
end
root :to => "boards#index"
resources :users, :user_sessions
match 'login' => 'user_sessions#new', :as => :login
match 'logout' => 'user_sessions#destroy', :as => :logout
end
Delete your public/index.html
file, it's no longer needed.
Now http://localhost:3000/
will display a list of your Boards, instead of the rails information page.
In your console type:
$ rake routes
This is a list of every url that your app can handle, the most important are the top few. We've now created a url hierarchy. All of a conversations will be listed by /boards/:board_id/conversations/:id.
Jumping into the view
Our site looks okay, but lets try and spiff it up a bit. Open up your views/layouts/index.html
and change it to look like:
<!DOCTYPE html>
<html>
<head>
<title>Rails Message Boards</title>
<%= stylesheet_link_tag :all %>
<%= javascript_include_tag :defaults %>
<%= csrf_meta_tag %>
</head>
<body>
<div id="wrapper">
<div id="top_nav">
<h1><%= image_tag("rails.png", :size => '50x50') %> Rails Boards</h1>
<% if current_user %>
<%= link_to "Edit Profile", edit_user_path(current_user.id)%> |
<%= link_to "Logout", :logout%>
<% else %>
<%= link_to "Register", new_user_path%> |
<%= link_to "Login", :login %>
<% end %>
<div class="bclear"></div>
</div>
<div id="content">
<%= yield %>
</div>
<div id="footer">
</div>
</div>
</body>
</html>
Create a new file in public/stylesheets
called style.css
and copy this style sheet:
/* Layout Styles */
#wrapper {
width: 750px;
margin: 25px auto;
border: 1px solid black;
}
#top_nav {
padding: 5px;
text-align: right;
border-bottom: 1px solid black;
}
#top_nav h1 {
display:inline;
float: left;
}
#top_nav h1 img {
vertical-align:middle;
}
#content {
padding: 0px 5px 5px 5px;
clear: both;
}
#content h1 {
font-size: 14pt;
}
#content h1#page_title {
text-align:right;
margin-top: 0px;
}
#content table {
width: 95%;
margin: 5px auto;
}
/* Helper styles */
.bclear {
clear: both;
}
At https://localhost:3000
you should be greeted by a much friendlier page. If you haven't created a board yet, feel free to create one now using the "Create Board" link at the bottom of the page. You can put in you own ID if you want, if you leave it blank rails will auto increment the id.
Lets fix up that homepage a bit and make it look slightly more professional. Open up views/boards/index.html
, and change it to look like this:
<h1 id="page_title">Message Boards</h1>
<p id="notice"><%= notice %></p>
<table>
<tr>
<th align="left">Title</th>
</tr>
<% @boards.each do |board| %>
<tr>
<td><%= link_to board.title, board %></td>
</tr>
<% end %>
</table>
Now that our landing page looks decent, lets see what happens when we view our message board. Not what anybody envisions a messageboard to be looking like, lets make some quick cosmetic changes before moving into the controller. Open up app/views/boards/show.html.erb
and make it the following changes:
<h1 id="page_title"><%= @board.title %></h1>
<p id="notice"><%= notice %></p>
<%= link_to 'Rails Boards', boards_path %> > <%= link_to @board.title, @board %> <br />
<%= link_to 'Post New Message', new_board_conversation_url(@board) %>
As you can see here we added a "Post New Message" link, the url function did not come out of thin air, if you run rake routes again. You'll see there is a path named "new_board_conversation", you can use any of those as a url by appending _url or _path to them.
If you click on the link to post new message you'll get "No Routes Matches" error. We'll fix that in a minute, but first lets make some adjustments to our conversation controller, open up app/controllers/conversation.rb and make the follow changes:
class ConversationsController < ApplicationController
before_filter :load_board
# GET /conversations
# GET /conversations.xml
def index
@conversations = Conversation.all
respond_to do |format|
format.html # index.html.erb
format.xml { render :xml => @conversations }
end
end
# CODE REMOVED FOR CLARITY SAKE
private
def load_board
if Board.exists?(params[:board_id])
@board = Board.find(params[:board_id]);
end
unless @board
redirect_to(boards_path, :notice =>"Please specify a valid board")
end
end
end
We added a private function that tries to load a Board off a passed in parameter. This function will be called on any page that goes through this controller.
Modify AuthLogic
If you have tried to login you may have noticed that we skipped a step. The login, logout, and register functions are not exactly tied to our application. To fix this we need to correct links in the views and redirect the controllers. While we're doing this it will be a good time to remove functions that the app won't be using. Open up app/controllers/users_controller.rb
and edit it to look like so:
class UsersController < ApplicationController
# GET /users/1
def show
@user = User.find(params[:id])
end
# GET /users/new
def new
@user = User.new
if current_user
redirect_to(homepage_url, :notice => 'Already registered')
end
end
# GET /users/1/edit
def edit
is_user
end
# POST /users
# POST /users.xml
def create
@user = User.new(params[:user])
@user_session = UserSession.new(params[:user])
respond_to do |format|
if @user.save && @user_session.save
format.html { redirect_to(homepage_url, :notice => 'Registration successfull.') }
else
format.html { render :action => "new" }
end
end
end
# PUT /users/1
def update
is_user
respond_to do |format|
if @user.update_attributes(params[:user])
format.html { redirect_to(@user, :notice => 'User was successfully updated.') }
else
format.html { render :action => "edit" }
end
end
end
private
def is_user
if User.exists?(params[:id])
@user = User.find(params[:id]);
if !current_user || current_user.id != @user.id
redirect_to(homepage_url, :notice =>"You do not have access to that page")
end
else
redirect_to(homepage_url, :notice =>"You do not have access to that page")
end
end
end
In this controller we added a function is_user. This function determines if the user is logged in and they are the correct user to be viewing specific pages. We also changed redirect urls to the site homepage and removed a lot of unused code. Now lets update our User View: We'll have to change the links on all the pages as well as display less confidential information on the Profile page.
<!-- edit.html.erb -->
<h1 id="page_title">Edit Your Profile</h1>
<%= render 'form' %>
<%= link_to 'Show', @user %> |
<%= link_to 'Back', homepage_url %>
<!-- new.html.erb -->
<h1 id="page_title">Registration</h1>
<%= render 'form' %>
<%= link_to 'Back', homepage_url %>
<!-- show.html.erb -->
<h1 id="page_title"><%= @user.username %>'s Profile</h1>
<p id="notice"><%= notice %></p>
<p>
<b>Username:</b>
<%= @user.username %>
</p>
<p>
<b>Email:</b>
<%= @user.email %>
</p>
<%= link_to 'Rails Boards', homepage_url %>
Now we just have to fix the Login view and the login/logout controller. These files need to have some cosmetic changes and fix issues with redirects open up app/views/user_sessions/new.html.erb
and app/controllers/user_sessions_controller.rb
and edit them to look like the following:
<!-- new.html.erb -->
<h1 id="page_title">Login</h1>
<%= render 'form' %>
<%= link_to 'Back', homepage_url %>
#user_session_controller.rb
class UserSessionsController < ApplicationController
# GET /user_sessions/new
def new
@user_session = UserSession.new
end
# POST /user_sessions
def create
@user_session = UserSession.new(params[:user_session])
if @user_session.save
redirect_to(homepage_url, :notice => 'Login Successful')
else
render :action => "new"
end
end
# DELETE /user_sessions/1
def destroy
@user_session = UserSession.find
@user_session.destroy
redirect_to(homepage_url, :notice => 'Goodbye!')
end
end
Now we just changed all the automatic form rerouting to point to our default homepage, added some basic styling, and changed the links in the site to point to more logical places. Lets create some content.
Creating a New Post
There are four parts to our new posts: author, board, title, body. Author will be determined by the user_session, board will be determine through the URL, what our form needs to get from the user is title and body. We must modify the Conversation controller to create a Comment object which we can use in the view. Open up app/controllers/conversation.rb
and modify create and new functions to look like this:
# GET /conversations/new
def new
@conversation = Conversation.new
@comment = @conversation.comments.build
respond_to do |format|
format.html # new.html.erb
end
end
# POST /conversations
def create
@conversation = Conversation.new(params[:conversation])
@comment = @conversation.comments.build(params[:comment])
@conversation.user_id = @comment.user_id = current_user.id
@conversation.board = @board
respond_to do |format|
if current_user && @conversation.save
format.html { redirect_to(board_path(@board), :notice => 'Your Post was created') }
else
format.html { render :action => "new" }
end
end
end
The build function was automatically created when we used has_many :comments
in the model, it creates an unsaved object using default values. In the create function we've added the board and author variable assignment, we also check to see if the user is logged in before we save.
if current_user && @conversation.save
We create @comment
in both places for simplicity. Both of these function use the same view, create if there is an error and new by default, thus we need the same variables in scope. In the create function we pass in params[:comment]
to build, this will build a Comment
object with any data passed in the comment parameter.
Now lets setup our views, change app/views/conversations/index.html.erb
, app/views/conversations/_form.html.erb
to look like this:
<%= form_for(@conversation, :url => board_conversations_path) do |f| %>
<% if @conversation.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(@conversation.errors.count, "error") %> prohibited this conversation from being saved:</h2>
<ul>
<% @conversation.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= f.label :title %><br />
<%= f.text_field :title %>
</div>
<%= fields_for(@comment) do |cf|%>
<div class="field">
<%= cf.label :body %><br />
<%= cf.text_area(:body, :size => "60x10")%>
</div>
<% end %>
<div class="actions">
<%= f.submit :as => "Create Post"%>
</div>
<% end %>
The changes to new.html.erb are fairy straight forward. We added an id to the h1 tag, and changed the link to display the name of the current table and link to the current table. We modified_form.html.erb pretty heavily, we removed alot of the input fields that will be assigned in the controller. We also modified the error printing at the top to include any errors from @comment
. fields_for works like form_for but does not create a new form tag. This allows us to create fields that are related to other objects.
Lets create our first post!
Viewing Our Post
You may be wondering where your posts are? Clearly they're in the database, however they're not showing up in our view. Lets fix that, open up app/views/boards/show.html.erb
:
<h1 id="page_title"><%= @board.title %></h1>
<p id="notice"><%= notice %></p>
<%= link_to 'Rails Boards', boards_path %> > <%= link_to @board.title, @board %> <br />
<table>
<tr>
<th>Post / Author</th>
<th>Last Reply</th>
<th>Number of Replies</th>
</tr>
<% @board.conversations.each do |conversation| %>
<tr>
<td>
<%= link_to conversation.title, board_conversation_path(:board_id => @board, :id => conversation) %><br />
<%= conversation.user.username %>
</td>
<td>
<%= conversation.comments.find(:last).created_at %><br />
by <%= conversation.comments.find(:last).user.username %>
</td>
<td>
<%= conversation.comments.count - 1%>
</td>
</tr>
<% end %>
</table>
<%= link_to 'Post New Message', new_board_conversation_url(@board) %>
We display all the conversations in that board along with some basic meta data. Most of this code is rather straight forward, the link to uses one of the routes that can be easily found using $ rake routes
. We use the find function which was also provided when we used has_many
. We subtract one for the number of replies so that we do not count the post it self. But all in all this view is as straight forward as they come.
Now lets work on the message view itself. Open up app/views/conversation/show.html.erb
and edit to look like this:
<%= link_to @conversation.board.title, @conversation.board%>
<p id="notice"><%= notice %></p>
<table>
<tr>
<th colspan="2"><%= @conversation.title %></th>
</tr>
<% @conversation.comments.each do |comment| %>
<tr>
<td style="width:25%;">
<%= link_to comment.user.username, comment.user %> <br />
Posted At <br />
<%= comment.created_at%>
</td>
<td>
<p><%=comment.body%></p>
</td>
</tr>
<% end %>
</table>
<%= link_to 'Reply', reply_board_conversation_url(:board_id=>@board, :id=>@conversation)%> |
<%= link_to @conversation.board.title, @conversation.board%>
Once again nothing to exciting happening here, the more astute of you may have realized we created a url function that we don't currently have mapped. Lets create that route, add:
get '/boards/:board_id/conversations/:id/reply' => "conversations#reply", :as => :reply_board_conversation
post '/boards/:board_id/conversations/:id/reply' => "conversations#save_reply", :as => :reply_board_conversation
This will create two different url matches, one for GET requests and one for POST, one when the page is called and one when the form is submitted. Lets got and create the reply and save_reply functions in the conversation controller, they should look fairly similar to the new and create functions, or like these:
# GET /conversations/reply
def reply
@conversation = Conversation.find(params[:id])
@comment = @conversation.comments.build
respond_to do |format|
format.html #reply.html.erb
end
end
# POST /conversations/reply
def save_reply
if !current_user
redirect_to(:login, :notice =>"Please login before posting")
return 1;
end
if Conversation.exists?(params[:id])
@conversation = Conversation.find(params[:id])
@comment = @conversation.comments.build(params[:comment])
@comment.user_id = current_user.id
else
redirect_to(boards_path, :notice =>"Please specify a valid board")
end
respond_to do |format|
if current_user && @comment.save
format.html { redirect_to(board_path(@board), :notice => 'Your reply was posted') }
else
format.html { render :action => "new" }
end
end
end
Once again here nothing is too revolutionary. reply handles the page load. It creates a @conversation
and @comment
for use in the view to generate forms. The @conversation
is loaded from the url, as is a @board
. @comment
is empty. save_reply
handles POST
requests, so form submissions. It verifies that the user is logged in and that the parent conversation exists. It then creates the comment from the conversation, assigns the user, and assigns the values passed in from the form. It saves the data and redirects the user to the board overview with a friendly reminder.
Reply function uses reply.html.erb
as it's view, since that hasn't been created yet lets do that now. Create another file as well _reply_form.html.erb
. They should look like this:
<%= form_for(@comment, :url => reply_board_conversation_url(:board_id=>@board, :id=>@conversation)) do |f| %>
<% if @comment.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(@comment.errors.count, "error") %> prohibited this reply from being saved:</h2>
<ul>
<% @comment.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= f.label :body %><br />
<%= f.text_area(:body, :size => "60x10")%>
</div>
<div class="actions">
<%= f.submit(:value => "Create Reply")%>
</div>
<% end %>
<h1 id="page_title">Reply To <%= @conversation.title %></h1>
<%= link_to @board.title, @board %>
<%= render 'reply_form' %>
<%= link_to @board.title, @board %>
There is nothing we haven't seen before in these files by themselves. Reply.html.erb
includes _reply_form.html.erb
you can tell this by the render statement, reply_form
is prepended with _
so you know it's not a controller view. _reply_form.html.erb
uses the same action as we saw to generate the url to this page, and only has a text area input field.
Feel free to try it now out now. The complete forum/message board should be working. If you have any questions feel free to leave a comment or view the working code base on my GitHub For Rails boards.