posts: allow toggling between upvotes and downvotes.

Like 9efb374ae, allow users to toggle between upvoting and downvoting a
post without raising an error or having to manually remove the vote
first. If you upvote a post, then downvote it, the upvote is
automatically removed and replaced by the downvote.

Other changes:

* Tagging a post with `upvote:self` or `downvote:self` is now silently
  ignored when the user doesn't have permission to vote, instead of
  raising an error.
* Undoing a vote that doesn't exist now does nothing instead of
  returning an error. This can happen if you open the same post in two
  tabs, undo the vote in tab 1, then try to undo the vote again in tab 2.

Changes to the /post_votes API:

* `POST /post_votes` and `DELETE /post_votes` now return a post vote
  instead of a post.
* The `score` param in `POST /post_votes` is now 1 or -1, not `up` or
  `down`.
This commit is contained in:
evazion
2021-01-29 01:58:12 -06:00
parent ffdd5e6128
commit d0c9f6e0b8
11 changed files with 221 additions and 155 deletions

View File

@@ -3,7 +3,7 @@
<% if upvoted? %> <% if upvoted? %>
<%= link_to upvote_icon, post_post_votes_path(post_id: post.id), class: "post-upvote-link post-unvote-link active-link", method: :delete, remote: true %> <%= link_to upvote_icon, post_post_votes_path(post_id: post.id), class: "post-upvote-link post-unvote-link active-link", method: :delete, remote: true %>
<% else %> <% else %>
<%= link_to upvote_icon, post_post_votes_path(post_id: post.id, score: "up"), class: "post-upvote-link inactive-link", method: :post, remote: true %> <%= link_to upvote_icon, post_post_votes_path(post_id: post.id, score: 1), class: "post-upvote-link inactive-link", method: :post, remote: true %>
<% end %> <% end %>
<% end %> <% end %>
@@ -13,7 +13,7 @@
<% if downvoted? %> <% if downvoted? %>
<%= link_to downvote_icon, post_post_votes_path(post_id: post.id), class: "post-downvote-link post-unvote-link active-link", method: :delete, remote: true %> <%= link_to downvote_icon, post_post_votes_path(post_id: post.id), class: "post-downvote-link post-unvote-link active-link", method: :delete, remote: true %>
<% else %> <% else %>
<%= link_to downvote_icon, post_post_votes_path(post_id: post.id, score: "down"), class: "post-downvote-link inactive-link", method: :post, remote: true %> <%= link_to downvote_icon, post_post_votes_path(post_id: post.id, score: -1), class: "post-downvote-link inactive-link", method: :post, remote: true %>
<% end %> <% end %>
<% end %> <% end %>
</span> </span>

View File

@@ -1,7 +1,6 @@
class PostVotesController < ApplicationController class PostVotesController < ApplicationController
skip_before_action :api_check skip_before_action :api_check
respond_to :js, :json, :xml, :html respond_to :js, :json, :xml, :html
rescue_with PostVote::Error, status: 422
def index def index
@post_votes = authorize PostVote.visible(CurrentUser.user).paginated_search(params, count_pages: true) @post_votes = authorize PostVote.visible(CurrentUser.user).paginated_search(params, count_pages: true)
@@ -11,16 +10,23 @@ class PostVotesController < ApplicationController
end end
def create def create
@post = authorize Post.find(params[:post_id]), policy_class: PostVotePolicy @post = Post.find(params[:post_id])
@post.vote!(params[:score])
respond_with(@post) @post.with_lock do
@post_vote = authorize PostVote.new(post: @post, score: params[:score], user: CurrentUser.user)
PostVote.where(post: @post, user: CurrentUser.user).destroy_all
@post_vote.save
end
flash.now[:notice] = @post_vote.errors.full_messages.join("; ") if @post_vote.errors.present?
respond_with(@post_vote)
end end
def destroy def destroy
@post = authorize Post.find(params[:post_id]), policy_class: PostVotePolicy @post = Post.find(params[:post_id])
@post.unvote! @post_vote = @post.votes.find_by(user: CurrentUser.user)
respond_with(@post) authorize(@post_vote).destroy if @post_vote
respond_with(@post_vote)
end end
end end

View File

