Initial attempt at comments

This commit is contained in:
Andrew Tomaka 2015-07-16 11:57:27 -04:00
parent 86e1cd891c
commit bbdc0001b4
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

@ -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