How to Build Reddit with Ruby on Rails

By: jakeday

NOTE: This tutorial is actively being written. - April 13, 2017

There are several tutorials on building a reddit clone with Rails. However, none that I'm aware of go further then the concept of post and comment creation. This tutorial will go further. 

Features will include:
  1. Posting of both links and text posts. 
  2. Post voting and sorting by vote rank. 
  3. User Post Karma (So important)
  4. Commenting on both posts and comments.
  5. Comment voting and sorting by vote rank.
  6. Nested comments
  7. User Comment Karma (Also important)

I'm using Rails 5.1.0.rc1 for this tutorial. I recommend using the same version but it's not required. Here's some helpful commands if you're going to use the same version:
$ rails -v  # Shows your current rails version 
$ gem list ^rails$  # List locally installed rails versions 
$ gem install rails -v 5.1.0.rc1  # Installs a specific version of rails
When creating a new rails app we can specify which version to use. We'll start the tutorial off by doing just that.
$ rails _5.1.0.rc1_ new reddit_on_rails

We're going to zoom through these steps. I include them for those new to rails.
$ cd reddit_on_rails   # Change into your new app.
$ git init  # Initialize git
$ git add .  # Add everything to git
$ git commit -m "Initial commit"  # Commit to git
$ rails s  # Start the rails server
Open your browser and visit localhost:3000
You should see the rails welcome page.

The Post Model

There are four initial models in our application. Posts, Users, Comments, and Votes
We're going to create the Post model first. It's good to start thinking about the relationships this model will have with the other three.

A Post will:
- Belong to a User.
- Have many Comments.
- Have many Votes.

(A Post will also belong to Channel when we include that model later on.)

Reddit has two types of posts:
1. Text Posts
2. Links

Text posts when clicked will take the user to the posts comment page with the body of the text at the top of the page.
Link posts will take the user to the shared URL. Below is an example. The first one is link based and the second is text based.

Generate a Post model that includes attributes for both types.
$ rails generate model Post title:string url:string body:text
This will generate a model and migration files for Post.

Let's migrate.
$ rails db:migrate
Add routes for posts actions and set the root to the posts index.
# config/routes.rb
Rails.application.routes.draw do
  resources :posts
  root "posts#index"
end

Generate a Posts controller with an index action. 
$ rails generate controller Posts
Add the index, show, new, and create actions. We'll also create a private post_params method.
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = Post.all
  end

  def new
    @post = Post.new
  end

  def create
    @post = Post.new(post_params)
    if @post.save
      redirect_to root_path
    else
      render 'new'
    end
  end

  private

  def post_params
    params.require(:post).permit(:title,:url,:body)
  end
end
Create the index view.
<!-- app/views/posts/index.html.erb -->
<% @posts.each do |post| %>
  <%= post.title %>
  <br>
<% end %>
Create a form partial using the new form_with syntax in Rails 5.1. 
<!-- app/views/posts/_form.html.erb -->
<%= form_with(model: @post) do |form| %>
  <%= form.label :title %>
  <%= form.text_field :title %>
  <br>
  <%= form.label :url %>
  <%= form.text_field :url %>
  <br>
  <%= form.label :body %>
  <%= form.text_area :body %>
  <br>
  <%= form.submit %>
<% end %>

Followed by the post new view that will render the form.

<!-- app/views/posts/new.html.erb -->
<%= render "form" %>
Restart your rails server if you exited earlier and visit: http://localhost:3000/posts/new
SCREENSHOT
Create a posts using the form. Make sure to provide a url.
Create a second post. This time leave the url blank and just write a title and body attribute.
SCREENSHOT
If a user is simply sharing a text post and not any url then there's no need to fill the url field.

The Post View
When a user clicks on a post we want one of two things to happen. We want to link the user to the shared URL if the post has an associated URL. If not we want to link the user to the posts show page that will show the post body and eventually the comments that users make on the post. Given this we'll want to check if a URL is present. Make this change.

<!-- app/views/posts/index.html.erb -->
<% @posts.each do |post| %>
  <% if post.url? %>
    <%= link_to(post.title, post.url) %>
  <% else %>
    <%= link_to(post.title, post) %>
  <% end %>
  <br>
<% end %>

Go to your post index page and click on the post that has a URL. Make sure it's working properly. 
Create a post show action. Practice DRY principles by creating a private find_post method that we'll use for other actions later in the tutorial.
The dots indicate omitted code.
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  before_action :find_post, only: [:show, :edit, :update, :destroy]
.
.
.
  def show
  end
.
.
.
  private 
 
  def find_post
    @post = Post.find(params[:id])
  end
.
.
.
Create a post show view that displays the post title and post body.
<!-- app/views/posts/show.html.erb -->
<%= @post.title %>
<br>
<%= @post.body %>
Navigate back to the post index view and select the post with no URL attribute. It should link to the show post page.
SCREENSHOT

