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:
parent
c7e471d35a
commit
a535798659
|
@ -15,6 +15,7 @@ end
|
||||||
# user_id :bigint
|
# user_id :bigint
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
|
# rank :integer default(0), not null
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
|
|
|
@ -72,11 +72,13 @@ export default class PollComponent extends Component {
|
||||||
areRanksValid = (arr) => {
|
areRanksValid = (arr) => {
|
||||||
let ranks = new Set(); // Using a Set to keep track of unique ranks
|
let ranks = new Set(); // Using a Set to keep track of unique ranks
|
||||||
let hasNonZeroDuplicate = false;
|
let hasNonZeroDuplicate = false;
|
||||||
|
let allZeros = true;
|
||||||
|
|
||||||
arr.forEach((obj) => {
|
arr.forEach((obj) => {
|
||||||
const rank = obj.rank;
|
const rank = obj.rank;
|
||||||
|
|
||||||
if (rank !== 0) {
|
if (rank !== 0) {
|
||||||
|
allZeros = false; // Set to false if any rank is non-zero
|
||||||
if (ranks.has(rank)) {
|
if (ranks.has(rank)) {
|
||||||
hasNonZeroDuplicate = true;
|
hasNonZeroDuplicate = true;
|
||||||
return; // Exit forEach loop if a non-zero duplicate is found
|
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) => {
|
_toggleOption = (option, rank = 0) => {
|
||||||
|
|
|
@ -39,6 +39,7 @@ en:
|
||||||
named_poll_with_multiple_choices_has_invalid_parameters: "Poll named <strong>%{name}</strong> with multiple choice has invalid parameters."
|
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_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:
|
edit_window_expired:
|
||||||
cannot_edit_default_poll_with_votes: "You cannot change a poll after the first %{minutes} minutes."
|
cannot_edit_default_poll_with_votes: "You cannot change a poll after the first %{minutes} minutes."
|
||||||
|
|
|
@ -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
|
|
@ -17,6 +17,12 @@ class DiscoursePoll::Poll
|
||||||
if poll.ranked_choice?
|
if poll.ranked_choice?
|
||||||
options = options.values.map { |hash| hash }
|
options = options.values.map { |hash| hash }
|
||||||
options.select! { |o| available_options.include?(o[:digest]) }
|
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
|
else
|
||||||
options.select! { |o| available_options.include?(o) }
|
options.select! { |o| available_options.include?(o) }
|
||||||
end
|
end
|
||||||
|
|
|
@ -43,6 +43,36 @@ RSpec.describe DiscoursePoll::Poll do
|
||||||
end.to raise_error(DiscoursePoll::Error, I18n.t("poll.one_vote_per_user"))
|
end.to raise_error(DiscoursePoll::Error, I18n.t("poll.one_vote_per_user"))
|
||||||
end
|
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
|
it "should clean up bad votes for a regular poll" do
|
||||||
poll = post_with_regular_poll.polls.first
|
poll = post_with_regular_poll.polls.first
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
test("shows vote", async function (assert) {
|
||||||
this.setProperties({
|
this.setProperties({
|
||||||
post: EmberObject.create({
|
post: EmberObject.create({
|
||||||
|
|
Loading…
Reference in New Issue