@@ -2,9 +2,8 @@ class CommentVote < ApplicationRecord
belongs_to :comment belongs_to :comment
belongs_to :user belongs_to :user
validates_presence_of :score validates :user_id, uniqueness: { scope: :comment_id, message: "have already voted for this comment" }
validates_uniqueness_of :user_id, :scope => :comment_id, :message => "have already voted for this comment" validates :score, inclusion: { in: [-1, 1], message: "must be 1 or -1" }
validates_inclusion_of :score, :in => [-1, 1], :message => "must be 1 or -1"
after_create :update_score_after_create after_create :update_score_after_create
after_destroy :update_score_after_destroy after_destroy :update_score_after_destroy

View File

@@ -633,7 +633,8 @@ class Post < ApplicationRecord
remove_favorite(CurrentUser.user) remove_favorite(CurrentUser.user)
when /^(up|down)vote:(.+)$/i when /^(up|down)vote:(.+)$/i
vote!($1) score = ($1 == "up" ? 1 : -1)
vote!(score, CurrentUser.user)
when /^status:active$/i when /^status:active$/i
raise User::PrivilegeError unless CurrentUser.is_approver? raise User::PrivilegeError unless CurrentUser.is_approver?
@@ -779,8 +780,7 @@ class Post < ApplicationRecord
def add_favorite!(user) def add_favorite!(user)
Favorite.add(post: self, user: user) Favorite.add(post: self, user: user)
vote!("up", user) if Pundit.policy!(user, PostVote).create? vote!(1, user)
rescue PostVote::Error
end end
def delete_user_from_fav_string(user_id) def delete_user_from_fav_string(user_id)
@@ -789,8 +789,7 @@ class Post < ApplicationRecord
def remove_favorite!(user) def remove_favorite!(user)
Favorite.remove(post: self, user: user) Favorite.remove(post: self, user: user)
unvote!(user) if Pundit.policy!(user, PostVote).create? unvote!(user)
rescue PostVote::Error
end end
def remove_favorite(user) def remove_favorite(user)
@@ -871,30 +870,22 @@ class Post < ApplicationRecord
end end
module VoteMethods module VoteMethods
def can_be_voted_by?(user) def vote!(score, voter)
!PostVote.exists?(:user_id => user.id, :post_id => id) # Ignore vote if user doesn't have permission to vote.
return unless Pundit.policy!(voter, PostVote).create?
with_lock do
votes.destroy_by(user: voter)
votes.create!(user: voter, score: score)
reload # PostVote.create modifies our score. Reload to get the new score.
end
end end
def vote!(vote, voter = CurrentUser.user) def unvote!(voter)
unless Pundit.policy!(voter, PostVote).create? return unless Pundit.policy!(voter, PostVote).create?
raise PostVote::Error.new("You do not have permission to vote")
end
unless can_be_voted_by?(voter) votes.destroy_by(user: voter)
raise PostVote::Error.new("You have already voted for this post") reload
end
votes.create!(user: voter, vote: vote)
reload # PostVote.create modifies our score. Reload to get the new score.
end
def unvote!(voter = CurrentUser.user)
if can_be_voted_by?(voter)
raise PostVote::Error.new("You have not voted for this post")
else
votes.where(user: voter).destroy_all
reload
end
end end
end end

View File

@@ -1,15 +1,12 @@
class PostVote < ApplicationRecord class PostVote < ApplicationRecord
class Error < StandardError; end
belongs_to :post belongs_to :post
belongs_to :user belongs_to :user
attr_accessor :vote
after_initialize :initialize_attributes, if: :new_record? validates :user_id, uniqueness: { scope: :post_id, message: "have already voted for this post" }
validates_presence_of :score validates :score, inclusion: { in: [1, -1], message: "must be 1 or -1" }
validates_inclusion_of :score, in: [1, -1]
after_create :update_post_on_create after_create :update_score_after_create
after_destroy :update_post_on_destroy after_destroy :update_score_after_destroy
scope :positive, -> { where("post_votes.score > 0") } scope :positive, -> { where("post_votes.score > 0") }
scope :negative, -> { where("post_votes.score < 0") } scope :negative, -> { where("post_votes.score < 0") }
@@ -23,16 +20,6 @@ class PostVote < ApplicationRecord
q.apply_default_order(params) q.apply_default_order(params)
end end
def initialize_attributes
self.user_id ||= CurrentUser.id
if vote == "up"
self.score = 1
elsif vote == "down"
self.score = -1
end
end
def is_positive? def is_positive?
score > 0 score > 0
end end
@@ -41,19 +28,19 @@ class PostVote < ApplicationRecord
score < 0 score < 0
end end
def update_post_on_create def update_score_after_create
if score > 0 if is_positive?
Post.where(:id => post_id).update_all("score = score + #{score}, up_score = up_score + #{score}") Post.update_counters(post_id, { score: score, up_score: score })
else else
Post.where(:id => post_id).update_all("score = score + #{score}, down_score = down_score + #{score}") Post.update_counters(post_id, { score: score, down_score: score })
end end
end end
def update_post_on_destroy def update_score_after_destroy
if score > 0 if is_positive?
Post.where(:id => post_id).update_all("score = score - #{score}, up_score = up_score - #{score}") Post.update_counters(post_id, { score: -score, up_score: -score })
else else
Post.where(:id => post_id).update_all("score = score - #{score}, down_score = down_score - #{score}") Post.update_counters(post_id, { score: -score, down_score: -score })
end end
end end

