diff --git a/plugins/poll/app/models/poll_vote.rb b/plugins/poll/app/models/poll_vote.rb index 6f453dacb8d..7dc8461db0c 100644 --- a/plugins/poll/app/models/poll_vote.rb +++ b/plugins/poll/app/models/poll_vote.rb @@ -15,6 +15,7 @@ end # user_id :bigint # created_at :datetime not null # updated_at :datetime not null +# rank :integer default(0), not null # # Indexes # diff --git a/plugins/poll/assets/javascripts/discourse/components/poll.gjs b/plugins/poll/assets/javascripts/discourse/components/poll.gjs index 16b58eacb35..85b48724c1e 100644 --- a/plugins/poll/assets/javascripts/discourse/components/poll.gjs +++ b/plugins/poll/assets/javascripts/discourse/components/poll.gjs @@ -72,11 +72,13 @@ export default class PollComponent extends Component { areRanksValid = (arr) => { let ranks = new Set(); // Using a Set to keep track of unique ranks let hasNonZeroDuplicate = false; + let allZeros = true; arr.forEach((obj) => { const rank = obj.rank; if (rank !== 0) { + allZeros = false; // Set to false if any rank is non-zero if (ranks.has(rank)) { hasNonZeroDuplicate = true; return; // Exit forEach loop if a non-zero duplicate is found @@ -85,7 +87,7 @@ export default class PollComponent extends Component { } }); - return !hasNonZeroDuplicate; + return !hasNonZeroDuplicate && !allZeros; }; _toggleOption = (option, rank = 0) => { diff --git a/plugins/poll/config/locales/server.en.yml b/plugins/poll/config/locales/server.en.yml index 090fbe48dd0..5d7860f7064 100644 --- a/plugins/poll/config/locales/server.en.yml +++ b/plugins/poll/config/locales/server.en.yml @@ -39,6 +39,7 @@ en: named_poll_with_multiple_choices_has_invalid_parameters: "Poll named %{name} with multiple choice has invalid parameters." requires_at_least_1_valid_option: "You must select at least 1 valid option." + requires_that_at_least_one_option_is_ranked: "In ranked-choice polls, you must rank at least one option." edit_window_expired: cannot_edit_default_poll_with_votes: "You cannot change a poll after the first %{minutes} minutes." diff --git a/plugins/poll/db/migrate/20241105211601_delete_zero_rank_user_vote_collections_from_poll_votes.rb b/plugins/poll/db/migrate/20241105211601_delete_zero_rank_user_vote_collections_from_poll_votes.rb new file mode 100644 index 00000000000..1ac4f9321b0 --- /dev/null +++ b/plugins/poll/db/migrate/20241105211601_delete_zero_rank_user_vote_collections_from_poll_votes.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +class DeleteZeroRankUserVoteCollectionsFromPollVotes < ActiveRecord::Migration[7.1] + def up + execute <<~SQL + DELETE FROM poll_votes + WHERE (poll_id, user_id) IN ( + SELECT poll_votes.poll_id, poll_votes.user_id + FROM poll_votes + JOIN polls ON polls.id = poll_votes.poll_id + WHERE polls.type = 3 + GROUP BY poll_votes.poll_id, poll_votes.user_id + HAVING SUM(poll_votes.rank) = 0 + ); + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/poll/lib/poll.rb b/plugins/poll/lib/poll.rb index cb1be3933ed..de1f2b8eb9e 100644 --- a/plugins/poll/lib/poll.rb +++ b/plugins/poll/lib/poll.rb @@ -17,6 +17,12 @@ class DiscoursePoll::Poll if poll.ranked_choice? options = options.values.map { |hash| hash } options.select! { |o| available_options.include?(o[:digest]) } + + if options.all? { |o| o[:rank] == "0" } + raise DiscoursePoll::Error.new I18n.t( + "poll.requires_that_at_least_one_option_is_ranked", + ) + end else options.select! { |o| available_options.include?(o) } end diff --git a/plugins/poll/spec/lib/poll_spec.rb b/plugins/poll/spec/lib/poll_spec.rb index 6df76e68935..ce53df2323e 100644 --- a/plugins/poll/spec/lib/poll_spec.rb +++ b/plugins/poll/spec/lib/poll_spec.rb @@ -43,6 +43,36 @@ RSpec.describe DiscoursePoll::Poll do end.to raise_error(DiscoursePoll::Error, I18n.t("poll.one_vote_per_user")) end + it "should not allow a ranked vote with all abstentions" do + poll = post_with_ranked_choice_poll.polls.first + poll_options = poll.poll_options + + expect do + DiscoursePoll::Poll.vote( + user, + post_with_ranked_choice_poll.id, + "poll", + { + "0": { + digest: poll_options.first.digest, + rank: "0", + }, + "1": { + digest: poll_options.second.digest, + rank: "0", + }, + "2": { + digest: poll_options.third.digest, + rank: "0", + }, + }, + ) + end.to raise_error( + DiscoursePoll::Error, + I18n.t("poll.requires_that_at_least_one_option_is_ranked"), + ) + end + it "should clean up bad votes for a regular poll" do poll = post_with_regular_poll.polls.first diff --git a/plugins/poll/test/javascripts/component/poll-test.js b/plugins/poll/test/javascripts/component/poll-test.js index ab90eb3151e..ff04674e004 100644 --- a/plugins/poll/test/javascripts/component/poll-test.js +++ b/plugins/poll/test/javascripts/component/poll-test.js @@ -41,6 +41,135 @@ module("Poll | Component | poll", function (hooks) { }); }); + test("valid ranks with which you can vote", async function (assert) { + this.setProperties({ + post: EmberObject.create({ + id: 42, + topic: { + archived: false, + }, + user_id: 29, + polls_votes: { + poll: [ + { + digest: "1f972d1df351de3ce35a787c89faad29", + rank: 1, + }, + { + digest: "d7ebc3a9beea2e680815a1e4f57d6db6", + rank: 2, + }, + { + digest: "6c986ebcde3d5822a6e91a695c388094", + rank: 3, + }, + ], + }, + }), + poll: new TrackedObject({ + name: "poll", + type: "ranked_choice", + status: "open", + results: "on_close", + options: [ + { + id: "1f972d1df351de3ce35a787c89faad29", + html: "this", + votes: 0, + rank: 1, + }, + { + id: "d7ebc3a9beea2e680815a1e4f57d6db6", + html: "that", + votes: 0, + rank: 2, + }, + { + id: "6c986ebcde3d5822a6e91a695c388094", + html: "other", + votes: 0, + rank: 3, + }, + ], + voters: 0, + chart_type: "bar", + }), + }); + + await render(hbs``); + + assert.dom(".poll-buttons .cast-votes:disabled").doesNotExist(); + assert.dom(".poll-buttons .cast-votes").exists(); + }); + + test("invalid ranks with which you cannot vote", async function (assert) { + this.setProperties({ + post: EmberObject.create({ + id: 42, + topic: { + archived: false, + }, + user_id: 29, + }), + poll: new TrackedObject({ + name: "poll", + type: "ranked_choice", + status: "open", + results: "always", + options: [ + { + id: "1f972d1df351de3ce35a787c89faad29", + html: "this", + votes: 0, + rank: 0, + }, + { + id: "d7ebc3a9beea2e680815a1e4f57d6db6", + html: "that", + votes: 0, + rank: 0, + }, + { + id: "6c986ebcde3d5822a6e91a695c388094", + html: "other", + votes: 0, + rank: 0, + }, + ], + voters: 0, + chart_type: "bar", + }), + }); + + await render(hbs``); + + await click( + ".ranked-choice-poll-option[data-poll-option-id='1f972d1df351de3ce35a787c89faad29'] button", + "open dropdown" + ); + + assert + .dom(".dropdown-menu__item:nth-child(2)") + .hasText(`1 ${I18n.t("poll.options.ranked_choice.highest_priority")}`); + + await click( + ".dropdown-menu__item:nth-child(2) button", + "select 1st priority" + ); + + assert.dom(".poll-buttons .cast-votes:disabled").doesNotExist(); + assert.dom(".poll-buttons .cast-votes").exists(); + + await click( + ".ranked-choice-poll-option[data-poll-option-id='1f972d1df351de3ce35a787c89faad29'] button", + "open dropdown" + ); + + await click(".dropdown-menu__item:nth-child(1) button", "select Abstain"); + + assert.dom(".poll-buttons .cast-votes:disabled").exists(); + }); + test("shows vote", async function (assert) { this.setProperties({ post: EmberObject.create({