FEATURE: Allow users to remove their vote (#14459)

FEATURE: Allow users to remove their vote (#14459)

They can use the remove vote button or select the same option again for single choice polls.

This commit refactor the plugin to properly organize code and make it easier to follow.

diff --git a/plugins/poll/app/controllers/polls_controller.rb b/plugins/poll/app/controllers/polls_controller.rb
new file mode 100644
index 0000000..86ae5c1
--- /dev/null
+++ b/plugins/poll/app/controllers/polls_controller.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+class DiscoursePoll::PollsController < ::ApplicationController
+  requires_plugin DiscoursePoll::PLUGIN_NAME
+
+  before_action :ensure_logged_in, except: [:voters, :grouped_poll_results]
+
+  def vote
+    post_id = params.require(:post_id)
+    poll_name = params.require(:poll_name)
+    options = params.require(:options)
+
+    begin
+      poll, options = DiscoursePoll::Poll.vote(current_user, post_id, poll_name, options)
+      render json: { poll: poll, vote: options }
+    rescue DiscoursePoll::Error => e
+      render_json_error e.message
+    end
+  end
+
+  def remove_vote
+    post_id = params.require(:post_id)
+    poll_name = params.require(:poll_name)
+
+    begin
+      poll = DiscoursePoll::Poll.remove_vote(current_user, post_id, poll_name)
+      render json: { poll: poll }
+    rescue DiscoursePoll::Error => e
+      render_json_error e.message
+    end
+  end
+
+  def toggle_status
+    post_id = params.require(:post_id)
+    poll_name = params.require(:poll_name)
+    status = params.require(:status)
+
+    begin
+      poll = DiscoursePoll::Poll.toggle_status(current_user, post_id, poll_name, status)
+      render json: { poll: poll }
+    rescue DiscoursePoll::Error => e
+      render_json_error e.message
+    end
+  end
+
+  def voters
+    post_id = params.require(:post_id)
+    poll_name = params.require(:poll_name)
+    opts = params.permit(:limit, :page, :option_id)
+
+    raise Discourse::InvalidParameters.new(:post_id) if !Post.where(id: post_id).exists?
+
+    poll = Poll.find_by(post_id: post_id, name: poll_name)
+    raise Discourse::InvalidParameters.new(:poll_name) if !poll&.can_see_voters?(current_user)
+
+    render json: { voters: DiscoursePoll::Poll.serialized_voters(poll, opts) }
+  end
+
+  def grouped_poll_results
+    post_id = params.require(:post_id)
+    poll_name = params.require(:poll_name)
+    user_field_name = params.require(:user_field_name)
+
+    begin
+      render json: {
+        grouped_results: DiscoursePoll::Poll.grouped_poll_results(current_user, post_id, poll_name, user_field_name)
+      }
+    rescue DiscoursePoll::Error => e
+      render_json_error e.message
+    end
+  end
+end
diff --git a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6
index ab0fb09..67212d5 100644
--- a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6
+++ b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6
@@ -109,6 +109,7 @@ function initializePolls(api) {
           post: pollPost,
           poll,
           vote,
+          hasSavedVote: vote.length > 0,
           titleHTML: titleElement && titleElement.outerHTML,
           groupableUserFields: (
             api.container.lookup("site-settings:main")
diff --git a/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6 b/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6
index a6937e5..a68560d 100644
--- a/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6
+++ b/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6
@@ -636,24 +636,44 @@ createWidget("discourse-poll-buttons", {
         })
       );
     } else {
+      let showResultsButton;
+      let infoText;
+
       if (poll.results === "on_vote" && !attrs.hasVoted && !isMe) {
-        contents.push(infoTextHtml(I18n.t("poll.results.vote.title")));
+        infoText = infoTextHtml(I18n.t("poll.results.vote.title"));
       } else if (poll.results === "on_close" && !closed) {
-        contents.push(infoTextHtml(I18n.t("poll.results.closed.title")));
+        infoText = infoTextHtml(I18n.t("poll.results.closed.title"));
       } else if (poll.results === "staff_only" && !isStaff) {
-        contents.push(infoTextHtml(I18n.t("poll.results.staff.title")));
+        infoText = infoTextHtml(I18n.t("poll.results.staff.title"));
       } else {
+        showResultsButton = this.attach("button", {
+          className: "btn-default toggle-results",
+          label: "poll.show-results.label",
+          title: "poll.show-results.title",
+          icon: "far-eye",
+          action: "toggleResults",
+        });
+      }
+
+      if (showResultsButton) {
+        contents.push(showResultsButton);
+      }
+
+      if (attrs.hasSavedVote) {
         contents.push(
           this.attach("button", {
-            className: "btn-default toggle-results",
-            label: "poll.show-results.label",
-            title: "poll.show-results.title",
-            icon: "far-eye",
-            disabled: poll.voters === 0,
-            action: "toggleResults",
+            className: "btn-default remove-vote",
+            label: "poll.remove-vote.label",
+            title: "poll.remove-vote.title",
+            icon: "trash-alt",
+            action: "removeVote",
           })
         );
       }
+
+      if (infoText) {
+        contents.push(infoText);
+      }
     }
 
     if (attrs.groupableUserFields.length && poll.voters > 0) {
@@ -894,6 +914,28 @@ export default createWidget("discourse-poll", {
     this.state.showResults = !this.state.showResults;
   },
 
+  removeVote() {
+    const { attrs, state } = this;
+    state.loading = true;
+    return ajax("/polls/vote", {
+      type: "DELETE",
+      data: {
+        post_id: attrs.post.id,
+        poll_name: attrs.poll.name,
+      },
+    })
+      .then(({ poll }) => {
+        attrs.poll.setProperties(poll);
+        attrs.vote.length = 0;
+        attrs.hasSavedVote = false;
+        this.appEvents.trigger("poll:voted", poll, attrs.post, attrs.vote);
+      })
+      .catch((error) => popupAjaxError(error))
+      .finally(() => {
+        state.loading = false;
+      });
+  },
+
   exportResults() {
     const { attrs } = this;
     const queryID = this.siteSettings.poll_export_data_explorer_query_id;
@@ -963,6 +1005,10 @@ export default createWidget("discourse-poll", {
     }
 
     const { vote } = attrs;
+    if (!this.isMultiple() && vote.length === 1 && vote[0] === option.id) {
+      return this.removeVote();
+    }
+
     if (!this.isMultiple()) {
       vote.length = 0;
     }
@@ -994,6 +1040,7 @@ export default createWidget("discourse-poll", {
       },
     })
       .then(({ poll }) => {
+        attrs.hasSavedVote = true;
         attrs.poll.setProperties(poll);
         this.appEvents.trigger("poll:voted", poll, attrs.post, attrs.vote);
 
diff --git a/plugins/poll/config/locales/client.en.yml b/plugins/poll/config/locales/client.en.yml
index b1a0246..c7fa389 100644
--- a/plugins/poll/config/locales/client.en.yml
+++ b/plugins/poll/config/locales/client.en.yml
@@ -44,6 +44,10 @@ en:
         title: "Display the poll results"
         label: "Show results"
 
+      remove-vote:
+        title: "Remove your vote"
+        label: "Remove vote"
+
       hide-results:
         title: "Back to your votes"
         label: "Show vote"
diff --git a/plugins/poll/jobs/regular/close_poll.rb b/plugins/poll/jobs/regular/close_poll.rb
index 864bef0..c803e44 100644
--- a/plugins/poll/jobs/regular/close_poll.rb
+++ b/plugins/poll/jobs/regular/close_poll.rb
@@ -13,10 +13,10 @@ module Jobs
       end
 
       DiscoursePoll::Poll.toggle_status(
+        Discourse.system_user,
         args[:post_id],
         args[:poll_name],
         "closed",
-        Discourse.system_user,
         false
       )
     end
diff --git a/plugins/poll/lib/poll.rb b/plugins/poll/lib/poll.rb
new file mode 100644
index 0000000..fba328e
--- /dev/null
+++ b/plugins/poll/lib/poll.rb
@@ -0,0 +1,338 @@
+# frozen_string_literal: true
+
+class DiscoursePoll::Poll
+  def self.vote(user, post_id, poll_name, options)

[... diff too long, it was truncated ...]

GitHub sha: 6a143030f8bde9342b988dcb332912bd872aac76

This commit appears in #14459 which was approved by ZogStriP and tgxworld. It was merged by nbianca.