mirror of
https://github.com/discourse/discourse.git
synced 2024-11-22 19:46:55 +08:00
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:
parent
ef27ee9fb6
commit
bae492efee
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
}
|
|
@ -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}}
|
||||
|
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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}}>
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -50,6 +50,9 @@
|
|||
}
|
||||
|
||||
.poll-type {
|
||||
.poll-type-value {
|
||||
font-size: var(--font-down-1);
|
||||
}
|
||||
padding: 0;
|
||||
margin-top: 0;
|
||||
border: none;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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...
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
130
plugins/poll/lib/ranked_choice.rb
Normal file
130
plugins/poll/lib/ranked_choice.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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}" } }
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
69
plugins/poll/spec/lib/ranked_choice_spec.rb
Normal file
69
plugins/poll/spec/lib/ranked_choice_spec.rb
Normal 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
|
|
@ -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}}
|
||||
|
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user