FEATURE: Add Ranked Choice Voting

using Instant Run-off Voting algorithm to Poll Plugin (Part 2 add Ranked Choice)

---------

Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com>
Co-authored-by: Jarek Radosz <jradosz@gmail.com>
This commit is contained in:
Robert 2024-07-17 10:49:14 +01:00 committed by GitHub
parent ef27ee9fb6
commit bae492efee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1548 additions and 222 deletions

View File

@ -61,18 +61,29 @@ class DiscoursePoll::PollsController < ::ApplicationController
poll_name = params.require(:poll_name)
user_field_name = params.require(:user_field_name)
begin
poll = Poll.find_by(post_id: post_id, name: poll_name)
if poll.nil?
render json: { error: I18n.t("poll.errors.poll_not_found") }, status: :not_found
elsif poll.ranked_choice?
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
error: I18n.t("poll.ranked_choice.no_group_results_support"),
},
status: :unprocessable_entity
else
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
end

View File

@ -9,7 +9,7 @@ class Poll < ActiveRecord::Base
has_many :poll_options, -> { order(:id) }, dependent: :destroy
has_many :poll_votes
enum type: { regular: 0, multiple: 1, number: 2 }, _scopes: false
enum type: { regular: 0, multiple: 1, number: 2, ranked_choice: 3 }, _scopes: false
enum status: { open: 0, closed: 1 }, _scopes: false
@ -43,6 +43,10 @@ class Poll < ActiveRecord::Base
def can_see_voters?(user)
everyone? && can_see_results?(user)
end
def ranked_choice?
type == "ranked_choice"
end
end
# == Schema Information

View File

@ -16,7 +16,8 @@ class PollSerializer < ApplicationSerializer
:preloaded_voters,
:chart_type,
:groups,
:title
:title,
:ranked_choice_outcome
def public
true
@ -75,4 +76,12 @@ class PollSerializer < ApplicationSerializer
def include_preloaded_voters?
object.can_see_voters?(scope.user)
end
def include_ranked_choice_outcome?
object.ranked_choice?
end
def ranked_choice_outcome
DiscoursePoll::RankedChoice.outcome(object.id)
end
end

View File