View File

@@ -4,6 +4,6 @@ class PostVotePolicy < ApplicationPolicy
end end
def destroy? def destroy?
unbanned? && user.is_gold? unbanned? && record.user == user
end end
end end

View File

@@ -1 +1 @@
$("span.post-votes[data-id=<%= @post.id %>]").replaceWith("<%= j render_post_votes @post, current_user: CurrentUser.user %>"); $("span.post-votes[data-id=<%= @post.reload.id %>]").replaceWith("<%= j render_post_votes @post, current_user: CurrentUser.user %>");

View File

@@ -34,7 +34,7 @@ class PostVotesComponentTest < ViewComponent::TestCase
context "for a downvoted post" do context "for a downvoted post" do
should "highlight the downvote button as active" do should "highlight the downvote button as active" do
@post.vote!("down", @user) @post.vote!(-1, @user)
render_post_votes(@post, current_user: @user) render_post_votes(@post, current_user: @user)
assert_css(".post-upvote-link.inactive-link") assert_css(".post-upvote-link.inactive-link")
@@ -44,7 +44,7 @@ class PostVotesComponentTest < ViewComponent::TestCase
context "for an upvoted post" do context "for an upvoted post" do
should "highlight the upvote button as active" do should "highlight the upvote button as active" do
@post.vote!("up", @user) @post.vote!(1, @user)
render_post_votes(@post, current_user: @user) render_post_votes(@post, current_user: @user)
assert_css(".post-upvote-link.active-link") assert_css(".post-upvote-link.active-link")

View File

@@ -46,52 +46,94 @@ class PostVotesControllerTest < ActionDispatch::IntegrationTest
context "create action" do context "create action" do
should "not allow anonymous users to vote" do should "not allow anonymous users to vote" do
post post_post_votes_path(post_id: @post.id), params: {:score => "up", :format => "js"} post post_post_votes_path(post_id: @post.id), params: { score: 1, format: "js" }
assert_response 403 assert_response 403
assert_equal(0, @post.reload.score) assert_equal(0, @post.reload.score)
end end
should "not allow banned users to vote" do should "not allow banned users to vote" do
@banned = create(:user) post_auth post_post_votes_path(post_id: @post.id), create(:banned_user), params: { score: 1, format: "js"}
@ban = create(:ban, user: @banned)
post_auth post_post_votes_path(post_id: @post.id), @banned, params: {:score => "up", :format => "js"}
assert_response 403 assert_response 403
assert_equal(0, @post.reload.score) assert_equal(0, @post.reload.score)
end end
should "not allow members to vote" do should "not allow members to vote" do
@member = create(:member_user) post_auth post_post_votes_path(post_id: @post.id), create(:user), params: { score: 1, format: "js" }
post_auth post_post_votes_path(post_id: @post.id), @member, params: {:score => "up", :format => "js"}
assert_response 403 assert_response 403
assert_equal(0, @post.reload.score) assert_equal(0, @post.reload.score)
end end
should "not allow invalid scores" do
post_auth post_post_votes_path(post_id: @post.id), @user, params: { score: 3, format: "js" }
assert_response 200
assert_equal(0, @post.reload.score)
assert_equal(0, @post.up_score)
assert_equal(0, @post.votes.count)
end
should "increment a post's score if the score is positive" do should "increment a post's score if the score is positive" do
post_auth post_post_votes_path(post_id: @post.id), @user, params: {:score => "up", :format => "js"} post_auth post_post_votes_path(post_id: @post.id), @user, params: { score: 1, format: "js" }
assert_response :success assert_response :success
@post.reload assert_equal(1, @post.reload.score)
assert_equal(1, @post.score) assert_equal(1, @post.up_score)
assert_equal(1, @post.votes.count)
end
should "decrement a post's score if the score is negative" do
post_auth post_post_votes_path(post_id: @post.id), @user, params: { score: -1, format: "js" }
assert_response :success
assert_equal(-1, @post.reload.score)
assert_equal(-1, @post.down_score)
assert_equal(1, @post.votes.count)
end end
context "for a post that has already been voted on" do context "for a post that has already been voted on" do
should "not create another vote" do should "replace the vote" do
@post.vote!("up", @user) @post.vote!(1, @user)
assert_no_difference("PostVote.count") do
post_auth post_post_votes_path(post_id: @post.id), @user, params: { score: "up", format: "js" } assert_no_difference("@post.votes.count") do
assert_response 422 post_auth post_post_votes_path(post_id: @post.id), @user, params: { score: -1, format: "js" }
assert_response :success
assert_equal(-1, @post.reload.score)
assert_equal(0, @post.up_score)
assert_equal(-1, @post.down_score)
end end
end end
end end
end end
context "destroy action" do context "destroy action" do
should "remove a vote" do should "do nothing for anonymous users" do
as(@user) { create(:post_vote, post_id: @post.id, user_id: @user.id) } delete post_post_votes_path(post_id: @post.id), xhr: true
assert_difference("PostVote.count", -1) do assert_response 200
delete_auth post_post_votes_path(post_id: @post.id), @user, as: :javascript assert_equal(0, @post.reload.score)
assert_redirected_to @post end
end
should "do nothing if the post hasn't been voted on" do
delete_auth post_post_votes_path(post_id: @post.id), @user, xhr: true
assert_response :success
assert_equal(0, @post.reload.score)
assert_equal(0, @post.down_score)
assert_equal(0, @post.votes.count)
end
should "remove a vote" do
@post.vote!(1, @user)
delete_auth post_post_votes_path(post_id: @post.id), @user, xhr: true
assert_response :success
assert_equal(0, @post.reload.score)
assert_equal(0, @post.down_score)
assert_equal(0, @post.votes.count)
end end
end end
end end

