Merge branch 'atomaka/feature/comments' into 'master'

Add comments to creddit

Initial pass at commenting with some things left to consider.  Primarily, double nesting of resources is advised as poor practice.  Investigate alternatives.  Testing on comments isn't entirely complete (delete missing and some other things).  Comment replies need to be moved to inline. 

See merge request !15
This commit is contained in:
Andrew Tomaka 2015-08-05 22:04:04 -04:00
commit 4636bc837d
36 changed files with 687 additions and 62 deletions

View file

@ -21,6 +21,7 @@ gem 'bootstrap-sass'
gem 'simple_form' gem 'simple_form'
gem 'friendly_id', '~> 5.1.0' gem 'friendly_id', '~> 5.1.0'
gem 'ancestry'
gem 'bcrypt' gem 'bcrypt'

View file

@ -41,6 +41,8 @@ GEM
ice_nine (~> 0.11.0) ice_nine (~> 0.11.0)
memoizable (~> 0.4.0) memoizable (~> 0.4.0)
addressable (2.3.8) addressable (2.3.8)
ancestry (2.1.0)
activerecord (>= 3.0.0)
arel (6.0.0) arel (6.0.0)
arrayfields (4.9.2) arrayfields (4.9.2)
ast (2.0.0) ast (2.0.0)
@ -365,6 +367,7 @@ PLATFORMS
ruby ruby
DEPENDENCIES DEPENDENCIES
ancestry
bcrypt bcrypt
better_errors better_errors
binding_of_caller binding_of_caller

View file

@ -0,0 +1,3 @@
# Place all the behaviors and hooks related to the matching controller here.
# All this logic will automatically be available in application.js.
# You can use CoffeeScript in this file: http://coffeescript.org/

View file

@ -0,0 +1,3 @@
// Place all the styles related to the Comments controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/

View file