@ -7,35 +7,51 @@
<:body>
<ul class="nav nav-pills poll-type">
<li>
<a
href
{{on "click" (fn this.updatePollType "regular")}}
class="poll-type-value poll-type-value-regular
{{if this.isRegular 'active'}}"
<DButton
@action={{fn this.updatePollType "regular"}}
class={{concatClass
"poll-type-value poll-type-value-regular"
(if this.isRegular "active")
}}
>
{{i18n "poll.ui_builder.poll_type.regular"}}
</a>
</DButton>
</li>
<li>
<a
href
{{on "click" (fn this.updatePollType "multiple")}}
class="poll-type-value poll-type-value-multiple
{{if this.isMultiple 'active'}}"
<DButton
@action={{fn this.updatePollType "multiple"}}
class={{concatClass
"poll-type-value poll-type-value-multiple"
(if this.isMultiple "active")
}}
>
{{i18n "poll.ui_builder.poll_type.multiple"}}
</a>
</DButton>
</li>
{{#if this.showNumber}}
<li>
<a
href
{{on "click" (fn this.updatePollType "number")}}
class="poll-type-value poll-type-value-number
{{if this.isNumber 'active'}}"
<DButton
@action={{fn this.updatePollType "number"}}
class={{concatClass
"poll-type-value poll-type-value-number"
(if this.isNumber "active")
}}
>
{{i18n "poll.ui_builder.poll_type.number"}}
</a>
</DButton>
</li>
{{/if}}
{{#if this.showRankedChoice}}
<li>
<DButton
@action={{fn this.updatePollType "ranked_choice"}}
class={{concatClass
"poll-type-value poll-type-value-ranked-choice"
(if this.isRankedChoice "active")
}}
>
{{i18n "poll.ui_builder.poll_type.ranked_choice"}}
</DButton>
</li>
{{/if}}
</ul>
@ -103,7 +119,7 @@
</div>
{{/unless}}
{{#unless this.isRegular}}
{{#unless this.rankedChoiceOrRegular}}
<div class="options">
<div class="input-group poll-number">
<label class="input-group-label">{{i18n
@ -197,7 +213,7 @@
/>
</div>
{{#unless this.isNumber}}
{{#unless this.rankedChoiceOrNumber}}
<div class="input-group poll-select column">
<label class="input-group-label">{{i18n
"poll.ui_builder.poll_chart_type.label"

View File

@ -12,6 +12,7 @@ export const PIE_CHART_TYPE = "pie";
export const REGULAR_POLL_TYPE = "regular";
export const NUMBER_POLL_TYPE = "number";
export const MULTIPLE_POLL_TYPE = "multiple";
export const RANKED_CHOICE_POLL_TYPE = "ranked_choice";
const ALWAYS_POLL_RESULT = "always";
const VOTE_POLL_RESULT = "on_vote";
@ -36,7 +37,10 @@ export default class PollUiBuilderModal extends Component {
publicPoll = this.siteSettings.poll_default_public;
@or("showAdvanced", "isNumber") showNumber;
@or("showAdvanced", "isRankedChoice") showRankedChoice;
@gt("pollOptions.length", 1) canRemoveOption;
@or("isRankedChoice", "isRegular") rankedChoiceOrRegular;
@or("isRankedChoice", "isNumber") rankedChoiceOrNumber;
@discourseComputed("currentUser.staff")
pollResults(staff) {
@ -80,6 +84,11 @@ export default class PollUiBuilderModal extends Component {
return pollType === MULTIPLE_POLL_TYPE;
}
@discourseComputed("pollType")
isRankedChoice(pollType) {
return pollType === RANKED_CHOICE_POLL_TYPE;
}
@discourseComputed("pollOptions.@each.value")
pollOptionsCount(pollOptions) {
return (pollOptions || []).filter((option) => option.value.length > 0)

View File

@ -56,7 +56,9 @@ export default class PollButtonsDropdownComponent extends Component {
const isAdmin = this.currentUser && this.currentUser.admin;
const dataExplorerEnabled = this.siteSettings.data_explorer_enabled;
const exportQueryID = this.siteSettings.poll_export_data_explorer_query_id;
const exportQueryID = this.args.isRankedChoice
? this.siteSettings.poll_export_ranked_choice_data_explorer_query_id
: this.siteSettings.poll_export_data_explorer_query_id;
const {
closed,

View File

@ -0,0 +1,61 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { action } from "@ember/object";
import DButton from "discourse/components/d-button";
import DropdownMenu from "discourse/components/dropdown-menu";
import icon from "discourse-common/helpers/d-icon";
import I18n from "discourse-i18n";
import DMenu from "float-kit/components/d-menu";
export default class PollOptionsDropdownComponent extends Component {
@tracked rank;
constructor() {
super(...arguments);
this.rank = this.args.rank;
}
@action
onRegisterApi(api) {
this.dMenu = api;
}
@action
selectRank(option, rank) {
this.args.sendRank(option, rank);
this.rank =
rank === 0 ? I18n.t("poll.options.ranked_choice.abstain") : rank;
this.dMenu.close();
}
get rankLabel() {
return this.rank === 0
? I18n.t("poll.options.ranked_choice.abstain")
: this.rank;
}
<template>
<DMenu @onRegisterApi={{this.onRegisterApi}}>
<:trigger>
<span class="d-button-label">
{{this.rankLabel}}
</span>
{{icon "angle-down"}}
</:trigger>
<:content>
<DropdownMenu as |dropdown|>
{{#each @rankedChoiceDropdownContent as |content|}}
<dropdown.item>
<DButton
@translatedLabel={{content.name}}
class="btn-transparent poll-option-dropdown"
@action={{fn this.selectRank @option.id content.id}}
/>
</dropdown.item>
{{/each}}
</DropdownMenu>
</:content>
</DMenu>
</template>
}

View File

@ -0,0 +1,40 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import DButton from "discourse/components/d-button";
import routeAction from "discourse/helpers/route-action";
import i18n from "discourse-common/helpers/i18n";
import PollOptionRankedChoiceDropdown from "./poll-option-ranked-choice-dropdown";
export default class PollOptionsComponent extends Component {
@service currentUser;
@action
sendRank(option, rank = 0) {
this.args.sendRank(option, rank);
}
<template>
<div
tabindex="0"
class="ranked-choice-poll-option"
data-poll-option-id={{@option.id}}
data-poll-option-rank={{@option.rank}}
>
{{#if this.currentUser}}
<PollOptionRankedChoiceDropdown
@rank={{@option.rank}}
@option={{@option}}
@rankedChoiceDropdownContent={{@rankedChoiceDropdownContent}}
@sendRank={{this.sendRank}}
/>
{{else}}
<DButton class="btn-default" onclick={{routeAction "showLogin"}}>{{i18n
"poll.options.ranked_choice.login"
}}</DButton>
{{/if}}
<span class="option-text">{{htmlSafe @option.html}}</span>
</div>
</template>
}

View File

@ -4,8 +4,10 @@ import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import concatClass from "discourse/helpers/concat-class";
import routeAction from "discourse/helpers/route-action";
import icon from "discourse-common/helpers/d-icon";
import PollOptionRankedChoice from "./poll-option-ranked-choice";
export default class PollOptionsComponent extends Component {
@service currentUser;
@ -19,46 +21,58 @@ export default class PollOptionsComponent extends Component {
this.args.sendOptionSelect(option);
}
@action
sendRank(option, rank = 0) {
this.args.sendOptionSelect(option, rank);
}
<template>
<ul>
<ul class={{concatClass (if @isRankedChoice "ranked-choice-poll-options")}}>
{{#each @options as |option|}}
<li tabindex="0" data-poll-option-id={{option.id}}>
{{#if this.currentUser}}
<button {{on "click" (fn this.sendClick option)}}>
{{#if (this.isChosen option)}}
{{#if @isCheckbox}}
{{icon "far-check-square"}}
{{#if @isRankedChoice}}
<PollOptionRankedChoice
@option={{option}}
@rankedChoiceDropdownContent={{@rankedChoiceDropdownContent}}
@sendRank={{this.sendRank}}
/>
{{else}}
<li tabindex="0" data-poll-option-id={{option.id}}>
{{#if this.currentUser}}
<button {{on "click" (fn this.sendClick option)}}>
{{#if (this.isChosen option)}}
{{#if @isCheckbox}}
{{icon "far-check-square"}}
{{else}}
{{icon "circle"}}
{{/if}}
{{else}}
{{icon "circle"}}
{{#if @isCheckbox}}
{{icon "far-square"}}
{{else}}
{{icon "far-circle"}}
{{/if}}
{{/if}}
{{else}}
{{#if @isCheckbox}}
{{icon "far-square"}}
<span class="option-text">{{htmlSafe option.html}}</span>
</button>
{{else}}
<button onclick={{routeAction "showLogin"}}>
{{#if (this.isChosen option)}}
{{#if @isCheckbox}}
{{icon "far-check-square"}}
{{else}}
{{icon "circle"}}
{{/if}}
{{else}}
{{icon "far-circle"}}
{{#if @isCheckbox}}
{{icon "far-square"}}
{{else}}
{{icon "far-circle"}}
{{/if}}
{{/if}}
{{/if}}
<span class="option-text">{{htmlSafe option.html}}</span>
</button>
{{else}}
<button onclick={{routeAction "showLogin"}}>
{{#if (this.isChosen option)}}
{{#if @isCheckbox}}
{{icon "far-check-square"}}
{{else}}
{{icon "circle"}}
{{/if}}
{{else}}
{{#if @isCheckbox}}
{{icon "far-square"}}
{{else}}
{{icon "far-circle"}}
{{/if}}
{{/if}}
<span class="option-text">{{htmlSafe option.html}}</span>
</button>
{{/if}}
</li>
<span class="option-text">{{htmlSafe option.html}}</span>
</button>
{{/if}}
</li>
{{/if}}
{{/each}}
</ul>
</template>

View File

@ -0,0 +1,73 @@
import Component from "@glimmer/component";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
export default class PollResultsRankedChoiceComponent extends Component {
get rankedChoiceWinnerText() {
return I18n.t("poll.ranked_choice.winner", {
count: this.args.rankedChoiceOutcome.round_activity.length,
winner: this.args.rankedChoiceOutcome.winning_candidate.html,
});
}
get rankedChoiceTiedText() {
return I18n.t("poll.ranked_choice.tied", {
count: this.args.rankedChoiceOutcome.round_activity.length,
});
}
<template>
<h3 class="poll-results-ranked-choice-subtitle-rounds">
{{i18n "poll.ranked_choice.title.rounds"}}
</h3>
<table class="poll-results-ranked-choice">
<thead>
<tr>
<th>{{i18n "poll.ranked_choice.round"}}</th>
<th>{{i18n "poll.ranked_choice.majority"}}</th>
<th>{{i18n "poll.ranked_choice.eliminated"}}</th>
</tr>
</thead>
<tbody>
{{#each @rankedChoiceOutcome.round_activity as |round|}}
{{#if round.majority}}
<tr>
<td>{{round.round}}</td>
<td>{{round.majority.html}}</td>
<td>{{i18n "poll.ranked_choice.none"}}</td>
</tr>
{{else}}
<tr>
<td>{{round.round}}</td>
<td>{{i18n "poll.ranked_choice.none"}}</td>
<td>
{{#each round.eliminated as |eliminated|}}
{{eliminated.html}}
{{/each}}
</td>
</tr>
{{/if}}
{{/each}}
</tbody>
</table>
<h3 class="poll-results-ranked-choice-subtitle-outcome">
{{i18n "poll.ranked_choice.title.outcome"}}
</h3>
{{#if @rankedChoiceOutcome.tied}}
<span
class="poll-results-ranked-choice-info"
>{{this.rankedChoiceTiedText}}</span>
<ul class="poll-results-ranked-choice-tied-candidates">
{{#each @rankedChoiceOutcome.tied_candidates as |tied_candidate|}}
<li
class="poll-results-ranked-choice-tied-candidate"
>{{tied_candidate.html}}</li>
{{/each}}
</ul>
{{else}}
<span
class="poll-results-ranked-choice-info"
>{{this.rankedChoiceWinnerText}}</span>
{{/if}}
</template>
}

View File

@ -70,24 +70,29 @@ export default class PollResultsStandardComponent extends Component {
<li class={{if option.chosen "chosen" ""}}>
<div class="option">
<p>
<span class="percentage">{{i18n
"number.percent"
count=option.percentage
}}</span>
{{#unless @isRankedChoice}}
<span class="percentage">{{i18n
"number.percent"
count=option.percentage
}}</span>
{{/unless}}
<span class="option-text">{{htmlSafe option.html}}</span>
</p>
<div class="bar-back">
<div
class="bar"
style={{htmlSafe (concat "width:" option.percentage "%")}}
/>
</div>
{{#unless @isRankedChoice}}
<div class="bar-back">
<div
class="bar"
style={{htmlSafe (concat "width:" option.percentage "%")}}
/>
</div>
{{/unless}}
{{#if @isPublic}}
<PollVoters
@postId={{@postId}}
@pollType={{@pollType}}
@optionId={{option.id}}
@pollName={{@pollName}}
@isRankedChoice={{@isRankedChoice}}
@totalVotes={{option.votes}}
@voters={{option.voters}}
@fetchVoters={{@fetchVoters}}

View File

@ -0,0 +1,79 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { action } from "@ember/object";
import { eq } from "truth-helpers";
import DButton from "discourse/components/d-button";
import I18n from "discourse-i18n";
import PollResultsRankedChoice from "./poll-results-ranked-choice";
import PollResultsStandard from "./poll-results-standard";
export default class TabsComponent extends Component {
@tracked activeTab;
tabOne = I18n.t("poll.results.tabs.votes");
tabTwo = I18n.t("poll.results.tabs.outcome");
constructor() {
super(...arguments);
this.activeTab =
this.args.isRankedChoice && this.args.isPublic
? this.tabs[1]
: this.tabs[0];
}
get tabs() {
let tabs = [];
if (
!this.args.isRankedChoice ||
(this.args.isRankedChoice && this.args.isPublic)
) {
tabs.push(this.tabOne);
}
if (this.args.isRankedChoice) {
tabs.push(this.tabTwo);
}
return tabs;
}
@action
selectTab(tab) {
this.activeTab = tab;
}
<template>
<div class="tab-container">
<ul class="tabs nav nav-items">
{{#each this.tabs as |tab|}}
<li class="tab nav-item {{if (eq tab this.activeTab) 'active'}}">
<DButton class="nav-btn" @action={{fn this.selectTab tab}}>
{{tab}}
</DButton>
</li>
{{/each}}
</ul>
<div class="tab-content">
{{#if (eq this.activeTab this.tabOne)}}
<PollResultsStandard
@options={{@options}}
@pollName={{@pollName}}
@pollType={{@pollType}}
@isPublic={{@isPublic}}
@isRankedChoice={{@isRankedChoice}}
@postId={{@postId}}
@vote={{@vote}}
@voters={{@voters}}
@votersCount={{@votersCount}}
@fetchVoters={{@fetchVoters}}
/>
{{/if}}
{{#if (eq this.activeTab this.tabTwo)}}
<PollResultsRankedChoice
@rankedChoiceOutcome={{@rankedChoiceOutcome}}
/>
{{/if}}
</div>
</div>
</template>
}

View File

@ -0,0 +1,47 @@
import Component from "@glimmer/component";
import { eq } from "truth-helpers";
import avatar from "discourse/helpers/bound-avatar-template";
import icon from "discourse-common/helpers/d-icon";
export default class PollVotersComponent extends Component {
groupVotersByRank = (voters) => {
return voters.reduce((groups, voter) => {
const rank = voter.rank;
groups[rank] ??= [];
groups[rank].push(voter);
return groups;
}, {});
};
get rankedChoiceVoters() {
const voters = [...this.args.voters];
// Group voters by rank so they can be displayed together by rank
const groupedByRank = this.groupVotersByRank(voters);
// Convert groups to array of objects with keys rank and voters
const groupedVoters = Object.keys(groupedByRank).map((rank) => ({
rank,
voters: groupedByRank[rank],
}));
return groupedVoters;
}
<template>
{{#each this.rankedChoiceVoters as |rank|}}
<ul>
{{#if (eq rank.rank "Abstain")}}
<span class="rank">{{icon "ban"}}</span>
{{else}}
<span class="rank">{{rank.rank}}</span>
{{/if}}
{{#each rank.voters as |user|}}
<li>
{{avatar user.user.avatar_template "tiny"}}
</li>
{{/each}}
</ul>
{{/each}}
</template>
}

View File

@ -3,6 +3,7 @@ import { fn } from "@ember/helper";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import DButton from "discourse/components/d-button";
import avatar from "discourse/helpers/bound-avatar-template";
import PollVotersRankedChoice from "./poll-voters-ranked-choice";
export default class PollVotersComponent extends Component {
get showMore() {
@ -12,11 +13,15 @@ export default class PollVotersComponent extends Component {
<template>
<div class="poll-voters">
<ul class="poll-voters-list">
{{#each @voters as |user|}}
<li>
{{avatar user.avatar_template "tiny"}}
</li>
{{/each}}
{{#if @isRankedChoice}}
<PollVotersRankedChoice @voters={{@voters}} />
{{else}}
{{#each @voters as |user|}}
<li>
{{avatar user.avatar_template "tiny"}}
</li>
{{/each}}
{{/if}}
</ul>
{{#if this.showMore}}
<ConditionalLoadingSpinner @condition={{@loading}}>

View File

@ -17,13 +17,14 @@ import PollButtonsDropdown from "../components/poll-buttons-dropdown";
import PollInfo from "../components/poll-info";
import PollOptions from "../components/poll-options";
import PollResultsPie from "../components/poll-results-pie";
import PollResultsStandard from "../components/poll-results-standard";
import PollResultsTabs from "../components/poll-results-tabs";
const FETCH_VOTERS_COUNT = 25;
const STAFF_ONLY = "staff_only";
const MULTIPLE = "multiple";
const NUMBER = "number";
const REGULAR = "regular";
const RANKED_CHOICE = "ranked_choice";
const ON_VOTE = "on_vote";
const ON_CLOSE = "on_close";
@ -42,6 +43,9 @@ export default class PollComponent extends Component {
@tracked poll = this.args.attrs.poll;
@tracked voters = this.poll.voters || 0;
@tracked preloadedVoters = this.args.preloadedVoters || [];
@tracked isRankedChoice = this.poll.type === RANKED_CHOICE;
@tracked rankedChoiceOutcome = this.poll.ranked_choice_outcome || [];
@tracked isMultiVoteType = this.isRankedChoice || this.isMultiple;
@tracked staffOnly = this.poll.results === STAFF_ONLY;
@tracked isMultiple = this.poll.type === MULTIPLE;
@tracked isNumber = this.poll.type === NUMBER;
@ -90,6 +94,7 @@ export default class PollComponent extends Component {
.then(({ poll }) => {
this.options = [...poll.options];
this.hasSavedVote = true;
this.rankedChoiceOutcome = poll.ranked_choice_outcome || [];
this.poll.setProperties(poll);
this.appEvents.trigger(
"poll:voted",
@ -114,7 +119,7 @@ export default class PollComponent extends Component {
})
.catch((error) => {
if (error) {
if (!this.isMultiple) {
if (!this.isMultiple && !this.isRankedChoice) {
this._toggleOption(option);
}
popupAjaxError(error);
@ -123,7 +128,25 @@ export default class PollComponent extends Component {
}
});
};
_toggleOption = (option) => {
areRanksValid = (arr) => {
let ranks = new Set(); // Using a Set to keep track of unique ranks
let hasNonZeroDuplicate = false;
arr.forEach((obj) => {
const rank = obj.rank;
if (rank !== 0) {
if (ranks.has(rank)) {
hasNonZeroDuplicate = true;
return; // Exit forEach loop if a non-zero duplicate is found
}
ranks.add(rank);
}
});
return !hasNonZeroDuplicate;
};
_toggleOption = (option, rank = 0) => {
let options = this.options;
let vote = this.vote;
@ -135,6 +158,24 @@ export default class PollComponent extends Component {
} else {
vote.push(option.id);
}
} else if (this.isRankedChoice) {
options.forEach((candidate, i) => {
const chosenIdx = vote.findIndex(
(object) => object.digest === candidate.id
);
if (chosenIdx === -1) {
vote.push({
digest: candidate.id,
rank: candidate.id === option ? rank : 0,
});
} else {
if (candidate.id === option) {
vote[chosenIdx].rank = rank;
options[i].rank = rank;
}
}
});
} else {
vote = [option.id];
}
@ -148,6 +189,29 @@ export default class PollComponent extends Component {
this.post = this.args.attrs.post;
this.options = this.poll.options;
this.groupableUserFields = this.args.attrs.groupableUserFields;
this.rankedChoiceDropdownContent = [];
if (this.isRankedChoice) {
this.rankedChoiceDropdownContent.push({
id: 0,
name: I18n.t("poll.options.ranked_choice.abstain"),
});
}
this.options.forEach((option, i) => {
option.rank = 0;
if (this.isRankedChoice) {
this.rankedChoiceDropdownContent.push({
id: i + 1,
name: (i + 1).toString(),
});
this.args.attrs.vote.forEach((vote) => {
if (vote.digest === option.id) {
option.rank = vote.rank;
}
});
}
});
}
get min() {
@ -189,7 +253,7 @@ export default class PollComponent extends Component {
}
@action
toggleOption(option) {
toggleOption(option, rank = 0) {
if (this.closed) {
return;
}
@ -203,19 +267,20 @@ export default class PollComponent extends Component {
if (
!this.isMultiple &&
!this.isRankedChoice &&
this.vote.length === 1 &&
this.vote[0] === option.id
) {
return this.removeVote();
}
if (!this.isMultiple) {
if (!this.isMultiple && !this.isRankedChoice) {
this.vote.length = 0;
}
this._toggleOption(option);
this._toggleOption(option, rank);
if (!this.isMultiple) {
if (!this.isMultiple && !this.isRankedChoice) {
this.castVotes(option);
}
}
@ -237,6 +302,13 @@ export default class PollComponent extends Component {
return selectedOptionCount >= this.min && selectedOptionCount <= this.max;
}
if (this.isRankedChoice) {
return (
this.options.length === this.vote.length &&
this.areRanksValid(this.vote)
);
}
return selectedOptionCount > 0;
}
@ -249,7 +321,7 @@ export default class PollComponent extends Component {
}
get showCastVotesButton() {
return this.isMultiple && !this.showResults;
return (this.isMultiple || this.isRankedChoice) && !this.showResults;
}
get castVotesButtonClass() {
@ -323,6 +395,15 @@ export default class PollComponent extends Component {
updatedVoters() {
this.preloadedVoters = this.args.preloadedVoters;
this.options = [...this.args.options];
if (this.isRankedChoice) {
this.options.forEach((candidate) => {
let specificVote = this.vote.find(
(vote) => vote.digest === candidate.id
);
let rank = specificVote ? specificVote.rank : 0;
candidate.rank = rank;
});
}
}
@action
@ -349,22 +430,26 @@ export default class PollComponent extends Component {
? this.preloadedVoters[optionId]
: this.preloadedVoters;
const newVoters = optionId ? result.voters[optionId] : result.voters;
const votersSet = new Set(voters.map((voter) => voter.username));
newVoters.forEach((voter) => {
if (!votersSet.has(voter.username)) {
votersSet.add(voter.username);
voters.push(voter);
}
});
// remove users who changed their vote
if (this.poll.type === REGULAR) {
Object.keys(this.preloadedVoters).forEach((otherOptionId) => {
if (optionId !== otherOptionId) {
this.preloadedVoters[otherOptionId] = this.preloadedVoters[
otherOptionId
].filter((voter) => !votersSet.has(voter.username));
if (this.isRankedChoice) {
this.preloadedVoters[optionId] = [...new Set([...newVoters])];
} else {
const votersSet = new Set(voters.map((voter) => voter.username));
newVoters.forEach((voter) => {
if (!votersSet.has(voter.username)) {
votersSet.add(voter.username);
voters.push(voter);
}
});
// remove users who changed their vote
if (this.poll.type === REGULAR) {
Object.keys(this.preloadedVoters).forEach((otherOptionId) => {
if (optionId !== otherOptionId) {
this.preloadedVoters[otherOptionId] = this.preloadedVoters[
otherOptionId
].filter((voter) => !votersSet.has(voter.username));
}
});
}
}
this.preloadedVoters[optionId] = [
...new Set([...this.preloadedVoters[optionId], ...newVoters]),
@ -398,8 +483,14 @@ export default class PollComponent extends Component {
},
})
.then(({ poll }) => {
if (this.poll.type === RANKED_CHOICE) {
poll.options.forEach((option) => {
option.rank = 0;
});
}
this.options = [...poll.options];
this.poll.setProperties(poll);
this.rankedChoiceOutcome = poll.ranked_choice_outcome || [];
this.vote = [];
this.voters = poll.voters;
this.hasSavedVote = false;
@ -456,7 +547,10 @@ export default class PollComponent extends Component {
@action
exportResults() {
const queryID = this.siteSettings.poll_export_data_explorer_query_id;
const queryID =
this.poll.type === RANKED_CHOICE
? this.siteSettings.poll_export_ranked_choice_data_explorer_query_id
: this.siteSettings.poll_export_data_explorer_query_id;
// This uses the Data Explorer plugin export as CSV route
// There is detection to check if the plugin is enabled before showing the button
@ -511,16 +605,18 @@ export default class PollComponent extends Component {
{{#if this.resultsPie}}
<PollResultsPie @id={{this.id}} @options={{this.options}} />
{{else}}
<PollResultsStandard
<PollResultsTabs
@options={{this.options}}
@pollName={{this.poll.name}}
@pollType={{this.poll.type}}
@isRankedChoice={{this.isRankedChoice}}
@isPublic={{this.poll.public}}
@postId={{this.post.id}}
@vote={{this.vote}}
@voters={{this.preloadedVoters}}
@votersCount={{this.poll.voters}}
@fetchVoters={{this.fetchVoters}}
@rankedChoiceOutcome={{this.rankedChoiceOutcome}}
/>
{{/if}}
{{/if}}
@ -528,6 +624,8 @@ export default class PollComponent extends Component {
{{else}}
<PollOptions
@isCheckbox={{this.isCheckbox}}
@isRankedChoice={{this.isRankedChoice}}
@rankedChoiceDropdownContent={{this.rankedChoiceDropdownContent}}
@options={{this.options}}
@votes={{this.vote}}
@sendOptionSelect={{this.toggleOption}}
@ -599,6 +697,7 @@ export default class PollComponent extends Component {
@voters={{this.voters}}
@isStaff={{this.isStaff}}
@isMe={{this.isMe}}
@isRankedChoice={{this.isRankedChoice}}
@topicArchived={{this.topicArchived}}
@groupableUserFields={{this.groupableUserFields}}
@isAutomaticallyClosed={{this.isAutomaticallyClosed}}

View File

@ -50,6 +50,9 @@
}
.poll-type {
.poll-type-value {
font-size: var(--font-down-1);
}
padding: 0;
margin-top: 0;
border: none;

View File

@ -31,6 +31,51 @@ div.poll-outer {
}
}
.ranked-choice-poll-options {
display: flex;
flex-direction: column;
gap: 0.5em;
padding: 0.5em;
.ranked-choice-poll-option {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5em;
padding: 0.5em;
}
}
.tabs {
display: none;
}
.discourse-poll-ranked_choice-results {
.tabs {
&.nav-items {
display: flex;
gap: 0.5em;
.nav-btn {
background-color: var(--secondary);
border: none;
color: var(--primary-medium);
}
.active {
.nav-btn {
color: var(--primary);
}
}
}
}
.rank {
display: inline-block;
min-width: 20px;
}
.poll-results-ranked_choice-subtitle-rounds {
margin: 0.25em 0 0.67rem;
}
}
img {
// Hacky way to stop images without width/height
// from causing abrupt unintended scrolling
@ -299,64 +344,6 @@ div.poll-outer {
}
}
// .poll-buttons-dropdown {
// align-self: stretch;
// position: relative;
// .label {
// display: none;
// }
// .widget-dropdown {
// &.closed {
// :not(:first-child) {
// display: none;
// }
// }
// &.opened {
// display: flex;
// flex-direction: column;
// position: relative;
// overflow-y: visible;
// .widget-dropdown-body {
// display: block;
// position: absolute;
// z-index: 300;
// overflow-y: visible;
// transform: translate(-44px, 38px);
// .widget-dropdown-item {
// width: 100%;
// padding: 0;
// margin: 0;
// float: left;
// &:hover {
// color: var(--tertiary);
// background-color: var(--primary-low);
// }
// button {
// width: 100%;
// padding: 0.5em;
// display: flex;
// flex-direction: row;
// background-color: var(--secondary);
// &:hover {
// background-color: var(--primary-low);
// }
// border: none;
// }
// }
// }
// }
// &-header {
// height: 100%;
// .d-icon {
// margin: 0;
// }
// }
// }
// }
.poll-buttons-dropdown,
.export-results,
.toggle-status,

View File

@ -22,6 +22,9 @@ en:
title: "Results will be shown once <strong>closed</strong>."
staff:
title: "Results are only shown to <strong>staff</strong> members."
tabs:
votes: "Votes"
outcome: "Outcome"
multiple:
help:
at_least_min_options:
@ -80,8 +83,26 @@ en:
percentage: "Percentage"
count: "Count"
ranked_choice:
title:
rounds: "Rounds"
outcome: "Result"
none: "None"
majority: "Majority"
eliminated: "Eliminated"
round: "Round"
winner:
one: "Winner was %{winner} after one round."
other: "Winner was %{winner} after %{count} rounds."
tied:
one: "Tied after one round between the following candidates:"
other: "Tied after %{count} rounds between the following candidates:"
options:
label: "Options"
ranked_choice:
abstain: "Abstain"
login: "Login to vote!"
error_while_toggling_status: "Sorry, there was an error toggling the status of this poll."
error_while_casting_votes: "Sorry, there was an error casting your votes."
@ -102,6 +123,7 @@ en:
label: Type
regular: Single Choice
multiple: Multiple Choice
ranked_choice: Ranked Choice
number: Number Rating
poll_result:
label: Show Results...

View File

@ -7,6 +7,7 @@ en:
poll_create_allowed_groups: "The groups that are allowed to create polls."
poll_groupable_user_fields: "A set of user field names that can be used to group and filter poll results."
poll_export_data_explorer_query_id: "ID of the Data Explorer Query to use for exporting poll results (0 to disable)."
poll_export_ranked_choice_data_explorer_query_id: "ID of the Data Explorer Query to use for exporting Instant Run-off Voting poll results."
poll_default_public: "When creating a new poll, enable the 'show who voted' option by default."
keywords:
poll_create_allowed_groups: "poll_minimum_trust_level_to_create"
@ -58,10 +59,13 @@ en:
min_vote_per_user:
one: A minimum of %{count} vote is required for this poll.
other: A minimum of %{count} votes is required for this poll.
ranked_choice:
vote_options_mismatch: "For Instant Run-off, the number of options supplied in a vote must match the number of options. The number of options is %{count} and the number provided was %{provided}."
no_group_results_support: "Invalid poll type, ranked_choice does not support grouped results."
topic_must_be_open_to_toggle_status: "The topic must be open to toggle status."
only_staff_or_op_can_toggle_status: "Only a staff member or the original poster can toggle a poll status."
errors:
poll_not_found: "Poll not found, please confirm parameters."
insufficient_rights_to_create: "You are not allowed to create polls."
email:

View File

@ -30,6 +30,10 @@ plugins:
default: -16
min: -9999
client: true
poll_export_ranked_choice_data_explorer_query_id:
default: -19
min: -9999
client: true
poll_default_public:
default: true
client: true

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddRankToPollVotes < ActiveRecord::Migration[7.0]
def change
add_column :poll_votes, :rank, :integer, null: false, default: 0
end
end

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true
class DiscoursePoll::Poll
RANKED_CHOICE = "ranked_choice"
MULTIPLE = "multiple"
REGULAR = "regular"
@ -13,7 +14,12 @@ class DiscoursePoll::Poll
# remove options that aren't available in the poll
available_options = poll.poll_options.map { |o| o.digest }.to_set
options.select! { |o| available_options.include?(o) }
if poll.ranked_choice?
options = options.values.map { |hash| hash }
options.select! { |o| available_options.include?(o[:digest]) }
else
options.select! { |o| available_options.include?(o) }
end
if options.empty?
raise DiscoursePoll::Error.new I18n.t("poll.requires_at_least_1_valid_option")
@ -23,7 +29,11 @@ class DiscoursePoll::Poll
poll
.poll_options
.each_with_object([]) do |option, obj|
obj << option.id if options.include?(option.digest)
if poll.ranked_choice?
obj << option.id if options.any? { |o| o[:digest] == option.digest }
else
obj << option.id if options.include?(option.digest)
end
end
self.validate_votes!(poll, new_option_ids)
@ -35,39 +45,83 @@ class DiscoursePoll::Poll
obj << option.id if option.poll_votes.where(user_id: user.id).exists?
end
# remove non-selected votes
PollVote.where(poll: poll, user: user).where.not(poll_option_id: new_option_ids).delete_all
if poll.ranked_choice?
# for ranked choice, we need to remove all votes and re-create them as there is no way to update them due to lack of primary key.
PollVote.where(poll: poll, user: user).delete_all
creation_set = new_option_ids
else
# remove non-selected votes
PollVote
.where(poll: poll, user: user)
.where.not(poll_option_id: new_option_ids)
.delete_all
creation_set = new_option_ids - old_option_ids
end
# create missing votes
creation_set = new_option_ids - old_option_ids
creation_set.each do |option_id|
PollVote.create!(poll: poll, user: user, poll_option_id: option_id)
if poll.ranked_choice?
option_digest = poll.poll_options.find(option_id).digest
PollVote.create!(
poll: poll,
user: user,
poll_option_id: option_id,
rank: options.find { |o| o[:digest] == option_digest }[:rank],
)
else
PollVote.create!(poll: poll, user: user, poll_option_id: option_id)
end
end
end
# Ensure consistency here as we do not have a unique index to limit the
# number of votes per the poll's configuration.
is_multiple = serialized_poll[:type] == MULTIPLE
offset = is_multiple ? (serialized_poll[:max] || serialized_poll[:options].length) : 1
if serialized_poll[:type] == RANKED_CHOICE
serialized_poll[:ranked_choice_outcome] = DiscoursePoll::RankedChoice.outcome(poll_id)
else
# Ensure consistency here as we do not have a unique index to limit the
# number of votes per the poll's configuration.
is_multiple = serialized_poll[:type] == MULTIPLE
offset = is_multiple ? (serialized_poll[:max] || serialized_poll[:options].length) : 1
params = { poll_id: poll_id, offset: offset, user_id: user.id }
params = { poll_id: poll_id, offset: offset, user_id: user.id }
DB.query(<<~SQL, params)
DELETE FROM poll_votes
USING (
SELECT
poll_id,
user_id
FROM poll_votes
WHERE poll_id = :poll_id
AND user_id = :user_id
ORDER BY created_at DESC
OFFSET :offset
) to_delete_poll_votes
WHERE poll_votes.poll_id = to_delete_poll_votes.poll_id
AND poll_votes.user_id = to_delete_poll_votes.user_id
SQL
DB.query(<<~SQL, params)
DELETE FROM poll_votes
USING (
SELECT
poll_id,
user_id
FROM poll_votes
WHERE poll_id = :poll_id
AND user_id = :user_id
ORDER BY created_at DESC
OFFSET :offset
) to_delete_poll_votes
WHERE poll_votes.poll_id = to_delete_poll_votes.poll_id
AND poll_votes.user_id = to_delete_poll_votes.user_id
SQL
end
serialized_poll[:options].each do |option|
if serialized_poll[:type] == RANKED_CHOICE
option.merge!(
rank:
PollVote
.joins(:poll_option)
.where(poll_options: { digest: option[:id] }, user_id: user.id, poll_id: poll_id)
.limit(1)
.pluck(:rank),
)
elsif serialized_poll[:type] == MULTIPLE
option.merge!(
chosen:
PollVote
.joins(:poll_option)
.where(poll_options: { digest: option[:id] }, user_id: user.id, poll_id: poll_id)
.exists?,
)
end
end
if serialized_poll[:type] == MULTIPLE
serialized_poll[:options].each do |option|
@ -85,9 +139,19 @@ class DiscoursePoll::Poll
end
def self.remove_vote(user, post_id, poll_name)
DiscoursePoll::Poll.change_vote(user, post_id, poll_name) do |poll|
PollVote.where(poll: poll, user: user).delete_all
poll_id = nil
serialized_poll =
DiscoursePoll::Poll.change_vote(user, post_id, poll_name) do |poll|
poll_id = poll.id
PollVote.where(poll: poll, user: user).delete_all
end
if serialized_poll[:type] == RANKED_CHOICE
serialized_poll[:ranked_choice_outcome] = DiscoursePoll::RankedChoice.outcome(poll_id)
end
serialized_poll
end
def self.toggle_status(user, post_id, poll_name, status, raise_errors = true)
@ -166,34 +230,96 @@ class DiscoursePoll::Poll
raise Discourse::InvalidParameters.new(:option_id) unless poll_option
user_ids =
PollVote
.where(poll: poll, poll_option: poll_option)
.group(:user_id)
.order("MIN(created_at)")
.offset(offset)
.limit(limit)
.pluck(:user_id)
if poll.ranked_choice?
params = {
poll_id: poll.id,
option_digest: option_digest,
offset: offset,
offset_plus_limit: offset + limit,
}
user_hashes = User.where(id: user_ids).map { |u| UserNameSerializer.new(u).serializable_hash }
result = { option_digest => user_hashes }
else
params = { poll_id: poll.id, offset: offset, offset_plus_limit: offset + limit }
votes = DB.query(<<~SQL, params)
SELECT digest, user_id
votes = DB.query(<<~SQL, params)
SELECT digest, rank, user_id
FROM (
SELECT digest
, CASE rank WHEN 0 THEN 'Abstain' ELSE CAST(rank AS text) END AS rank
, user_id
, username
, ROW_NUMBER() OVER (PARTITION BY poll_option_id ORDER BY pv.created_at) AS row
FROM poll_votes pv
JOIN poll_options po ON pv.poll_option_id = po.id
JOIN users u ON pv.user_id = u.id
WHERE pv.poll_id = :poll_id
AND po.poll_id = :poll_id
AND po.digest = :option_digest
) v
WHERE row BETWEEN :offset AND :offset_plus_limit
ORDER BY digest, CASE WHEN rank = 'Abstain' THEN 1 ELSE CAST(rank AS integer) END, username
SQL
user_ids = votes.map(&:user_id).uniq
user_hashes =
User
.where(id: user_ids)
.map { |u| [u.id, UserNameSerializer.new(u).serializable_hash] }
.to_h
ranked_choice_users = []
votes.each do |v|
ranked_choice_users ||= []
ranked_choice_users << { rank: v.rank, user: user_hashes[v.user_id] }
end
user_hashes = ranked_choice_users
else
user_ids =
PollVote
.where(poll: poll, poll_option: poll_option)
.group(:user_id)
.order("MIN(created_at)")
.offset(offset)
.limit(limit)
.pluck(:user_id)
user_hashes =
User.where(id: user_ids).map { |u| UserNameSerializer.new(u).serializable_hash }
end
result = { option_digest => user_hashes }
else
params = { poll_id: poll.id, offset: offset, offset_plus_limit: offset + limit }
if poll.ranked_choice?
votes = DB.query(<<~SQL, params)
SELECT digest, rank, user_id
FROM (
SELECT digest
, CASE rank WHEN 0 THEN 'Abstain' ELSE CAST(rank AS text) END AS rank
, user_id
, username
, ROW_NUMBER() OVER (PARTITION BY poll_option_id ORDER BY pv.created_at) AS row
FROM poll_votes pv
JOIN poll_options po ON pv.poll_option_id = po.id
JOIN users u ON pv.user_id = u.id
WHERE pv.poll_id = :poll_id
AND po.poll_id = :poll_id
) v
WHERE row BETWEEN :offset AND :offset_plus_limit
SQL
ORDER BY digest, CASE WHEN rank = 'Abstain' THEN 1 ELSE CAST(rank AS integer) END, username
SQL
else
votes = DB.query(<<~SQL, params)
SELECT digest, user_id
FROM (
SELECT digest
, user_id
, ROW_NUMBER() OVER (PARTITION BY poll_option_id ORDER BY pv.created_at) AS row
FROM poll_votes pv
JOIN poll_options po ON pv.poll_option_id = po.id
WHERE pv.poll_id = :poll_id
AND po.poll_id = :poll_id
) v
WHERE row BETWEEN :offset AND :offset_plus_limit
SQL
end
user_ids = votes.map(&:user_id).uniq
@ -205,8 +331,13 @@ class DiscoursePoll::Poll
result = {}
votes.each do |v|
result[v.digest] ||= []
result[v.digest] << user_hashes[v.user_id]
if poll.ranked_choice?
result[v.digest] ||= []
result[v.digest] << { rank: v.rank, user: user_hashes[v.user_id] }
else
result[v.digest] ||= []
result[v.digest] << user_hashes[v.user_id]
end
end
end
@ -388,6 +519,16 @@ class DiscoursePoll::Poll
elsif poll.max && (num_of_options > poll.max)
raise DiscoursePoll::Error.new(I18n.t("poll.max_vote_per_user", count: poll.max))
end
elsif poll.ranked_choice?
if poll.poll_options.length != num_of_options
raise DiscoursePoll::Error.new(
I18n.t(
"poll.ranked_choice.vote_options_mismatch",
count: poll.options.length,
provided: num_of_options,
),
)
end
elsif num_of_options > 1
raise DiscoursePoll::Error.new(I18n.t("poll.one_vote_per_user"))
end

View File

@ -0,0 +1,130 @@
# frozen_string_literal: true
class DiscoursePoll::RankedChoice
MAX_ROUNDS = 50
def self.outcome(poll_id)
options = PollOption.where(poll_id: poll_id).map { |hash| { id: hash.digest, html: hash.html } }
ballot = []
#Fetch all votes for the poll in a single query
votes =
PollVote
.where(poll_id: poll_id)
.select(:user_id, :poll_option_id, :rank)
.order(:user_id, :rank)
.includes(:poll_option) # Eager load poll options
# Group votes by user_id
votes_by_user = votes.group_by(&:user_id)
# Build the ballot
votes_by_user.each do |user_id, user_votes|
ballot_paper =
user_votes.select { |vote| vote.rank > 0 }.map { |vote| vote.poll_option.digest }
ballot << ballot_paper
end
DiscoursePoll::RankedChoice.run(ballot, options) if ballot.length > 0
end
def self.run(starting_votes, options)
current_votes = starting_votes
round_activity = []
potential_winners = []
round = 0
while round < MAX_ROUNDS
round += 1
# Count the first place votes for each candidate
tally = tally_votes(current_votes)
max_votes = tally.values.max
# Find the candidate(s) with the most votes
potential_winners = find_potential_winners(tally, max_votes)
# Check for a majority and return if found
if majority_check(tally, potential_winners, max_votes)
majority_candidate = enrich(potential_winners.keys.first, options)
round_activity << { round: round, majority: majority_candidate, eliminated: nil }
return(
{
tied: false,
tied_candidates: nil,
winner: true,
winning_candidate: majority_candidate,
round_activity: round_activity,
}
)
end
# Find the candidate(s) with the least votes
losers = identify_losers(tally)
# Remove the candidate with the least votes
current_votes.each { |vote| vote.reject! { |candidate| losers.include?(candidate) } }
losers = losers.map { |loser| enrich(loser, options) }
round_activity << { round: round, majority: nil, eliminated: losers }
all_empty = current_votes.all? { |arr| arr.empty? }
if all_empty
return(
{
tied: true,
tied_candidates: losers,
winner: nil,
winning_candidate: nil,
round_activity: round_activity,
}
)
end
end
potential_winners =
potential_winners.keys.map { |potential_winner| enrich(potential_winner, options) }
{
tied: true,
tied_candidates: potential_winners,
winner: nil,
winning_candidate: nil,
round_activity: round_activity,
}
end
private
def self.tally_votes(current_votes)
tally = Hash.new(0)
current_votes.each do |vote|
vote.each { |candidate| tally[candidate] = 0 unless tally.has_key?(candidate) }
end
current_votes.each { |vote| tally[vote.first] += 1 if vote.first }
tally
end
def self.find_potential_winners(tally, max_votes)
tally.select { |k, v| v == max_votes }
end
def self.majority_check(tally, potential_winners, max_votes)
total_votes = tally.values.sum
max_votes > total_votes / 2 || potential_winners.count == 1
end
def self.identify_losers(tally)
min_votes = tally.values.min
tally.select { |k, v| v == min_votes }.keys
end
def self.enrich(digest, options)
{ digest: digest, html: options.find { |option| option[:id] == digest }[:html] }
end
end

View File

@ -40,6 +40,7 @@ after_initialize do
require_relative "app/serializers/poll_serializer"
require_relative "jobs/regular/close_poll"
require_relative "lib/poll"
require_relative "lib/ranked_choice"
require_relative "lib/polls_updater"
require_relative "lib/polls_validator"
require_relative "lib/post_validator"
@ -220,13 +221,22 @@ after_initialize do
) do
preloaded_polls
.map do |poll|
user_poll_votes =
poll
.poll_votes
.where(user_id: scope.user.id)
.joins(:poll_option)
.pluck("poll_options.digest")
if poll.ranked_choice?
user_poll_votes =
poll
.poll_votes
.where(user_id: scope.user.id)
.joins(:poll_option)
.pluck("poll_options.digest", "poll_votes.rank")
.map { |digest, rank| { digest: digest, rank: rank } }
else
user_poll_votes =
poll
.poll_votes
.where(user_id: scope.user.id)
.joins(:poll_option)
.pluck("poll_options.digest")
end
[poll.name, user_poll_votes]
end
.to_h

View File

@ -9,6 +9,8 @@ Fabricator(:poll_regular, from: :poll) { type "regular" }
Fabricator(:poll_multiple, from: :poll) { type "multiple" }
Fabricator(:poll_ranked_choice, from: :poll) { type "ranked_choice" }
Fabricator(:poll_option) do
poll
html { sequence(:html) { |i| "Poll Option #{i}" } }

View File

@ -13,9 +13,21 @@ RSpec.describe "DiscoursePoll endpoints" do
[/poll]
SQL
fab!(:post_with_ranked_choice_poll) { Fabricate(:post, raw: <<~SQL) }
[poll type=ranked_choice public=true]
- Red
- Blue
- Yellow
[/poll]
SQL
let(:option_a) { "5c24fc1df56d764b550ceae1b9319125" }
let(:option_b) { "e89dec30bbd9bf50fabf6a05b4324edf" }
let(:ranked_choice_option_a) { { id: "5c24fc1df56d764b550ceae1b9319125", rank: 2 } }
let(:ranked_choice_option_b) { { id: "e89dec30bbd9bf50fabf6a05b4324edf", rank: 1 } }
let(:ranked_choice_option_c) { { id: "a1a6e2779b52caadb93579c0c3db7c0c", rank: 0 } }
it "should return the right response" do
DiscoursePoll::Poll.vote(user, post.id, DiscoursePoll::DEFAULT_POLL_NAME, [option_a])
@ -63,6 +75,43 @@ RSpec.describe "DiscoursePoll endpoints" do
expect(option.first["username"]).to eq(user.username)
end
it "should return valid response for a ranked choice option" do
ranked_choice_poll = post_with_ranked_choice_poll.polls.first
ranked_choice_poll_options = ranked_choice_poll.poll_options
ranked_choice_votes = {
"0": {
digest: ranked_choice_poll_options.first.digest,
rank: "0",
},
"1": {
digest: ranked_choice_poll_options.second.digest,
rank: "2",
},
"2": {
digest: ranked_choice_poll_options.third.digest,
rank: "1",
},
}
DiscoursePoll::Poll.vote(
user,
post_with_ranked_choice_poll.id,
DiscoursePoll::DEFAULT_POLL_NAME,
ranked_choice_votes,
)
get "/polls/voters.json",
params: {
post_id: post_with_ranked_choice_poll.id,
poll_name: DiscoursePoll::DEFAULT_POLL_NAME,
option_id: ranked_choice_poll_options[1]["digest"],
}
expect(
JSON.parse(response.body)["voters"][ranked_choice_poll_options[1]["digest"]].first["rank"],
).to eq("2")
end
describe "when post_id is blank" do
it "should raise the right error" do
get "/polls/voters.json", params: { poll_name: DiscoursePoll::DEFAULT_POLL_NAME }
@ -141,12 +190,71 @@ RSpec.describe "DiscoursePoll endpoints" do
[/poll]
SQL
fab!(:post_with_ranked_choice_poll) { Fabricate(:post, raw: <<~SQL) }
[poll type=ranked_choice public=true]
- Red
- Blue
- Yellow
[/poll]
SQL
let(:option_a) { "5c24fc1df56d764b550ceae1b9319125" }
let(:option_b) { "e89dec30bbd9bf50fabf6a05b4324edf" }
let(:ranked_choice_vote_a) { { digest: "5c24fc1df56d764b550ceae1b9319125", rank: 2 } }
let(:ranked_choice_vote_b) { { digest: "e89dec30bbd9bf50fabf6a05b4324edf", rank: 1 } }
let(:ranked_choice_vote_c) { { digest: "a1a6e2779b52caadb93579c0c3db7c0c", rank: 0 } }
before do
sign_in(user1)
user_votes = { user_0: option_a, user_1: option_a, user_2: option_b }
ranked_choice_poll = post_with_ranked_choice_poll.polls.first
ranked_choice_poll_options = ranked_choice_poll.poll_options
user_ranked_choice_votes = [
{
"0": {
digest: ranked_choice_poll_options.first.digest,
rank: "0",
},
"1": {
digest: ranked_choice_poll_options.second.digest,
rank: "2",
},
"2": {
digest: ranked_choice_poll_options.third.digest,
rank: "1",
},
},
{
"0": {
digest: ranked_choice_poll_options.first.digest,
rank: "0",
},
"1": {
digest: ranked_choice_poll_options.second.digest,
rank: "2",
},
"2": {
digest: ranked_choice_poll_options.third.digest,
rank: "1",
},
},
{
"0": {
digest: ranked_choice_poll_options.first.digest,
rank: "0",
},
"1": {
digest: ranked_choice_poll_options.second.digest,
rank: "2",
},
"2": {
digest: ranked_choice_poll_options.third.digest,
rank: "1",
},
},
]
[user1, user2, user3].each_with_index do |user, index|
DiscoursePoll::Poll.vote(
@ -155,6 +263,12 @@ RSpec.describe "DiscoursePoll endpoints" do
DiscoursePoll::DEFAULT_POLL_NAME,
[user_votes["user_#{index}".to_sym]],
)
DiscoursePoll::Poll.vote(
user,
post_with_ranked_choice_poll.id,
DiscoursePoll::DEFAULT_POLL_NAME,
user_ranked_choice_votes[index],
)
UserCustomField.create(user_id: user.id, name: "something", value: "value#{index}")
end
@ -206,6 +320,19 @@ RSpec.describe "DiscoursePoll endpoints" do
)
end
it "returns an error when attempting to return group results for ranked choice type poll" do
SiteSetting.poll_groupable_user_fields = "something"
get "/polls/grouped_poll_results.json",
params: {
post_id: post_with_ranked_choice_poll.id,
poll_name: DiscoursePoll::DEFAULT_POLL_NAME,
user_field_name: "something",
}
expect(response.status).to eq(422)
expect(response.body).to include("ranked_choice")
end
it "returns an error when poll_groupable_user_fields is empty" do
SiteSetting.poll_groupable_user_fields = ""
get "/polls/grouped_poll_results.json",

View File

@ -13,6 +13,17 @@ RSpec.describe UserMerger do
fab!(:poll_multiple_optionB) { Fabricate(:poll_option, poll: poll_multiple, html: "Option B") }
fab!(:poll_multiple_optionC) { Fabricate(:poll_option, poll: poll_multiple, html: "Option C") }
fab!(:poll_ranked_choice) { Fabricate(:poll) }
fab!(:poll_ranked_choice_optionA) do
Fabricate(:poll_option, poll: poll_ranked_choice, html: "Option A")
end
fab!(:poll_ranked_choice_optionB) do
Fabricate(:poll_option, poll: poll_ranked_choice, html: "Option B")
end
fab!(:poll_ranked_choice_optionC) do
Fabricate(:poll_option, poll: poll_ranked_choice, html: "Option C")
end
it "will end up with no votes from source user" do
Fabricate(:poll_vote, poll: poll_regular, user: source_user, poll_option: poll_regular_option2)
Fabricate(
@ -58,6 +69,22 @@ RSpec.describe UserMerger do
)
end
it "will use source user's vote if poll was the ranked choice type" do
Fabricate(
:poll_vote,
poll: poll_ranked_choice,
user: source_user,
poll_option: poll_ranked_choice_optionA,
rank: 2,
)
DiscourseEvent.trigger(:merging_users, source_user, target_user)
expect(PollVote.where(user: target_user).pluck(:poll_option_id)).to contain_exactly(
poll_ranked_choice_optionA.id,
)
end
it "reassigns source_user vote to target_user if target user has never voted in the poll" do
Fabricate(:poll_vote, poll: poll_regular, user: source_user)

View File

@ -21,6 +21,14 @@ RSpec.describe DiscoursePoll::Poll do
[/poll]
RAW
fab!(:post_with_ranked_choice_poll) { Fabricate(:post, raw: <<~RAW) }
[poll type=ranked_choice public=true]
* Red
* Blue
* Yellow
[/poll]
RAW
describe ".vote" do
it "should only allow one vote per user for a regular poll" do
poll = post_with_regular_poll.polls.first
@ -159,6 +167,85 @@ RSpec.describe DiscoursePoll::Poll do
poll.poll_options.second.id,
)
end
it "allows user to vote on options correctly for a ranked choice poll and to vote again" do
poll = post_with_ranked_choice_poll.polls.first
poll_options = poll.poll_options
DiscoursePoll::Poll.vote(
user,
post_with_ranked_choice_poll.id,
"poll",
{
"0": {
digest: poll_options.first.digest,
rank: "2",
},
"1": {
digest: poll_options.second.digest,
rank: "1",
},
"2": {
digest: poll_options.third.digest,
rank: "0",
},
},
)
DiscoursePoll::Poll.vote(
user_2,
post_with_ranked_choice_poll.id,
"poll",
{
"0": {
digest: poll_options.first.digest,
rank: "0",
},
"1": {
digest: poll_options.second.digest,
rank: "2",
},
"2": {
digest: poll_options.third.digest,
rank: "1",
},
},
)
DiscoursePoll::Poll.vote(
user,
post_with_ranked_choice_poll.id,
"poll",
{
"0": {
digest: poll_options.first.digest,
rank: "1",
},
"1": {
digest: poll_options.second.digest,
rank: "2",
},
"2": {
digest: poll_options.third.digest,
rank: "0",
},
},
)
expect(PollVote.count).to eq(6)
expect(PollVote.where(poll: poll, user: user).pluck(:poll_option_id)).to contain_exactly(
poll_options.first.id,
poll_options.second.id,
poll_options.third.id,
)
expect(PollVote.where(poll: poll, user: user_2).pluck(:poll_option_id)).to contain_exactly(
poll_options.first.id,
poll_options.second.id,
poll_options.third.id,
)
end
end
describe "post_created" do

View File

@ -0,0 +1,69 @@
# frozen_string_literal: true
RSpec.describe DiscoursePoll::RankedChoice do
let(:options_1) { [{ id: "Alice", html: "Alice" }, { id: "Bob", html: "Bob" }] }
let(:options_2) do
[{ id: "Alice", html: "Alice" }, { id: "Bob", html: "Bob" }, { id: "Charlie", html: "Charlie" }]
end
let(:options_3) do
[
{ id: "Alice", html: "Alice" },
{ id: "Bob", html: "Bob" },
{ id: "Charlie", html: "Charlie" },
{ id: "Dave", html: "Dave" },
]
end
it "correctly finds the winner with a simple majority" do
votes = [%w[Alice Bob], %w[Bob Alice], %w[Alice Bob], %w[Bob Alice], %w[Alice Bob]]
expect(described_class.run(votes, options_1)[:winning_candidate]).to eq(
{ digest: "Alice", html: "Alice" },
)
end
it "correctly finds the winner after one elimination" do
votes = [
%w[Alice Bob Charlie],
%w[Bob Charlie Alice],
%w[Charlie Alice Bob],
%w[Charlie Alice Bob],
%w[Bob Charlie Alice],
]
expect(described_class.run(votes, options_2)[:winning_candidate]).to eq(
{ digest: "Bob", html: "Bob" },
)
end
it "handles a tie" do
votes = [
%w[Alice Bob Charlie Dave],
%w[Bob Charlie Dave Alice],
%w[Charlie Dave Alice Bob],
%w[Dave Alice Bob Charlie],
%w[Bob Dave Charlie Alice],
%w[Dave Charlie Bob Alice],
]
expect(described_class.run(votes, options_3)[:tied_candidates]).to eq(
[{ digest: "Bob", html: "Bob" }, { digest: "Dave", html: "Dave" }],
)
end
it "handles multiple rounds of elimination and tracks round activity" do
votes = [
%w[Alice Bob Charlie Dave],
%w[Bob Charlie Dave Alice],
%w[Charlie Dave Alice Bob],
%w[Dave Alice Bob Charlie],
%w[Bob Dave Charlie Alice],
%w[Dave Charlie Bob Alice],
]
expect(described_class.run(votes, options_3)[:round_activity].length).to eq(2)
end
it "handles the winner with a simple majority" do
votes = [%w[Dave Alice], %w[Bob Dave]]
expect(described_class.run(votes, options_3)[:tied_candidates]).to eq(
[{ digest: "Dave", html: "Dave" }, { digest: "Bob", html: "Bob" }],
)
end
end

View File

@ -31,12 +31,16 @@ module("Poll | Component | poll-options", function (hooks) {
test("single, not selected", async function (assert) {
this.setProperties({
isCheckbox: false,
isRankedChoice: false,
rankedChoiceDropdownContent: [],
options: OPTIONS,
votes: [],
});
await render(hbs`<PollOptions
@isCheckbox={{this.isCheckbox}}
@isRankedChoice={{this.isRankedChoice}}
@ranked_choice_dropdown_content={{this.ranked_choice_dropdown_content}}
@options={{this.options}}
@votes={{this.votes}}
@sendRadioClick={{this.toggleOption}}
@ -48,12 +52,16 @@ module("Poll | Component | poll-options", function (hooks) {
test("single, selected", async function (assert) {
this.setProperties({
isCheckbox: false,
isRankedChoice: false,
rankedChoiceDropdownContent: [],
options: OPTIONS,
votes: ["6c986ebcde3d5822a6e91a695c388094"],
});
await render(hbs`<PollOptions
@isCheckbox={{this.isCheckbox}}
@isRankedChoice={{this.isRankedChoice}}
@ranked_choice_dropdown_content={{this.ranked_choice_dropdown_content}}
@options={{this.options}}
@votes={{this.votes}}
@sendRadioClick={{this.toggleOption}}
@ -65,12 +73,16 @@ module("Poll | Component | poll-options", function (hooks) {
test("multi, not selected", async function (assert) {
this.setProperties({
isCheckbox: true,
isRankedChoice: false,
rankedChoiceDropdownContent: [],
options: OPTIONS,
votes: [],
});
await render(hbs`<PollOptions
@isCheckbox={{this.isCheckbox}}
@isRankedChoice={{this.isRankedChoice}}
@ranked_choice_dropdown_content={{this.ranked_choice_dropdown_content}}
@options={{this.options}}
@votes={{this.votes}}
@sendRadioClick={{this.toggleOption}}
@ -82,12 +94,16 @@ module("Poll | Component | poll-options", function (hooks) {
test("multi, selected", async function (assert) {
this.setProperties({
isCheckbox: true,
isRankedChoice: false,
rankedChoiceDropdownContent: [],
options: OPTIONS,
votes: ["6c986ebcde3d5822a6e91a695c388094"],
});
await render(hbs`<PollOptions
@isCheckbox={{this.isCheckbox}}
@isRankedChoice={{this.isRankedChoice}}
@ranked_choice_dropdown_content={{this.ranked_choice_dropdown_content}}
@options={{this.options}}
@votes={{this.votes}}
@sendRadioClick={{this.toggleOption}}

View File

@ -0,0 +1,61 @@
import { render } from "@ember/test-helpers";
import hbs from "htmlbars-inline-precompile";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { count, query } from "discourse/tests/helpers/qunit-helpers";
import I18n from "I18n";
const RANKED_CHOICE_OUTCOME = {
tied: false,
tied_candidates: null,
winner: true,
winning_candidate: {
digest: "c8678f4ce846ad5415278ff7ecadf3a6",
html: "Team Blue",
},
round_activity: [
{
round: 1,
eliminated: [
{ digest: "8bbb100d504298ad65a2604e99d5ba82", html: "Team Yellow" },
],
majority: null,
},
{
round: 2,
majority: [
{ digest: "c8678f4ce846ad5415278ff7ecadf3a6", html: "Team Blue" },
],
eliminated: null,
},
],
};
module("Poll | Component | poll-results-ranked-choice", function (hooks) {
setupRenderingTest(hooks);
test("Renders the ranked choice results component correctly", async function (assert) {
this.setProperties({
rankedChoiceOutcome: RANKED_CHOICE_OUTCOME,
});
await render(
hbs`<PollResultsRankedChoice @rankedChoiceOutcome={{this.rankedChoiceOutcome}} />`
);
assert.strictEqual(
count("table.poll-results-ranked-choice tr"),
3,
"there are two rounds of ranked choice"
);
assert.strictEqual(
query("span.poll-results-ranked-choice-info").textContent.trim(),
I18n.t("poll.ranked_choice.winner", {
count: this.rankedChoiceOutcome.round_activity.length,
winner: this.rankedChoiceOutcome.winning_candidate.html,
}),
"displays the winner information"
);
});
});

View File

@ -0,0 +1,155 @@
import { render } from "@ember/test-helpers";
import hbs from "htmlbars-inline-precompile";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { count } from "discourse/tests/helpers/qunit-helpers";
const TWO_OPTIONS = [
{
id: "1ddc47be0d2315b9711ee8526ca9d83f",
html: "Team Yellow",
votes: 5,
rank: 2,
},
{
id: "70e743697dac09483d7b824eaadb91e1",
html: "Team Blue",
votes: 4,
rank: 1,
},
];
const RANKED_CHOICE_OUTCOME = {
tied: false,
tied_candidates: null,
winner: true,
winning_candidate: {
digest: "70e743697dac09483d7b824eaadb91e1",
html: "Team Blue",
},
round_activity: [
{
round: 1,
eliminated: [
{ digest: "1ddc47be0d2315b9711ee8526ca9d83f", html: "Team Yellow" },
],
majority: null,
},
{
round: 2,
majority: [
{ digest: "70e743697dac09483d7b824eaadb91e1", html: "Team Blue" },
],
eliminated: null,
},
],
};
const PRELOADEDVOTERS = {
db753fe0bc4e72869ac1ad8765341764: [
{
id: 1,
username: "bianca",
name: null,
avatar_template: "/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png",
},
],
};
module("Poll | Component | poll-results-tabs", function (hooks) {
setupRenderingTest(hooks);
test("Renders one tab for non-ranked-choice poll", async function (assert) {
this.setProperties({
options: TWO_OPTIONS,
pollName: "Two Choice Poll",
pollType: "single",
isPublic: true,
isRankedChoice: false,
postId: 123,
vote: ["1ddc47be0d2315b9711ee8526ca9d83f"],
voters: PRELOADEDVOTERS,
votersCount: 9,
fetchVoters: () => {},
});
await render(hbs`<PollResultsTabs
@options={{this.options}}
@pollName={{this.pollName}}
@pollType={{this.pollType}}
@isPublic={{this.isPublic}}
@isRankedChoice={{this.isRankedChoice}}
@postId={{this.postId}}
@vote={{this.vote}}
@voters={{this.voters}}
@votersCount={{this.votersCount}}
@fetchVoters={{this.fetchVoters}}
/>`);
assert.strictEqual(count("li.tab"), 1);
});
test("Renders two tabs for public ranked choice poll", async function (assert) {
this.setProperties({
options: TWO_OPTIONS,
pollName: "Two Choice Poll",
pollType: "ranked_choice",
isPublic: true,
isRankedChoice: true,
rankedChoiceOutcome: RANKED_CHOICE_OUTCOME,
postId: 123,
vote: ["1ddc47be0d2315b9711ee8526ca9d83f"],
voters: PRELOADEDVOTERS,
votersCount: 9,
fetchVoters: () => {},
});
await render(hbs`<PollResultsTabs
@options={{this.options}}
@pollName={{this.pollName}}
@pollType={{this.pollType}}
@isPublic={{this.isPublic}}
@isRankedChoice={{this.isRankedChoice}}
@rankedChoiceOutcome={{this.rankedChoiceOutcome}}
@postId={{this.postId}}
@vote={{this.vote}}
@voters={{this.voters}}
@votersCount={{this.votersCount}}
@fetchVoters={{this.fetchVoters}}
/>`);
assert.strictEqual(count("li.tab"), 2);
});
test("Renders one tab for private ranked choice poll", async function (assert) {
this.setProperties({
options: TWO_OPTIONS,
pollName: "Two Choice Poll",
pollType: "ranked_choice",
isPublic: false,
isRankedChoice: true,
rankedChoiceOutcome: RANKED_CHOICE_OUTCOME,
postId: 123,
vote: ["1ddc47be0d2315b9711ee8526ca9d83f"],
voters: PRELOADEDVOTERS,
votersCount: 9,
fetchVoters: () => {},
});
await render(hbs`<PollResultsTabs
@options={{this.options}}
@pollName={{this.pollName}}
@pollType={{this.pollType}}
@isPublic={{this.isPublic}}
@isRankedChoice={{this.isRankedChoice}}
@rankedChoiceOutcome={{this.rankedChoiceOutcome}}
@postId={{this.postId}}
@vote={{this.vote}}
@voters={{this.voters}}
@votersCount={{this.votersCount}}
@fetchVoters={{this.fetchVoters}}
/>`);
assert.strictEqual(count("li.tab"), 1);
});
});