View File

@@ -1162,19 +1162,19 @@ class PostTest < ActiveSupport::TestCase
context "upvote:self or downvote:self" do context "upvote:self or downvote:self" do
context "by a member" do context "by a member" do
should "not upvote the post" do should "not upvote the post" do
assert_raises PostVote::Error do assert_no_difference("PostVote.count") do
@post.update(:tag_string => "upvote:self") @post.update(tag_string: "upvote:self")
end end
assert_equal(0, @post.score) assert_equal(0, @post.reload.score)
end end
should "not downvote the post" do should "not downvote the post" do
assert_raises PostVote::Error do assert_no_difference("PostVote.count") do
@post.update(:tag_string => "downvote:self") @post.update(tag_string: "downvote:self")
end end
assert_equal(0, @post.score) assert_equal(0, @post.reload.score)
end end
end end
@@ -1832,50 +1832,63 @@ class PostTest < ActiveSupport::TestCase
context "Voting:" do context "Voting:" do
should "not allow members to vote" do should "not allow members to vote" do
@user = FactoryBot.create(:user) user = create(:user)
@post = FactoryBot.create(:post) post = create(:post)
as(@user) do
assert_raises(PostVote::Error) { @post.vote!("up") } assert_nothing_raised { post.vote!(1, user) }
end assert_equal(0, post.votes.count)
assert_equal(0, post.reload.score)
end end
should "not allow duplicate votes" do should "not allow duplicate votes" do
user = FactoryBot.create(:gold_user) user = create(:gold_user)
post = FactoryBot.create(:post) post = create(:post)
CurrentUser.scoped(user, "127.0.0.1") do
assert_nothing_raised {post.vote!("up")} post.vote!(1, user)
assert_raises(PostVote::Error) {post.vote!("up")} post.vote!(1, user)
post.reload
assert_equal(1, PostVote.count) assert_equal(1, post.reload.score)
assert_equal(1, post.score) assert_equal(1, post.votes.count)
end
end end
should "allow undoing of votes" do should "allow undoing of votes" do
user = FactoryBot.create(:gold_user) user = create(:gold_user)
post = FactoryBot.create(:post) post = create(:post)
# We deliberately don't call post.reload until the end to verify that # We deliberately don't call post.reload until the end to verify that
# post.unvote! returns the correct score even when not forcibly reloaded. # post.unvote! returns the correct score even when not forcibly reloaded.
CurrentUser.scoped(user, "127.0.0.1") do post.vote!(1, user)
post.vote!("up") assert_equal(1, post.score)
assert_equal(1, post.score) assert_equal(1, post.up_score)
assert_equal(0, post.down_score)
assert_equal(1, post.votes.positive.count)
post.unvote! post.unvote!(user)
assert_equal(0, post.score) assert_equal(0, post.score)
assert_equal(0, post.up_score)
assert_equal(0, post.down_score)
assert_equal(0, post.votes.count)
assert_nothing_raised {post.vote!("down")} post.vote!(-1, user)
assert_equal(-1, post.score) assert_equal(-1, post.score)
assert_equal(0, post.up_score)
assert_equal(-1, post.down_score)
assert_equal(1, post.votes.negative.count)
post.unvote! post.unvote!(user)
assert_equal(0, post.score) assert_equal(0, post.score)
assert_equal(0, post.up_score)
assert_equal(0, post.down_score)
assert_equal(0, post.votes.count)
assert_nothing_raised {post.vote!("up")} post.vote!(1, user)
assert_equal(1, post.score) assert_equal(1, post.score)
assert_equal(1, post.up_score)
assert_equal(0, post.down_score)
assert_equal(1, post.votes.positive.count)
post.reload post.reload
assert_equal(1, post.score) assert_equal(1, post.score)
end
end end
end end