The User Model
We'll use the Devise gem for our user model. If you're new to Rails and haven't used Devise, it deals with the User model. It'll create user authentication, user log in, log out, changing of user settings, and pretty much all the basic features required for a user to interact with your application. Let's add it to our Gemfile and bundle install.
# Gemfile
gem 'devise'
Run the devise generator.
$ rails generate devise:install
Add this line to the bottom of the file.
# config/environments/development.rb
config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
Add flash messages to the application view. Inside the body tag and before the yield tag. 
# app/views/layouts/application.html.erb
.
.
. 
  <body>
    <p class="notice"><%= notice %></p>
    <p class="alert"><%= alert %></p>
    <%= yield %>
.
.
.
Now we create the User model using a devise generator:
$ rails generate devise User
$ rails db:migrate
Devise can automatically create the user sign in, sign up, and edit user page views via the terminal.
$ rails generate devise:views
Go to: http://localhost:3000/users/sign_up 
Create your first user to make sure everything is working properly. 
SCREENSHOT

Add a before action to your posts controller that will require a user to be signed in in order to use any action except the the ones included in the array. (Index and Show) 
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  before_action :authenticate_user!, except: [:index, :show]
.
.
.
Add a user_id column to posts.
$ rails generate migration AddUserIdToPosts user_id:integer
Open up the newly created migration to add an index to the user_id column. This will speed up query all the posts for any specific user. 
class AddUserIdToPosts < ActiveRecord::Migration[5.1]
  def change
    add_column :posts, :user_id, :integer
    add_index :posts, :user_id   #adds an index
  end
end
Migrate and restart the rails server:
$ rails db:migrate
$ rails server
Add the associations to the models.
# app/models/user.rb
class User < ApplicationRecord
  has_many :posts

# app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user
In our Post controller we want to assign a user to every post created. Rewrite the create method:
# app/controllers/posts_controller.rb  
def create
  @post = current_user.posts.build(post_params)    
  if @post.save
    redirect_to root_path
  else
    render 'new'
  end
end
Now that we have a user for each post we'll want to delete the existing posts. Open the rails console and delete them.
$ rails console
>> Post.delete_all
>> exit
Visit http://localhost:3000/users/sign_up and create a new user. 

