FIX: Poll: ensure it is not possible to vote with all abstentions in Ranked Choice (#29601)

Make sure Cast Vote button is disabled when all abstain and remove any historic data that fails new zero rank vote validation
This commit is contained in:
Robert 2024-11-26 12:16:22 +00:00 committed by GitHub
parent c7e471d35a
commit a535798659
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 190 additions and 1 deletions

View File

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

View File

@ -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) => {

View File

@ -39,6 +39,7 @@ en:
named_poll_with_multiple_choices_has_invalid_parameters: "Poll named <strong>%{name}</strong> 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."

View File

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

View File

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

View File

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

View File

@ -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`<Poll @post={{this.post}} @poll={{this.poll}} />`);
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`<Poll @post={{this.post}} @poll={{this.poll}} />`);
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({