View File

@@ -1,46 +1,74 @@
require 'test_helper' require 'test_helper'
class PostVoteTest < ActiveSupport::TestCase class PostVoteTest < ActiveSupport::TestCase
def setup context "A PostVote" do
super setup do
@post = create(:post)
@user = FactoryBot.create(:user)
CurrentUser.user = @user
CurrentUser.ip_addr = "127.0.0.1"
@post = FactoryBot.create(:post)
end
context "Voting for a post" do
should "interpret up as +1 score" do
vote = PostVote.create(:post_id => @post.id, :vote => "up")
assert_equal(1, vote.score)
end end
should "interpret down as -1 score" do context "during validation" do
vote = PostVote.create(:post_id => @post.id, :vote => "down") subject { build(:post_vote, post: @post) }
assert_equal(-1, vote.score)
should validate_uniqueness_of(:user_id).scoped_to(:post_id).with_message("have already voted for this post")
should validate_inclusion_of(:score).in_array([-1, 1]).with_message("must be 1 or -1")
end end
should "not accept any other scores" do context "creating" do
vote = PostVote.create(:post_id => @post.id, :vote => "xxx") context "an upvote" do
assert(vote.errors.any?) should "increment the post's score" do
vote = create(:post_vote, post: @post, score: 1)
assert_equal(1, @post.reload.score)
assert_equal(1, @post.up_score)
assert_equal(0, @post.down_score)
assert_equal(1, @post.votes.positive.count)
end
end
context "a downvote" do
should "decrement the post's score" do
vote = create(:post_vote, post: @post, score: -1)
assert_equal(-1, @post.reload.score)
assert_equal(0, @post.up_score)
assert_equal(-1, @post.down_score)
assert_equal(1, @post.votes.negative.count)
end
end
end end
should "increase the score of the post" do context "destroying" do
@post.votes.create(vote: "up") context "an upvote" do
@post.reload should "decrement the post's score" do
vote = create(:post_vote, post: @post, score: 1)
assert_equal(1, @post.reload.score)
assert_equal(1, @post.up_score)
assert_equal(0, @post.down_score)
assert_equal(1, @post.votes.count)
assert_equal(1, @post.score) vote.destroy
assert_equal(1, @post.up_score) assert_equal(0, @post.reload.score)
end assert_equal(0, @post.up_score)
assert_equal(0, @post.down_score)
assert_equal(0, @post.votes.count)
end
end
should "decrease the score of the post when removed" do context "a downvote" do
@post.votes.create(vote: "up").destroy should "increment the post's score" do
@post.reload vote = create(:post_vote, post: @post, score: -1)
assert_equal(-1, @post.reload.score)
assert_equal(0, @post.up_score)
assert_equal(-1, @post.down_score)
assert_equal(1, @post.votes.count)
assert_equal(0, @post.score) vote.destroy
assert_equal(0, @post.up_score) assert_equal(0, @post.reload.score)
assert_equal(0, @post.up_score)
assert_equal(0, @post.down_score)
assert_equal(0, @post.votes.count)
end
end
end end
end end
end end