@ -42,6 +42,10 @@ $navbar-default-link-hover-color: #369;
} }
} }
.links {
font-size: .8em;
}
.navbar { .navbar {
.logo { .logo {
margin-top: 3px; margin-top: 3px;
@ -49,32 +53,7 @@ $navbar-default-link-hover-color: #369;
} }
} }
.posts { .contents {
.post {
p {
margin-bottom: 1px;
}
.title {
font-size: 1.2em;
}
.details {
font-size: .8em;
}
.links {
font-size: .8em;
}
.content {
border: 1px solid #369;
background-color: #fafafa;
border-radius: 7px; // need to work all browsers
padding: 5px;
}
}
ul { ul {
list-style: none; list-style: none;
list-style-type: none; list-style-type: none;
@ -90,6 +69,38 @@ $navbar-default-link-hover-color: #369;
li:first-child { li:first-child {
padding-left: 0px; padding-left: 0px;
} }
p {
margin-bottom: 1px;
}
.title {
font-size: 1.2em;
}
.details {
font-size: .8em;
}
}
.posts {
.post {
margin: 0 0 15px 0;
.content {
margin: 4px 0 4px 0;
border: 1px solid #369;
background-color: #fafafa;
border-radius: 7px; // need to work all browsers
padding: 5px;
}
}
}
.comments {
.comment {
margin: 15px 0 15px 0;
}
} }
.navbar-custom { .navbar-custom {
@ -99,3 +110,15 @@ $navbar-default-link-hover-color: #369;
.main { .main {
margin: 0 5px 0 5px; margin: 0 5px 0 5px;
} }
.title {
font-size: 1.3em;
}
.nested_comments {
margin-left: 50px;
}
.in-page {
padding: 5px;
}

View file

@ -0,0 +1,62 @@
class CommentsController < ApplicationController
before_filter :set_comment, only: [:show, :edit, :update, :destroy]
before_filter :set_post
before_filter :set_subcreddit
def show
@comments = @comment.subtree.arrange(order: :created_at)
end
def new
@comment = Comment.new(params[:parent_id])
end
def create
@comment = @post.comments.build comment_params
@comment.user = current_user
if @comment.save
flash[:notice] = 'Comment saved'
else
flash[:alert] = 'Comment could not be saved'
end
redirect_to subcreddit_post_path(@subcreddit, @post)
end
def edit
end
def update
if @comment.update comment_params
redirect_to subcreddit_post_path(@subcreddit, @post),
notice: 'Comment updated'
else
render :edit
end
end
def destroy
@comment.destroy
redirect_to subcreddit_post_path(@subcreddit, @post),
notice: 'Comment deleted'
end
private
def set_subcreddit
@subcreddit = Subcreddit.friendly.find(params[:subcreddit_id])
end
def set_post
@post = Post.find(params[:post_id])
end
def set_comment
@comment = Comment.find(params[:id])
end
def comment_params
params.require(:comment).permit(:parent_id, :content)
end
end

View file

@ -3,6 +3,7 @@ class PostsController < ApplicationController
before_filter :set_subcreddit before_filter :set_subcreddit
def show def show
@comments = @post.comments.arrange(order: :created_at)
end end
def new def new

View file

@ -12,5 +12,5 @@ module ApplicationHelper
'notice' => 'alert-info', 'notice' => 'alert-info',
'success' => 'alert-success' 'success' => 'alert-success'
} }
end end
end end

View file

@ -0,0 +1,10 @@
module CommentsHelper
def nested_comments(comments)
comments.map do |comment, sub_comments|
render(comment, post: comment.post, subcreddit: comment.post.subcreddit) +
content_tag(:div,
nested_comments(sub_comments),
class: 'nested_comments')
end.join.html_safe
end
end

22
app/models/comment.rb Normal file
View file

@ -0,0 +1,22 @@
class Comment < ActiveRecord::Base
has_ancestry
belongs_to :user
belongs_to :post, counter_cache: true
delegate :username, to: :user, prefix: true
validates :content, presence: true
def content
destroyed? ? '[deleted]' : read_attribute(:content)
end
def destroy
update_attribute(:deleted_at, Time.now)
end
def destroyed?
self.deleted_at != nil
end
end

View file

@ -2,6 +2,8 @@ class Post < ActiveRecord::Base
belongs_to :user belongs_to :user
belongs_to :subcreddit belongs_to :subcreddit
has_many :comments
delegate :username, to: :user, prefix: true delegate :username, to: :user, prefix: true
validates :title, validates :title,
@ -12,6 +14,16 @@ class Post < ActiveRecord::Base
length: { maximum: 15000 } length: { maximum: 15000 }
def to_param def to_param
# This "just works" because of the way Rails IDs work. .to_i must be run on
# any incoming ID. "1-title-parameterized" will automatically be converted
# to 1 and "2-title-paramerterized-with-number-2" will automatically be
# converted to 2. This gives us desired functionality without adding code
# to properly handle retrieving based on slug. Hopefully, this does not
# cause issues later on.
"#{self.id}-#{self.title.parameterize}" "#{self.id}-#{self.title.parameterize}"
end end
def comments?
self.comments_count != 0
end
end end

View file

@ -0,0 +1,13 @@
.comment
p.details= "#{comment.user_username} X points #{distance_of_time_in_words comment.created_at, Time.now} ago"
p.content= comment.content
ul.links.list-inline
li= link_to 'permalink', subcreddit_post_comment_path(subcreddit, post, comment)
li= link_to 'save', ''
- if comment.parent
li= link_to 'parent', subcreddit_post_comment_path(subcreddit, post, comment.parent)
li= link_to 'edit', edit_subcreddit_post_comment_path(subcreddit, post, comment)
li= link_to 'delete', subcreddit_post_comment_path(subcreddit, post, comment), method: :delete
li= link_to 'spam', ''
li= link_to 'remove', ''
li= link_to 'give gold', ''

View file

@ -0,0 +1,7 @@
= simple_form_for [subcreddit, post, comment] do |f|
- if local_assigns.has_key?(:parent) && parent
= f.hidden_field :parent_id, value: parent.id
.form-inputs
= f.input :content, label: false
.form-actions
= f.button :submit

View file

@ -0,0 +1,2 @@
h1 Edit comment
== render 'form', subcreddit: @subcreddit, post: @post, comment: @comment

View file

View file

@ -0,0 +1,8 @@
== render 'posts/post', post: @post
.alert.alert-info.in-page
p you are viewing a single comment's thread.
p #{link_to 'view the rest of the comments', subcreddit_post_path(@subcreddit, @post)} →
= "Commenting as: #{current_user.username}"
== render 'comments/form', subcreddit: @subcreddit, post: @post, comment: @post.comments.build, parent: @comment
.comments.contents
== nested_comments(@comments)

View file

@ -7,7 +7,7 @@ html
= csrf_meta_tags = csrf_meta_tags
body body
== render 'navbar' == render 'navbar'
.container .container-fluid
== render 'flash_messages' == render 'flash_messages'
.main.container-fluid .main.container-fluid
.col-md-3.pull-right .col-md-3.pull-right

View file

@ -0,0 +1,14 @@
.posts.contents
.post.show
p.title= link_to post.title, [post.subcreddit, post]
p.details= "submitted #{distance_of_time_in_words post.created_at, Time.now} ago by #{post.user_username}"
p.content= post.content
ul.links.list-inline
li= link_to "#{post.comments_count} comments", subcreddit_post_path(post.subcreddit, post)
li= link_to 'share', ''
li= link_to 'edit', edit_subcreddit_post_path(post.subcreddit, post)
li= link_to 'save', ''
li= link_to 'hide', ''
li= link_to 'remove', ''
li= link_to 'approve', ''
li= link_to 'nsfw', ''

View file

@ -1,18 +1,9 @@
.posts == render 'post', post: @post
.post.show - if @post.comments?
p.title= link_to @post.title, [@subcreddit, @post] .title= "all #{@post.comments_count} comments"
p.details= "submitted #{distance_of_time_in_words @post.created_at, Time.now} ago by #{@post.user_username}" - else
ul.links.list-inline .title= "no comments (yet)"
li= link_to 'XXX comments', '' = "Commenting as: #{current_user.username}"
li= link_to 'source', '' == render 'comments/form', subcreddit: @subcreddit, post: @post, comment: @post.comments.build, parent: nil
li= link_to 'share', '' .comments.contents
li= link_to 'save', '' == nested_comments(@comments)
li= link_to 'hide', ''
li= link_to 'give gold', ''
li= link_to 'spam', ''
li= link_to 'remove', ''
li= link_to 'approve', ''
li= link_to 'report', ''
li= link_to 'nsfw', ''
li= link_to 'hide all child comments', ''
p.content= @post.content

View file

@ -1,13 +1,13 @@
- if @subcreddit.closed? - if @subcreddit.closed?
= "Board has been closed" = "Board has been closed"
- else - else
.posts .posts.contents
ul ul
- @subcreddit.posts.order('created_at DESC').each_with_index do |post, rank| - @subcreddit.posts.order('created_at DESC').each_with_index do |post, rank|
li li
.post .post
p.title= link_to post.title, [@subcreddit, post] p.title= link_to post.title, subcreddit_post_path(@subcreddit, post)
p.details= "submitted #{distance_of_time_in_words post.created_at, Time.now} ago by #{post.user_username}" p.details= "submitted #{distance_of_time_in_words post.created_at, Time.now} ago by #{post.user_username}"
ul.links.list-inline ul.links.list-inline
li= link_to 'XXX comments', '' li= link_to "#{post.comments_count} comments", subcreddit_post_path(@subcreddit, post)
li= link_to 'share', '' li= link_to 'share', ''

View file

@ -38,4 +38,11 @@ Rails.application.configure do
# Raises error for missing translations # Raises error for missing translations
# config.action_view.raise_on_missing_translations = true # config.action_view.raise_on_missing_translations = true
# Bullet
config.after_initialize do
Bullet.enable = true
Bullet.bullet_logger = true
Bullet.add_footer = true
end
end end

View file

@ -4,7 +4,9 @@ Rails.application.routes.draw do
get 'signout', to: 'user_sessions#destroy', as: :signout get 'signout', to: 'user_sessions#destroy', as: :signout
resources :subcreddits, path: 'c', except: [:destroy] do resources :subcreddits, path: 'c', except: [:destroy] do
resources :posts, except: [:index] resources :posts, path: '', constraints: { id: /\d+\-.+/ }, except: [:index] do
resources :comments, path: '', constraints: { id: /\d+/ }, except: [:index]
end
end end
resources :user_sessions, only: [:new, :create, :destroy] resources :user_sessions, only: [:new, :create, :destroy]

View file

@ -0,0 +1,14 @@
class CreateComments < ActiveRecord::Migration
def change
create_table :comments do |t|
t.references :user, index: true, foreign_key: true
t.references :post, index: true, foreign_key: true
t.string :ancestry
t.text :content
t.timestamps null: false
end
add_index :comments, :ancestry
end
end

View file

@ -0,0 +1,10 @@
class AddCommentsCountToPost < ActiveRecord::Migration
def change
add_column :posts, :comments_count, :integer, default: 0
Post.reset_column_information
Post.all.each do |p|
p.update_attribute :comments_count, p.comments.length
end
end
end

View file

@ -0,0 +1,5 @@
class AddDeletedAtToComments < ActiveRecord::Migration
def change
add_column :comments, :deleted_at, :datetime
end
end

View file

@ -11,7 +11,21 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20150716050055) do ActiveRecord::Schema.define(version: 20150804145405) do
create_table "comments", force: :cascade do |t|
t.integer "user_id"
t.integer "post_id"
t.string "ancestry"
t.text "content"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "deleted_at"
end
add_index "comments", ["ancestry"], name: "index_comments_on_ancestry"
add_index "comments", ["post_id"], name: "index_comments_on_post_id"
add_index "comments", ["user_id"], name: "index_comments_on_user_id"
create_table "friendly_id_slugs", force: :cascade do |t| create_table "friendly_id_slugs", force: :cascade do |t|
t.string "slug", null: false t.string "slug", null: false
@ -34,6 +48,7 @@ ActiveRecord::Schema.define(version: 20150716050055) do
t.text "content" t.text "content"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "comments_count", default: 0
end end
add_index "posts", ["subcreddit_id"], name: "index_posts_on_subcreddit_id" add_index "posts", ["subcreddit_id"], name: "index_posts_on_subcreddit_id"

View file

@ -0,0 +1,201 @@
require 'rails_helper'
describe CommentsController, type: :controller do
let!(:user) { build(:user) }
let!(:subcreddit) { create(:subcreddit) }
let!(:tpost) { create(:post, subcreddit: subcreddit) }
let(:data) { { content: 'Here is some updated content for a comment' } }
before(:each) do
allow_any_instance_of(ApplicationController)
.to receive(:current_user).and_return(user)
end
describe '#show' do
let!(:comment) { create(:comment) }
before(:each) do
get :show,
subcreddit_id: comment.post.subcreddit,
post_id: comment.post,
id: comment
end
it 'should render :show' do
expect(response).to render_template(:show)
end
it 'should assign correct Comment to @comment' do
expect(assigns(:comment)).to eq(comment)
end
end
describe '#new' do
let(:comment) { build(:comment) }
before(:each) do
get :new,
subcreddit_id: comment.post.subcreddit,
post_id: comment.post
end
it 'should render :new' do
expect(response).to render_template(:new)
end
it 'should assign new Comment to @comment' do
expect(assigns(:comment)).to be_a_new(Comment)
end
end
describe '#create' do
context 'with valid data' do
it 'should create a comment' do
expect do
post :create,
subcreddit_id: subcreddit,
post_id: tpost,
comment: data
end.to change(Comment, :count).by(1)
end
it 'should redirect to the parent post' do
expect(post :create,
subcreddit_id: subcreddit,
post_id: tpost,
comment: data
).to redirect_to(subcreddit_post_path(assigns(:post).subcreddit,
assigns(:post)))
end
it 'should send a notice flash message' do
expect(post :create,
subcreddit_id: subcreddit,
post_id: tpost,
comment: data)
expect(flash[:notice]).to be_present
end
end
context 'with invalid data' do
before(:each) { data['content'] = '' }
it 'should not create a new comment' do
expect do
post :create,
subcreddit_id: subcreddit,
post_id: tpost,
comment: data
end.to change(Comment, :count).by(0)
end
it 'should render :new' do
expect(post :create,
subcreddit_id: subcreddit,
post_id: tpost,
comment: data
).to redirect_to(subcreddit_post_path(subcreddit, tpost))
end
end
end
describe '#edit' do
let!(:comment) { create(:comment) }
before(:each) do
get :edit,
id: comment,
post_id: comment.post,
subcreddit_id: comment.post.subcreddit
end
context 'with valid comment' do
it 'should render :edit' do
expect(response).to render_template(:edit)
end
it 'should assign correct Comment to @comment' do
expect(assigns(:comment)).to eq(comment)
end
end
end
context '#update' do
let!(:comment) { create(:comment) }
let(:data) { { content: 'Some edited comment content goes here' } }
context 'with valid data' do
before(:each) do
put :update,
id: comment,
post_id: comment.post,
subcreddit_id: comment.post.subcreddit,
comment: data
end
it 'should assign correct Comment to @comment' do
expect(assigns(:comment)).to eq(comment)
end
it 'should update the comment' do
comment.reload
expect(comment.content).to eq(data[:content])
end
it 'should redirect to the post' do
expect(response)
.to redirect_to(subcreddit_post_path(assigns(:post).subcreddit,
assigns(:post)))
end
it 'should display a notice flash message' do
expect(flash[:notice]).to be_present
end
end
context 'with invalid data' do
before(:each) { data[:content] = '' }
it 'should render :edit' do
put :update,
id: comment,
post_id: comment.post,
subcreddit_id: comment.post.subcreddit,
comment: data
expect(response).to render_template(:edit)
end
end
end
context '#destroy' do
let!(:comment) { create(:comment) }
it 'should delete the post' do
delete :destroy,
id: comment,
post_id: comment.post,
subcreddit_id: comment.post.subcreddit
comment.reload
expect(comment.destroyed?).to be(true)
end
it 'should redirect to the post' do
delete :destroy,
id: comment,
post_id: comment.post,
subcreddit_id: comment.post.subcreddit
expect(response).to redirect_to(subcreddit_post_path(assigns(:subcreddit),
assigns(:post)))
end
it 'should send a notice flash message' do
delete :destroy,
id: comment,
post_id: comment.post,
subcreddit_id: comment.post.subcreddit
expect(flash[:notice]).to be_present
end
end
end

View file

@ -0,0 +1,7 @@
FactoryGirl.define do
factory :comment do
user
post
content { Faker::Lorem.paragraph }
end
end

View file

@ -1,6 +1,6 @@
FactoryGirl.define do FactoryGirl.define do
factory :user do factory :user do
username { Faker::Internet.user_name } sequence(:username) { |n| Faker::Internet.user_name + "#{n}" }
password { Faker::Internet.password(8, 50) } password { Faker::Internet.password(8, 50) }
email { Faker::Internet.email } email { Faker::Internet.email }
end end

View file

@ -0,0 +1,30 @@
require 'rails_helper'
describe 'Edit Comment', type: :feature do
let!(:user) { create(:user) }
let!(:post) { create(:post) }
let!(:comment) { create(:comment, post: post, user: user) }
context 'when signed in' do
let(:content) { 'Some different data' }
before(:each) { signin(user: user) }
context 'with valid data' do
before(:each) do
visit edit_subcreddit_post_comment_path(post.subcreddit, post, comment)
fill_in :comment_content, with: content
click_button 'Update Comment'
end
it 'should notify that the comment was edited' do
expect(page).to have_content('updated')
end
it 'should update the comment' do
expect(page).to have_content(content)
end
end
end
end

View file

@ -0,0 +1,57 @@
require 'rails_helper'
describe 'New Comment', type: :feature do
let!(:post) { create(:post) }
let(:comment) { build(:comment) }
context 'when signed in' do
let(:user) { create(:user) }
before(:each) { signin(user: user) }
context 'with valid data' do
before(:each) do
visit subcreddit_post_path(post.subcreddit, post)
fill_in :comment_content, with: comment.content
click_button 'Create Comment'
end
it 'should notify that a new content was created' do
expect(page).to have_content('saved')
end
it 'should display the new comment' do
expect(page).to have_content(comment.content)
end
context 'when nesting comment' do
let!(:comment) { create(:comment, post: post) }
it 'should display a nested comment' do
visit subcreddit_post_comment_path(post.subcreddit, post, comment)
fill_in :comment_content, with: comment.content
click_button 'Create Comment'
expect(page).to have_css('div.nested_comments')
end
end
end
context 'with invalid data' do
before(:each) do
visit subcreddit_post_path(post.subcreddit, post)
fill_in :comment_content, with: ''
click_button 'Create Comment'
end
it 'should display errors' do
expect(page).to have_content('could not')
end
end
end
end

View file

@ -24,8 +24,9 @@ describe 'Edit Post', type: :feature do
expect(page).to have_content('updated') expect(page).to have_content('updated')
end end
it 'should show the post' do it 'should show the updated post' do
expect(page).to have_content(new_post.title) expect(page).to have_content(new_post.title)
expect(page).to have_content(new_post.content)
end end
end end
end end

View file

@ -9,7 +9,7 @@ describe 'List Posts', type: :feature do
posts.each do |post| posts.each do |post|
expect(page) expect(page)
.to have_link(post.title, subcreddit_post_path(post, post.subcreddit)) .to have_link(post.title, subcreddit_post_path(post.subcreddit, post))
end end
end end
end end

View file

@ -0,0 +1,7 @@
require 'rails_helper'
describe CommentsHelper do
describe '#nested_comments' do
it 'renders the comment partial'
end
end

View file

@ -0,0 +1,63 @@
require 'rails_helper'
describe Comment, type: :model do
let(:comment) { build(:comment) }
it { should belong_to(:user) }
it { should belong_to(:post).counter_cache(true) }
it { should delegate_method(:username).to(:user).with_prefix }
context 'with valid data' do
it 'should be valid' do
expect(comment).to be_valid
end
end
context 'with invalid data' do
it 'should not be valid with blank content' do
comment.content = ''
expect(comment).to be_invalid
end
end
context 'when comment is deleted' do
before(:each) { comment.deleted_at = Time.now }
context '#destroyed?' do
it 'should respond with true' do
expect(comment.destroyed?).to be(true)
end
end
context '#content' do
it 'should return [deleted]' do
expect(comment.content).to eq('[deleted]')
end
end
end
context 'when comment is not deleted' do
context '#destroyed?' do
it 'should respond with false' do
expect(comment.destroyed?).to be(false)
end
end
context '#content' do
it 'should return comment content' do
expect(comment.content).to eq(comment.content)
end
end
end
context '#destroy' do
it 'should set the deleted_at time appropriately' do
Timecop.freeze do
comment.destroy
expect(comment.deleted_at).to eq(Time.now)
end
end
end
end

View file

@ -5,9 +5,20 @@ describe Post, type: :model do
it { should belong_to(:user) } it { should belong_to(:user) }
it { should belong_to(:subcreddit) } it { should belong_to(:subcreddit) }
it { should have_many(:comments) }
it { should delegate_method(:username).to(:user).with_prefix } it { should delegate_method(:username).to(:user).with_prefix }
context 'when adding a comment' do
let(:post) { create(:post) }
it 'should update the cache_counter for comments' do
expect do
create(:comment, post: post)
end.to change { post.comments_count }.by(1)
end
end
context 'with valid data' do context 'with valid data' do
it 'should be valid' do it 'should be valid' do
expect(post).to be_valid expect(post).to be_valid
@ -53,4 +64,24 @@ describe Post, type: :model do
expect(post.to_param).to eq("#{post.id}-#{post.title.parameterize}") expect(post.to_param).to eq("#{post.id}-#{post.title.parameterize}")
end end
end end
context '#comments?' do
let(:post) { create(:post) }
context 'with comments' do
before(:each) do
create(:comment, post: post)
end
it 'should respond with true' do
expect(post.comments?).to be(true)
end
end
context 'without comments' do
it 'should respond with false' do
expect(post.comments?).to be(false)
end
end
end
end end