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({