Now weell want to display the user email on each post. (We'll change this to a users username later in the tutorial.)
<!-- app/views/posts/index.html.erb -->
<% @posts.each do |post| %>
  <% if post.url? %>
    <%= link_to(post.title, post.url) %>
  <% else %>
    <%= link_to(post.title, post) %>
  <% end %>
  <div class="link-info">
    submitted <%= time_ago_in_words(post.created_at) %> ago by <%= post.user.email %>
  </div>
  <br>
<% end %>
Visit localhost:3000/posts/new and create a new post. If you're not logged in it will redirect you to log in. Once you create a new post you'll when the post was created and who created it.
SCREENSHOT

Moving on we want to create a link to the show page of a post. The show page is where the body of the post and the comments attached to the post are displayed. We'll create a delete option to delete to post if the currently signed in user is the one that created the post.
<!-- app/views/posts/index.html.erb -->
<% @posts.each do |post| %>
  <% if post.url? %>
    <%= link_to(post.title, post.url) %>
  <% else %>
    <%= link_to(post.title, post) %>
  <% end %>
  <div class="post-info">
    submitted <%= time_ago_in_words(post.created_at) %> ago by <%= post.user.email %>
  </div>
  <div class="bottom-links">
    <%= link_to "comments", post %>
    <% if user_signed_in? && post.user_id == current_user.id %>
      <%= link_to "delete", post_path(post), method: :delete, data: { confirm: "Really delete" } %>
    <% end %>
  <br>
<% end %>
Add the destroy method to your posts controller.
# app/controllers/posts_controller.rb
def destroy
  @post.destroy
  redirect_back(fallback_location: root_path)
end
Test that you can delete a post.

The Comment Model
As on reddit, a comment can belong to either a post or a comment. When you reply to a users comment for instance, your new comment needs to belong to their comment. That being the case we'll want to create a polymorphic association between comments. Meaning, comments can belong to either a Post or a Comment. 

Create the comment model.
$ rails g model comment body:text commentable_id:integer commentable_type:string user_id:integer
$ rails db:migrate
Create the associations in the comment model.
#app/models/comment.rb
class Comment < ApplicationRecord
  belongs_to :user
  belongs_to :commentable, polymorphic: true
  has_many :comments, as: :commentable
end
A comment will belong to a user. We then state that comments belongs to a polymorphic association through commentable. And finally, comments have many comments through commentable. Wew. If that's confusing hopefully the Post model will be easier to grep.
# app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user
  has_many :comments, as: :commentable
end
Recall that we created a commentable_id when we first created the comment model. That's the key here. A normal association like (has_many :comments) would create a comment that has a post_id. But, since  we want comments to either have a post_id or a comment_id we essentially merge them and call them a commentable_id. That is preciscely why we also created a commentable_type attribute. There we will specify whether it belongs to a Post or a Comment.

Create the nested routes for comments.
# config/routes.rb
Rails.application.routes.draw do
  devise_for :users
  
  resources :posts do
    resources :comments
  end

  resources :comments do
    resources  :comments
  end
  
  root "posts#index"
end
Create a comments controller.
$ rails generate controller comments
Whether a comment belongs to a post or to a comment will be determined in the form. Add a form to the post show view.
<!-- app/views/posts/show.html.erb -->
<%= @post.title %>
<br>
<%= @post.body %>

<h4>Comments</h4>
<% if user_signed_in? %>
  <%= form_for [@post, Comment.new] do |form| %>
    <%= form.text_area :body %>
    <%= form.submit "save" %>
    <% end %>
<% else %>
  You need to <%= link_to "log in", new_user_session_path %> or <%= link_to "sign up", new_user_registration_path %> to comment.
<% end %>
We'll need to write a create action for our form. We'll create a private method to provide the comment params.
# app/controllers/comments_controller.rb
class CommentsController < ApplicationController
  before_action :authenticate_user!
  before_action :find_commentable

  def create
    @comment = @commentable.comments.new comment_params
    if @comment.save
      redirect_back fallback_location: root_path, notice: "Comment posted successfully"
    else
      redirect_back fallback_location: root_path, notice: "Something went wrong"
    end
  end

  private

  def comment_params
    params[:comment][:user_id] = current_user.id
    params.require(:comment).permit(:body, :user_id)
  end

  def find_commentable
    @commentable = Comment.find_by_id(params[:comment_id]) if params[:comment_id]
    @commentable = Post.find_by_id(params[:post_id]) if params[:post_id]
  end
end
Every comment is either nested under a Post or a Comment. The private method find_commentable that's run before a create action. If params contains a comment_id, it's a comment on a comment. If it contains a post_id it's a comment on a post. 

Displaying comments will require us to create an additional view. We'll create a partial as the view and then render it at the bottom of the post show page.
<!-- app/views/comments/_comment.html.erb -->
<li>
    <%= time_ago_in_words(comment.created_at) %> ago by <%= comment.user.email %> ago
    <br>
    <%= comment.body %>
</li>

<!-- app/views/posts/show.html.erb -->
.
.
.
<ul> <%= render partial: 'comments/comment', collection: @post.comments %> </ul>
Commenting on Comments.
On reddit the reply text field isn't show until the user clicks on reply. For us this means using JQuery to hide all reply text boxes until the corresponding reply button is clicked.

Rails dropped JQuery as a  default library in 5.1.0. So we'll need to add it to our Gemfile.
# Gemfile
.
.
.
gem 'jquery-rails'
Make sure to bundle install after.

We'll need to require jquery and jquery-ujs in the application.js file. We'll take this opportunity to also right the show/hide function. Which will toggle the display of the reply-form.
// app/assets/javascripts/application.js
.
.
.
//= require jquery
//= require rails-ujs
//= require turbolinks
//= require_tree .

$(document).on('turbolinks:load', function() {
  $('.reply-form').hide();
  $('.reply-button').on('click', function(e){
    e.preventDefault();
    $(this).next('.reply-form').toggle(); // Show form on button click
  });
});
Returning to our comment partial we need to add the button (which is just the word "reply") and a form. 
<!-- app/views/comments/_comment.html.erb -->
<li>
    <%= time_ago_in_words(comment.created_at) %> ago by <%= comment.user.email %> ago
    <br>
    <%= comment.body %>
    <div class="reply-button">reply</div>
    <div class="reply-form">
      <%= form_for [comment, Comment.new] do |f| %>
        <%= f.text_area :body %>
        <%= f.submit "save" %>
      <% end %>
    </div>
</li>
SCREENSHOT

At this point you can save a comment on a comment. You'll notice though that the reply isn't displayed after saving it. This is because we haven't rendered the view for that comment. The way to do so is to use the same partial we're currently using. Add this to the bottom of your comment partial. Make sure to add it inside of the existing </li> tag though.
<!-- app/views/comments/_comment.html.erb -->
.
.
.
  <ul><%= render partial: 'comments/comment', collection: comment.comments %></ul>
</li>
Refresh the page. Create a reply and see that it's nested below the comment you're replying to.
SCREENSHOT

Voting on Posts and Comments
The voting mechanism is a fundamental part of Reddit. Post and comments are sorted by ranking. There are several variables that determine the rank of a post or comment; such as the rate at which something is being voted on, how old a post is, and the engagement/commenting happening on a post. One of the most important variables is the amount of up votes vs down votes a post or comment has. In this part of the tutorial we'll implement the act_as_votable gem to manage the vote scores of both Posts and Comments. 
#Gemfile
gem 'acts_as_votable', '~> 0.10.0' 

$ bundle install

TO BE CONTINUED

Course Complete! Go to courses.
    Build a Reddit Clone with Rails