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)
|
poll_name = params.require(:poll_name)
|
||||||
user_field_name = params.require(:user_field_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: {
|
render json: {
|
||||||
grouped_results:
|
error: I18n.t("poll.ranked_choice.no_group_results_support"),
|
||||||
DiscoursePoll::Poll.grouped_poll_results(
|
},
|
||||||
current_user,
|
status: :unprocessable_entity
|
||||||
post_id,
|
else
|
||||||
poll_name,
|
begin
|
||||||
user_field_name,
|
render json: {
|
||||||
),
|
grouped_results:
|
||||||
}
|
DiscoursePoll::Poll.grouped_poll_results(
|
||||||
rescue DiscoursePoll::Error => e
|
current_user,
|
||||||
render_json_error e.message
|
post_id,
|
||||||
|
poll_name,
|
||||||
|
user_field_name,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
rescue DiscoursePoll::Error => e
|
||||||
|
render_json_error e.message
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -9,7 +9,7 @@ class Poll < ActiveRecord::Base
|
||||||
has_many :poll_options, -> { order(:id) }, dependent: :destroy
|
has_many :poll_options, -> { order(:id) }, dependent: :destroy
|
||||||
has_many :poll_votes
|
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
|
enum status: { open: 0, closed: 1 }, _scopes: false
|
||||||
|
|
||||||
|
@ -43,6 +43,10 @@ class Poll < ActiveRecord::Base
|
||||||
def can_see_voters?(user)
|
def can_see_voters?(user)
|
||||||
everyone? && can_see_results?(user)
|
everyone? && can_see_results?(user)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def ranked_choice?
|
||||||
|
type == "ranked_choice"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# == Schema Information
|
# == Schema Information
|
||||||
|
|
|
@ -16,7 +16,8 @@ class PollSerializer < ApplicationSerializer
|
||||||
:preloaded_voters,
|
:preloaded_voters,
|
||||||
:chart_type,
|
:chart_type,
|
||||||
:groups,
|
:groups,
|
||||||
:title
|
:title,
|
||||||
|
:ranked_choice_outcome
|
||||||
|
|
||||||
def public
|
def public
|
||||||
true
|
true
|
||||||
|
@ -75,4 +76,12 @@ class PollSerializer < ApplicationSerializer
|
||||||
def include_preloaded_voters?
|
def include_preloaded_voters?
|
||||||
object.can_see_voters?(scope.user)
|
object.can_see_voters?(scope.user)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def include_ranked_choice_outcome?
|
||||||
|
object.ranked_choice?
|
||||||
|
end
|
||||||
|
|
||||||
|
def ranked_choice_outcome
|
||||||
|
DiscoursePoll::RankedChoice.outcome(object.id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,35 +7,51 @@
|
||||||
<:body>
|
<:body>
|
||||||
<ul class="nav nav-pills poll-type">
|
<ul class="nav nav-pills poll-type">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<DButton
|
||||||
href
|
@action={{fn this.updatePollType "regular"}}
|
||||||
{{on "click" (fn this.updatePollType "regular")}}
|
class={{concatClass
|
||||||
class="poll-type-value poll-type-value-regular
|
"poll-type-value poll-type-value-regular"
|
||||||
{{if this.isRegular 'active'}}"
|
(if this.isRegular "active")
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{{i18n "poll.ui_builder.poll_type.regular"}}
|
{{i18n "poll.ui_builder.poll_type.regular"}}
|
||||||
</a>
|
</DButton>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<DButton
|
||||||
href
|
@action={{fn this.updatePollType "multiple"}}
|
||||||
{{on "click" (fn this.updatePollType "multiple")}}
|
class={{concatClass
|
||||||
class="poll-type-value poll-type-value-multiple
|
"poll-type-value poll-type-value-multiple"
|
||||||
{{if this.isMultiple 'active'}}"
|
(if this.isMultiple "active")
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{{i18n "poll.ui_builder.poll_type.multiple"}}
|
{{i18n "poll.ui_builder.poll_type.multiple"}}
|
||||||
</a>
|
</DButton>
|
||||||
</li>
|
</li>
|
||||||
{{#if this.showNumber}}
|
{{#if this.showNumber}}
|
||||||
<li>
|
<li>
|
||||||
<a
|
<DButton
|
||||||
href
|
@action={{fn this.updatePollType "number"}}
|
||||||
{{on "click" (fn this.updatePollType "number")}}
|
class={{concatClass
|
||||||
class="poll-type-value poll-type-value-number
|
"poll-type-value poll-type-value-number"
|
||||||
{{if this.isNumber 'active'}}"
|
(if this.isNumber "active")
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{{i18n "poll.ui_builder.poll_type.number"}}
|
{{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>
|
</li>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -103,7 +119,7 @@
|
||||||
</div>
|
</div>
|
||||||
{{/unless}}
|
{{/unless}}
|
||||||
|
|
||||||
{{#unless this.isRegular}}
|
{{#unless this.rankedChoiceOrRegular}}
|
||||||
<div class="options">
|
<div class="options">
|
||||||
<div class="input-group poll-number">
|
<div class="input-group poll-number">
|
||||||
<label class="input-group-label">{{i18n
|
<label class="input-group-label">{{i18n
|
||||||
|
@ -197,7 +213,7 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{#unless this.isNumber}}
|
{{#unless this.rankedChoiceOrNumber}}
|
||||||
<div class="input-group poll-select column">
|
<div class="input-group poll-select column">
|
||||||
<label class="input-group-label">{{i18n
|
<label class="input-group-label">{{i18n
|
||||||
"poll.ui_builder.poll_chart_type.label"
|
"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 REGULAR_POLL_TYPE = "regular";
|
||||||
export const NUMBER_POLL_TYPE = "number";
|
export const NUMBER_POLL_TYPE = "number";
|
||||||
export const MULTIPLE_POLL_TYPE = "multiple";
|
export const MULTIPLE_POLL_TYPE = "multiple";
|
||||||
|
export const RANKED_CHOICE_POLL_TYPE = "ranked_choice";
|
||||||
|
|
||||||
const ALWAYS_POLL_RESULT = "always";
|
const ALWAYS_POLL_RESULT = "always";
|
||||||
const VOTE_POLL_RESULT = "on_vote";
|
const VOTE_POLL_RESULT = "on_vote";
|
||||||
|
@ -36,7 +37,10 @@ export default class PollUiBuilderModal extends Component {
|
||||||
publicPoll = this.siteSettings.poll_default_public;
|
publicPoll = this.siteSettings.poll_default_public;
|
||||||
|
|
||||||
@or("showAdvanced", "isNumber") showNumber;
|
@or("showAdvanced", "isNumber") showNumber;
|
||||||
|
@or("showAdvanced", "isRankedChoice") showRankedChoice;
|
||||||
@gt("pollOptions.length", 1) canRemoveOption;
|
@gt("pollOptions.length", 1) canRemoveOption;
|
||||||
|
@or("isRankedChoice", "isRegular") rankedChoiceOrRegular;
|
||||||
|
@or("isRankedChoice", "isNumber") rankedChoiceOrNumber;
|
||||||
|
|
||||||
@discourseComputed("currentUser.staff")
|
@discourseComputed("currentUser.staff")
|
||||||
pollResults(staff) {
|
pollResults(staff) {
|
||||||
|
@ -80,6 +84,11 @@ export default class PollUiBuilderModal extends Component {
|
||||||
return pollType === MULTIPLE_POLL_TYPE;
|
return pollType === MULTIPLE_POLL_TYPE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discourseComputed("pollType")
|
||||||
|
isRankedChoice(pollType) {
|
||||||
|
return pollType === RANKED_CHOICE_POLL_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
@discourseComputed("pollOptions.@each.value")
|
@discourseComputed("pollOptions.@each.value")
|
||||||
pollOptionsCount(pollOptions) {
|
pollOptionsCount(pollOptions) {
|
||||||
return (pollOptions || []).filter((option) => option.value.length > 0)
|
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 isAdmin = this.currentUser && this.currentUser.admin;
|
||||||
|
|
||||||
const dataExplorerEnabled = this.siteSettings.data_explorer_enabled;
|
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 {
|
const {
|
||||||
closed,
|
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 { action } from "@ember/object";
|
||||||
import { inject as service } from "@ember/service";
|
import { inject as service } from "@ember/service";
|
||||||
import { htmlSafe } from "@ember/template";
|
import { htmlSafe } from "@ember/template";
|
||||||
|
import concatClass from "discourse/helpers/concat-class";
|
||||||
import routeAction from "discourse/helpers/route-action";
|
import routeAction from "discourse/helpers/route-action";
|
||||||
import icon from "discourse-common/helpers/d-icon";
|
import icon from "discourse-common/helpers/d-icon";
|
||||||
|
import PollOptionRankedChoice from "./poll-option-ranked-choice";
|
||||||
|
|
||||||
export default class PollOptionsComponent extends Component {
|
export default class PollOptionsComponent extends Component {
|
||||||
@service currentUser;
|
@service currentUser;
|
||||||
|
@ -19,46 +21,58 @@ export default class PollOptionsComponent extends Component {
|
||||||
this.args.sendOptionSelect(option);
|
this.args.sendOptionSelect(option);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
sendRank(option, rank = 0) {
|
||||||
|
this.args.sendOptionSelect(option, rank);
|
||||||
|
}
|
||||||
<template>
|
<template>
|
||||||
<ul>
|
<ul class={{concatClass (if @isRankedChoice "ranked-choice-poll-options")}}>
|
||||||
{{#each @options as |option|}}
|
{{#each @options as |option|}}
|
||||||
<li tabindex="0" data-poll-option-id={{option.id}}>
|
{{#if @isRankedChoice}}
|
||||||
{{#if this.currentUser}}
|
<PollOptionRankedChoice
|
||||||
<button {{on "click" (fn this.sendClick option)}}>
|
@option={{option}}
|
||||||
{{#if (this.isChosen option)}}
|
@rankedChoiceDropdownContent={{@rankedChoiceDropdownContent}}
|
||||||
{{#if @isCheckbox}}
|
@sendRank={{this.sendRank}}
|
||||||
{{icon "far-check-square"}}
|
/>
|
||||||
|
{{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}}
|
{{else}}
|
||||||
{{icon "circle"}}
|
{{#if @isCheckbox}}
|
||||||
|
{{icon "far-square"}}
|
||||||
|
{{else}}
|
||||||
|
{{icon "far-circle"}}
|
||||||
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{else}}
|
<span class="option-text">{{htmlSafe option.html}}</span>
|
||||||
{{#if @isCheckbox}}
|
</button>
|
||||||
{{icon "far-square"}}
|
{{else}}
|
||||||
|
<button onclick={{routeAction "showLogin"}}>
|
||||||
|
{{#if (this.isChosen option)}}
|
||||||
|
{{#if @isCheckbox}}
|
||||||
|
{{icon "far-check-square"}}
|
||||||
|
{{else}}
|
||||||
|
{{icon "circle"}}
|
||||||
|
{{/if}}
|
||||||
{{else}}
|
{{else}}
|
||||||
{{icon "far-circle"}}
|
{{#if @isCheckbox}}
|
||||||
|
{{icon "far-square"}}
|
||||||
|
{{else}}
|
||||||
|
{{icon "far-circle"}}
|
||||||
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{/if}}
|
<span class="option-text">{{htmlSafe option.html}}</span>
|
||||||
<span class="option-text">{{htmlSafe option.html}}</span>
|
</button>
|
||||||
</button>
|
{{/if}}
|
||||||
{{else}}
|
</li>
|
||||||
<button onclick={{routeAction "showLogin"}}>
|
{{/if}}
|
||||||
{{#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>
|
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</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" ""}}>
|
<li class={{if option.chosen "chosen" ""}}>
|
||||||
<div class="option">
|
<div class="option">
|
||||||
<p>
|
<p>
|
||||||
<span class="percentage">{{i18n
|
{{#unless @isRankedChoice}}
|
||||||
"number.percent"
|
<span class="percentage">{{i18n
|
||||||
count=option.percentage
|
"number.percent"
|
||||||
}}</span>
|
count=option.percentage
|
||||||
|
}}</span>
|
||||||
|
{{/unless}}
|
||||||
<span class="option-text">{{htmlSafe option.html}}</span>
|
<span class="option-text">{{htmlSafe option.html}}</span>
|
||||||
</p>
|
</p>
|
||||||
<div class="bar-back">
|
{{#unless @isRankedChoice}}
|
||||||
<div
|
<div class="bar-back">
|
||||||
class="bar"
|
<div
|
||||||
style={{htmlSafe (concat "width:" option.percentage "%")}}
|
class="bar"
|
||||||
/>
|
style={{htmlSafe (concat "width:" option.percentage "%")}}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
{{/unless}}
|
||||||
{{#if @isPublic}}
|
{{#if @isPublic}}
|
||||||
<PollVoters
|
<PollVoters
|
||||||
@postId={{@postId}}
|
@postId={{@postId}}
|
||||||
@pollType={{@pollType}}
|
@pollType={{@pollType}}
|
||||||
@optionId={{option.id}}
|
@optionId={{option.id}}
|
||||||
@pollName={{@pollName}}
|
@pollName={{@pollName}}
|
||||||
|
@isRankedChoice={{@isRankedChoice}}
|
||||||
@totalVotes={{option.votes}}
|
@totalVotes={{option.votes}}
|
||||||
@voters={{option.voters}}
|
@voters={{option.voters}}
|
||||||
@fetchVoters={{@fetchVoters}}
|
@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 ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
|
||||||
import DButton from "discourse/components/d-button";
|
import DButton from "discourse/components/d-button";
|
||||||
import avatar from "discourse/helpers/bound-avatar-template";
|
import avatar from "discourse/helpers/bound-avatar-template";
|
||||||
|
import PollVotersRankedChoice from "./poll-voters-ranked-choice";
|
||||||
|
|
||||||
export default class PollVotersComponent extends Component {
|
export default class PollVotersComponent extends Component {
|
||||||
get showMore() {
|
get showMore() {
|
||||||
|
@ -12,11 +13,15 @@ export default class PollVotersComponent extends Component {
|
||||||
<template>
|
<template>
|
||||||
<div class="poll-voters">
|
<div class="poll-voters">
|
||||||
<ul class="poll-voters-list">
|
<ul class="poll-voters-list">
|
||||||
{{#each @voters as |user|}}
|
{{#if @isRankedChoice}}
|
||||||
<li>
|
<PollVotersRankedChoice @voters={{@voters}} />
|
||||||
{{avatar user.avatar_template "tiny"}}
|
{{else}}
|
||||||
</li>
|
{{#each @voters as |user|}}
|
||||||
{{/each}}
|
<li>
|
||||||
|
{{avatar user.avatar_template "tiny"}}
|
||||||
|
</li>
|
||||||
|
{{/each}}
|
||||||
|
{{/if}}
|
||||||
</ul>
|
</ul>
|
||||||
{{#if this.showMore}}
|
{{#if this.showMore}}
|
||||||
<ConditionalLoadingSpinner @condition={{@loading}}>
|
<ConditionalLoadingSpinner @condition={{@loading}}>
|
||||||
|
|
|
@ -17,13 +17,14 @@ import PollButtonsDropdown from "../components/poll-buttons-dropdown";
|
||||||
import PollInfo from "../components/poll-info";
|
import PollInfo from "../components/poll-info";
|
||||||
import PollOptions from "../components/poll-options";
|
import PollOptions from "../components/poll-options";
|
||||||
import PollResultsPie from "../components/poll-results-pie";
|
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 FETCH_VOTERS_COUNT = 25;
|
||||||
const STAFF_ONLY = "staff_only";
|
const STAFF_ONLY = "staff_only";
|
||||||
const MULTIPLE = "multiple";
|
const MULTIPLE = "multiple";
|
||||||
const NUMBER = "number";
|
const NUMBER = "number";
|
||||||
const REGULAR = "regular";
|
const REGULAR = "regular";
|
||||||
|
const RANKED_CHOICE = "ranked_choice";
|
||||||
const ON_VOTE = "on_vote";
|
const ON_VOTE = "on_vote";
|
||||||
const ON_CLOSE = "on_close";
|
const ON_CLOSE = "on_close";
|
||||||
|
|
||||||
|
@ -42,6 +43,9 @@ export default class PollComponent extends Component {
|
||||||
@tracked poll = this.args.attrs.poll;
|
@tracked poll = this.args.attrs.poll;
|
||||||
@tracked voters = this.poll.voters || 0;
|
@tracked voters = this.poll.voters || 0;
|
||||||
@tracked preloadedVoters = this.args.preloadedVoters || [];
|
@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 staffOnly = this.poll.results === STAFF_ONLY;
|
||||||
@tracked isMultiple = this.poll.type === MULTIPLE;
|
@tracked isMultiple = this.poll.type === MULTIPLE;
|
||||||
@tracked isNumber = this.poll.type === NUMBER;
|
@tracked isNumber = this.poll.type === NUMBER;
|
||||||
|
@ -90,6 +94,7 @@ export default class PollComponent extends Component {
|
||||||
.then(({ poll }) => {
|
.then(({ poll }) => {
|
||||||
this.options = [...poll.options];
|
this.options = [...poll.options];
|
||||||
this.hasSavedVote = true;
|
this.hasSavedVote = true;
|
||||||
|
this.rankedChoiceOutcome = poll.ranked_choice_outcome || [];
|
||||||
this.poll.setProperties(poll);
|
this.poll.setProperties(poll);
|
||||||
this.appEvents.trigger(
|
this.appEvents.trigger(
|
||||||
"poll:voted",
|
"poll:voted",
|
||||||
|
@ -114,7 +119,7 @@ export default class PollComponent extends Component {
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
if (!this.isMultiple) {
|
if (!this.isMultiple && !this.isRankedChoice) {
|
||||||
this._toggleOption(option);
|
this._toggleOption(option);
|
||||||
}
|
}
|
||||||
popupAjaxError(error);
|
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 options = this.options;
|
||||||
let vote = this.vote;
|
let vote = this.vote;
|
||||||
|
|
||||||
|
@ -135,6 +158,24 @@ export default class PollComponent extends Component {
|
||||||
} else {
|
} else {
|
||||||
vote.push(option.id);
|
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 {
|
} else {
|
||||||
vote = [option.id];
|
vote = [option.id];
|
||||||
}
|
}
|
||||||
|
@ -148,6 +189,29 @@ export default class PollComponent extends Component {
|
||||||
this.post = this.args.attrs.post;
|
this.post = this.args.attrs.post;
|
||||||
this.options = this.poll.options;
|
this.options = this.poll.options;
|
||||||
this.groupableUserFields = this.args.attrs.groupableUserFields;
|
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() {
|
get min() {
|
||||||
|
@ -189,7 +253,7 @@ export default class PollComponent extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
toggleOption(option) {
|
toggleOption(option, rank = 0) {
|
||||||
if (this.closed) {
|
if (this.closed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -203,19 +267,20 @@ export default class PollComponent extends Component {
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!this.isMultiple &&
|
!this.isMultiple &&
|
||||||
|
!this.isRankedChoice &&
|
||||||
this.vote.length === 1 &&
|
this.vote.length === 1 &&
|
||||||
this.vote[0] === option.id
|
this.vote[0] === option.id
|
||||||
) {
|
) {
|
||||||
return this.removeVote();
|
return this.removeVote();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.isMultiple) {
|
if (!this.isMultiple && !this.isRankedChoice) {
|
||||||
this.vote.length = 0;
|
this.vote.length = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._toggleOption(option);
|
this._toggleOption(option, rank);
|
||||||
|
|
||||||
if (!this.isMultiple) {
|
if (!this.isMultiple && !this.isRankedChoice) {
|
||||||
this.castVotes(option);
|
this.castVotes(option);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -237,6 +302,13 @@ export default class PollComponent extends Component {
|
||||||
return selectedOptionCount >= this.min && selectedOptionCount <= this.max;
|
return selectedOptionCount >= this.min && selectedOptionCount <= this.max;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isRankedChoice) {
|
||||||
|
return (
|
||||||
|
this.options.length === this.vote.length &&
|
||||||
|
this.areRanksValid(this.vote)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return selectedOptionCount > 0;
|
return selectedOptionCount > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -249,7 +321,7 @@ export default class PollComponent extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
get showCastVotesButton() {
|
get showCastVotesButton() {
|
||||||
return this.isMultiple && !this.showResults;
|
return (this.isMultiple || this.isRankedChoice) && !this.showResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
get castVotesButtonClass() {
|
get castVotesButtonClass() {
|
||||||
|
@ -323,6 +395,15 @@ export default class PollComponent extends Component {
|
||||||
updatedVoters() {
|
updatedVoters() {
|
||||||
this.preloadedVoters = this.args.preloadedVoters;
|
this.preloadedVoters = this.args.preloadedVoters;
|
||||||
this.options = [...this.args.options];
|
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
|
@action
|
||||||
|
@ -349,22 +430,26 @@ export default class PollComponent extends Component {
|
||||||
? this.preloadedVoters[optionId]
|
? this.preloadedVoters[optionId]
|
||||||
: this.preloadedVoters;
|
: this.preloadedVoters;
|
||||||
const newVoters = optionId ? result.voters[optionId] : result.voters;
|
const newVoters = optionId ? result.voters[optionId] : result.voters;
|
||||||
const votersSet = new Set(voters.map((voter) => voter.username));
|
if (this.isRankedChoice) {
|
||||||
newVoters.forEach((voter) => {
|
this.preloadedVoters[optionId] = [...new Set([...newVoters])];
|
||||||
if (!votersSet.has(voter.username)) {
|
} else {
|
||||||
votersSet.add(voter.username);
|
const votersSet = new Set(voters.map((voter) => voter.username));
|
||||||
voters.push(voter);
|
newVoters.forEach((voter) => {
|
||||||
}
|
if (!votersSet.has(voter.username)) {
|
||||||
});
|
votersSet.add(voter.username);
|
||||||
// remove users who changed their vote
|
voters.push(voter);
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// 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] = [
|
this.preloadedVoters[optionId] = [
|
||||||
...new Set([...this.preloadedVoters[optionId], ...newVoters]),
|
...new Set([...this.preloadedVoters[optionId], ...newVoters]),
|
||||||
|
@ -398,8 +483,14 @@ export default class PollComponent extends Component {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(({ poll }) => {
|
.then(({ poll }) => {
|
||||||
|
if (this.poll.type === RANKED_CHOICE) {
|
||||||
|
poll.options.forEach((option) => {
|
||||||
|
option.rank = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
this.options = [...poll.options];
|
this.options = [...poll.options];
|
||||||
this.poll.setProperties(poll);
|
this.poll.setProperties(poll);
|
||||||
|
this.rankedChoiceOutcome = poll.ranked_choice_outcome || [];
|
||||||
this.vote = [];
|
this.vote = [];
|
||||||
this.voters = poll.voters;
|
this.voters = poll.voters;
|
||||||
this.hasSavedVote = false;
|
this.hasSavedVote = false;
|
||||||
|
@ -456,7 +547,10 @@ export default class PollComponent extends Component {
|
||||||
|
|
||||||
@action
|
@action
|
||||||
exportResults() {
|
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
|
// This uses the Data Explorer plugin export as CSV route
|
||||||
// There is detection to check if the plugin is enabled before showing the button
|
// 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}}
|
{{#if this.resultsPie}}
|
||||||
<PollResultsPie @id={{this.id}} @options={{this.options}} />
|
<PollResultsPie @id={{this.id}} @options={{this.options}} />
|
||||||
{{else}}
|
{{else}}
|
||||||
<PollResultsStandard
|
<PollResultsTabs
|
||||||
@options={{this.options}}
|
@options={{this.options}}
|
||||||
@pollName={{this.poll.name}}
|
@pollName={{this.poll.name}}
|
||||||
@pollType={{this.poll.type}}
|
@pollType={{this.poll.type}}
|
||||||
|
@isRankedChoice={{this.isRankedChoice}}
|
||||||
@isPublic={{this.poll.public}}
|
@isPublic={{this.poll.public}}
|
||||||
@postId={{this.post.id}}
|
@postId={{this.post.id}}
|
||||||
@vote={{this.vote}}
|
@vote={{this.vote}}
|
||||||
@voters={{this.preloadedVoters}}
|
@voters={{this.preloadedVoters}}
|
||||||
@votersCount={{this.poll.voters}}
|
@votersCount={{this.poll.voters}}
|
||||||
@fetchVoters={{this.fetchVoters}}
|
@fetchVoters={{this.fetchVoters}}
|
||||||
|
@rankedChoiceOutcome={{this.rankedChoiceOutcome}}
|
||||||
/>
|
/>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
@ -528,6 +624,8 @@ export default class PollComponent extends Component {
|
||||||
{{else}}
|
{{else}}
|
||||||
<PollOptions
|
<PollOptions
|
||||||
@isCheckbox={{this.isCheckbox}}
|
@isCheckbox={{this.isCheckbox}}
|
||||||
|
@isRankedChoice={{this.isRankedChoice}}
|
||||||
|
@rankedChoiceDropdownContent={{this.rankedChoiceDropdownContent}}
|
||||||
@options={{this.options}}
|
@options={{this.options}}
|
||||||
@votes={{this.vote}}
|
@votes={{this.vote}}
|
||||||
@sendOptionSelect={{this.toggleOption}}
|
@sendOptionSelect={{this.toggleOption}}
|
||||||
|
@ -599,6 +697,7 @@ export default class PollComponent extends Component {
|
||||||
@voters={{this.voters}}
|
@voters={{this.voters}}
|
||||||
@isStaff={{this.isStaff}}
|
@isStaff={{this.isStaff}}
|
||||||
@isMe={{this.isMe}}
|
@isMe={{this.isMe}}
|
||||||
|
@isRankedChoice={{this.isRankedChoice}}
|
||||||
@topicArchived={{this.topicArchived}}
|
@topicArchived={{this.topicArchived}}
|
||||||
@groupableUserFields={{this.groupableUserFields}}
|
@groupableUserFields={{this.groupableUserFields}}
|
||||||
@isAutomaticallyClosed={{this.isAutomaticallyClosed}}
|
@isAutomaticallyClosed={{this.isAutomaticallyClosed}}
|
||||||
|
|
|
@ -50,6 +50,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.poll-type {
|
.poll-type {
|
||||||
|
.poll-type-value {
|
||||||
|
font-size: var(--font-down-1);
|
||||||
|
}
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
border: none;
|
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 {
|
img {
|
||||||
// Hacky way to stop images without width/height
|
// Hacky way to stop images without width/height
|
||||||
// from causing abrupt unintended scrolling
|
// 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,
|
.poll-buttons-dropdown,
|
||||||
.export-results,
|
.export-results,
|
||||||
.toggle-status,
|
.toggle-status,
|
||||||
|
|
|
@ -22,6 +22,9 @@ en:
|
||||||
title: "Results will be shown once <strong>closed</strong>."
|
title: "Results will be shown once <strong>closed</strong>."
|
||||||
staff:
|
staff:
|
||||||
title: "Results are only shown to <strong>staff</strong> members."
|
title: "Results are only shown to <strong>staff</strong> members."
|
||||||
|
tabs:
|
||||||
|
votes: "Votes"
|
||||||
|
outcome: "Outcome"
|
||||||
multiple:
|
multiple:
|
||||||
help:
|
help:
|
||||||
at_least_min_options:
|
at_least_min_options:
|
||||||
|
@ -80,8 +83,26 @@ en:
|
||||||
percentage: "Percentage"
|
percentage: "Percentage"
|
||||||
count: "Count"
|
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:
|
options:
|
||||||
label: "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_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."
|
error_while_casting_votes: "Sorry, there was an error casting your votes."
|
||||||
|
@ -102,6 +123,7 @@ en:
|
||||||
label: Type
|
label: Type
|
||||||
regular: Single Choice
|
regular: Single Choice
|
||||||
multiple: Multiple Choice
|
multiple: Multiple Choice
|
||||||
|
ranked_choice: Ranked Choice
|
||||||
number: Number Rating
|
number: Number Rating
|
||||||
poll_result:
|
poll_result:
|
||||||
label: Show Results...
|
label: Show Results...
|
||||||
|
|
|
@ -7,6 +7,7 @@ en:
|
||||||
poll_create_allowed_groups: "The groups that are allowed to create polls."
|
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_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_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."
|
poll_default_public: "When creating a new poll, enable the 'show who voted' option by default."
|
||||||
keywords:
|
keywords:
|
||||||
poll_create_allowed_groups: "poll_minimum_trust_level_to_create"
|
poll_create_allowed_groups: "poll_minimum_trust_level_to_create"
|
||||||
|
@ -58,10 +59,13 @@ en:
|
||||||
min_vote_per_user:
|
min_vote_per_user:
|
||||||
one: A minimum of %{count} vote is required for this poll.
|
one: A minimum of %{count} vote is required for this poll.
|
||||||
other: A minimum of %{count} votes 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."
|
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."
|
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."
|
insufficient_rights_to_create: "You are not allowed to create polls."
|
||||||
|
|
||||||
email:
|
email:
|
||||||
|
|
|
@ -30,6 +30,10 @@ plugins:
|
||||||
default: -16
|
default: -16
|
||||||
min: -9999
|
min: -9999
|
||||||
client: true
|
client: true
|
||||||
|
poll_export_ranked_choice_data_explorer_query_id:
|
||||||
|
default: -19
|
||||||
|
min: -9999
|
||||||
|
client: true
|
||||||
poll_default_public:
|
poll_default_public:
|
||||||
default: true
|
default: true
|
||||||
client: 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
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class DiscoursePoll::Poll
|
class DiscoursePoll::Poll
|
||||||
|
RANKED_CHOICE = "ranked_choice"
|
||||||
MULTIPLE = "multiple"
|
MULTIPLE = "multiple"
|
||||||
REGULAR = "regular"
|
REGULAR = "regular"
|
||||||
|
|
||||||
|
@ -13,7 +14,12 @@ class DiscoursePoll::Poll
|
||||||
# remove options that aren't available in the poll
|
# remove options that aren't available in the poll
|
||||||
available_options = poll.poll_options.map { |o| o.digest }.to_set
|
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?
|
if options.empty?
|
||||||
raise DiscoursePoll::Error.new I18n.t("poll.requires_at_least_1_valid_option")
|
raise DiscoursePoll::Error.new I18n.t("poll.requires_at_least_1_valid_option")
|
||||||
|
@ -23,7 +29,11 @@ class DiscoursePoll::Poll
|
||||||
poll
|
poll
|
||||||
.poll_options
|
.poll_options
|
||||||
.each_with_object([]) do |option, obj|
|
.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
|
end
|
||||||
|
|
||||||
self.validate_votes!(poll, new_option_ids)
|
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?
|
obj << option.id if option.poll_votes.where(user_id: user.id).exists?
|
||||||
end
|
end
|
||||||
|
|
||||||
# remove non-selected votes
|
if poll.ranked_choice?
|
||||||
PollVote.where(poll: poll, user: user).where.not(poll_option_id: new_option_ids).delete_all
|
# 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
|
# create missing votes
|
||||||
creation_set = new_option_ids - old_option_ids
|
|
||||||
|
|
||||||
creation_set.each do |option_id|
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
# Ensure consistency here as we do not have a unique index to limit the
|
if serialized_poll[:type] == RANKED_CHOICE
|
||||||
# number of votes per the poll's configuration.
|
serialized_poll[:ranked_choice_outcome] = DiscoursePoll::RankedChoice.outcome(poll_id)
|
||||||
is_multiple = serialized_poll[:type] == MULTIPLE
|
else
|
||||||
offset = is_multiple ? (serialized_poll[:max] || serialized_poll[:options].length) : 1
|
# 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)
|
DB.query(<<~SQL, params)
|
||||||
DELETE FROM poll_votes
|
DELETE FROM poll_votes
|
||||||
USING (
|
USING (
|
||||||
SELECT
|
SELECT
|
||||||
poll_id,
|
poll_id,
|
||||||
user_id
|
user_id
|
||||||
FROM poll_votes
|
FROM poll_votes
|
||||||
WHERE poll_id = :poll_id
|
WHERE poll_id = :poll_id
|
||||||
AND user_id = :user_id
|
AND user_id = :user_id
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
OFFSET :offset
|
OFFSET :offset
|
||||||
) to_delete_poll_votes
|
) to_delete_poll_votes
|
||||||
WHERE poll_votes.poll_id = to_delete_poll_votes.poll_id
|
WHERE poll_votes.poll_id = to_delete_poll_votes.poll_id
|
||||||
AND poll_votes.user_id = to_delete_poll_votes.user_id
|
AND poll_votes.user_id = to_delete_poll_votes.user_id
|
||||||
SQL
|
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
|
if serialized_poll[:type] == MULTIPLE
|
||||||
serialized_poll[:options].each do |option|
|
serialized_poll[:options].each do |option|
|
||||||
|
@ -85,9 +139,19 @@ class DiscoursePoll::Poll
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.remove_vote(user, post_id, poll_name)
|
def self.remove_vote(user, post_id, poll_name)
|
||||||
DiscoursePoll::Poll.change_vote(user, post_id, poll_name) do |poll|
|
poll_id = nil
|
||||||
PollVote.where(poll: poll, user: user).delete_all
|
|
||||||
|
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
|
end
|
||||||
|
|
||||||
|
serialized_poll
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.toggle_status(user, post_id, poll_name, status, raise_errors = true)
|
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
|
raise Discourse::InvalidParameters.new(:option_id) unless poll_option
|
||||||
|
|
||||||
user_ids =
|
if poll.ranked_choice?
|
||||||
PollVote
|
params = {
|
||||||
.where(poll: poll, poll_option: poll_option)
|
poll_id: poll.id,
|
||||||
.group(:user_id)
|
option_digest: option_digest,
|
||||||
.order("MIN(created_at)")
|
offset: offset,
|
||||||
.offset(offset)
|
offset_plus_limit: offset + limit,
|
||||||
.limit(limit)
|
}
|
||||||
.pluck(:user_id)
|
|
||||||
|
|
||||||
user_hashes = User.where(id: user_ids).map { |u| UserNameSerializer.new(u).serializable_hash }
|
votes = DB.query(<<~SQL, params)
|
||||||
|
SELECT digest, rank, user_id
|
||||||
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
|
|
||||||
FROM (
|
FROM (
|
||||||
SELECT digest
|
SELECT digest
|
||||||
|
, CASE rank WHEN 0 THEN 'Abstain' ELSE CAST(rank AS text) END AS rank
|
||||||
, user_id
|
, user_id
|
||||||
|
, username
|
||||||
, ROW_NUMBER() OVER (PARTITION BY poll_option_id ORDER BY pv.created_at) AS row
|
, ROW_NUMBER() OVER (PARTITION BY poll_option_id ORDER BY pv.created_at) AS row
|
||||||
FROM poll_votes pv
|
FROM poll_votes pv
|
||||||
JOIN poll_options po ON pv.poll_option_id = po.id
|
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
|
WHERE pv.poll_id = :poll_id
|
||||||
AND po.poll_id = :poll_id
|
AND po.poll_id = :poll_id
|
||||||
) v
|
) v
|
||||||
WHERE row BETWEEN :offset AND :offset_plus_limit
|
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
|
user_ids = votes.map(&:user_id).uniq
|
||||||
|
|
||||||
|
@ -205,8 +331,13 @@ class DiscoursePoll::Poll
|
||||||
|
|
||||||
result = {}
|
result = {}
|
||||||
votes.each do |v|
|
votes.each do |v|
|
||||||
result[v.digest] ||= []
|
if poll.ranked_choice?
|
||||||
result[v.digest] << user_hashes[v.user_id]
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -388,6 +519,16 @@ class DiscoursePoll::Poll
|
||||||
elsif poll.max && (num_of_options > poll.max)
|
elsif poll.max && (num_of_options > poll.max)
|
||||||
raise DiscoursePoll::Error.new(I18n.t("poll.max_vote_per_user", count: poll.max))
|
raise DiscoursePoll::Error.new(I18n.t("poll.max_vote_per_user", count: poll.max))
|
||||||
end
|
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
|
elsif num_of_options > 1
|
||||||
raise DiscoursePoll::Error.new(I18n.t("poll.one_vote_per_user"))
|
raise DiscoursePoll::Error.new(I18n.t("poll.one_vote_per_user"))
|
||||||
end
|
end
|
||||||
|
|
|
@ -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 "app/serializers/poll_serializer"
|
||||||
require_relative "jobs/regular/close_poll"
|
require_relative "jobs/regular/close_poll"
|
||||||
require_relative "lib/poll"
|
require_relative "lib/poll"
|
||||||
|
require_relative "lib/ranked_choice"
|
||||||
require_relative "lib/polls_updater"
|
require_relative "lib/polls_updater"
|
||||||
require_relative "lib/polls_validator"
|
require_relative "lib/polls_validator"
|
||||||
require_relative "lib/post_validator"
|
require_relative "lib/post_validator"
|
||||||
|
@ -220,13 +221,22 @@ after_initialize do
|
||||||
) do
|
) do
|
||||||
preloaded_polls
|
preloaded_polls
|
||||||
.map do |poll|
|
.map do |poll|
|
||||||
user_poll_votes =
|
if poll.ranked_choice?
|
||||||
poll
|
user_poll_votes =
|
||||||
.poll_votes
|
poll
|
||||||
.where(user_id: scope.user.id)
|
.poll_votes
|
||||||
.joins(:poll_option)
|
.where(user_id: scope.user.id)
|
||||||
.pluck("poll_options.digest")
|
.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]
|
[poll.name, user_poll_votes]
|
||||||
end
|
end
|
||||||
.to_h
|
.to_h
|
||||||
|
|
|
@ -9,6 +9,8 @@ Fabricator(:poll_regular, from: :poll) { type "regular" }
|
||||||
|
|
||||||
Fabricator(:poll_multiple, from: :poll) { type "multiple" }
|
Fabricator(:poll_multiple, from: :poll) { type "multiple" }
|
||||||
|
|
||||||
|
Fabricator(:poll_ranked_choice, from: :poll) { type "ranked_choice" }
|
||||||
|
|
||||||
Fabricator(:poll_option) do
|
Fabricator(:poll_option) do
|
||||||
poll
|
poll
|
||||||
html { sequence(:html) { |i| "Poll Option #{i}" } }
|
html { sequence(:html) { |i| "Poll Option #{i}" } }
|
||||||
|
|
|
@ -13,9 +13,21 @@ RSpec.describe "DiscoursePoll endpoints" do
|
||||||
[/poll]
|
[/poll]
|
||||||
SQL
|
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_a) { "5c24fc1df56d764b550ceae1b9319125" }
|
||||||
let(:option_b) { "e89dec30bbd9bf50fabf6a05b4324edf" }
|
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
|
it "should return the right response" do
|
||||||
DiscoursePoll::Poll.vote(user, post.id, DiscoursePoll::DEFAULT_POLL_NAME, [option_a])
|
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)
|
expect(option.first["username"]).to eq(user.username)
|
||||||
end
|
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
|
describe "when post_id is blank" do
|
||||||
it "should raise the right error" do
|
it "should raise the right error" do
|
||||||
get "/polls/voters.json", params: { poll_name: DiscoursePoll::DEFAULT_POLL_NAME }
|
get "/polls/voters.json", params: { poll_name: DiscoursePoll::DEFAULT_POLL_NAME }
|
||||||
|
@ -141,12 +190,71 @@ RSpec.describe "DiscoursePoll endpoints" do
|
||||||
[/poll]
|
[/poll]
|
||||||
SQL
|
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_a) { "5c24fc1df56d764b550ceae1b9319125" }
|
||||||
let(:option_b) { "e89dec30bbd9bf50fabf6a05b4324edf" }
|
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
|
before do
|
||||||
sign_in(user1)
|
sign_in(user1)
|
||||||
user_votes = { user_0: option_a, user_1: option_a, user_2: option_b }
|
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|
|
[user1, user2, user3].each_with_index do |user, index|
|
||||||
DiscoursePoll::Poll.vote(
|
DiscoursePoll::Poll.vote(
|
||||||
|
@ -155,6 +263,12 @@ RSpec.describe "DiscoursePoll endpoints" do
|
||||||
DiscoursePoll::DEFAULT_POLL_NAME,
|
DiscoursePoll::DEFAULT_POLL_NAME,
|
||||||
[user_votes["user_#{index}".to_sym]],
|
[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}")
|
UserCustomField.create(user_id: user.id, name: "something", value: "value#{index}")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -206,6 +320,19 @@ RSpec.describe "DiscoursePoll endpoints" do
|
||||||
)
|
)
|
||||||
end
|
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
|
it "returns an error when poll_groupable_user_fields is empty" do
|
||||||
SiteSetting.poll_groupable_user_fields = ""
|
SiteSetting.poll_groupable_user_fields = ""
|
||||||
get "/polls/grouped_poll_results.json",
|
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_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_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
|
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(:poll_vote, poll: poll_regular, user: source_user, poll_option: poll_regular_option2)
|
||||||
Fabricate(
|
Fabricate(
|
||||||
|
@ -58,6 +69,22 @@ RSpec.describe UserMerger do
|
||||||
)
|
)
|
||||||
end
|
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
|
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)
|
Fabricate(:poll_vote, poll: poll_regular, user: source_user)
|
||||||
|
|
||||||
|
|
|
@ -21,6 +21,14 @@ RSpec.describe DiscoursePoll::Poll do
|
||||||
[/poll]
|
[/poll]
|
||||||
RAW
|
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
|
describe ".vote" do
|
||||||
it "should only allow one vote per user for a regular poll" do
|
it "should only allow one vote per user for a regular poll" do
|
||||||
poll = post_with_regular_poll.polls.first
|
poll = post_with_regular_poll.polls.first
|
||||||
|
@ -159,6 +167,85 @@ RSpec.describe DiscoursePoll::Poll do
|
||||||
poll.poll_options.second.id,
|
poll.poll_options.second.id,
|
||||||
)
|
)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe "post_created" do
|
describe "post_created" do
|
||||||
|
|
|
@ -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) {
|
test("single, not selected", async function (assert) {
|
||||||
this.setProperties({
|
this.setProperties({
|
||||||
isCheckbox: false,
|
isCheckbox: false,
|
||||||
|
isRankedChoice: false,
|
||||||
|
rankedChoiceDropdownContent: [],
|
||||||
options: OPTIONS,
|
options: OPTIONS,
|
||||||
votes: [],
|
votes: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
await render(hbs`<PollOptions
|
await render(hbs`<PollOptions
|
||||||
@isCheckbox={{this.isCheckbox}}
|
@isCheckbox={{this.isCheckbox}}
|
||||||
|
@isRankedChoice={{this.isRankedChoice}}
|
||||||
|
@ranked_choice_dropdown_content={{this.ranked_choice_dropdown_content}}
|
||||||
@options={{this.options}}
|
@options={{this.options}}
|
||||||
@votes={{this.votes}}
|
@votes={{this.votes}}
|
||||||
@sendRadioClick={{this.toggleOption}}
|
@sendRadioClick={{this.toggleOption}}
|
||||||
|
@ -48,12 +52,16 @@ module("Poll | Component | poll-options", function (hooks) {
|
||||||
test("single, selected", async function (assert) {
|
test("single, selected", async function (assert) {
|
||||||
this.setProperties({
|
this.setProperties({
|
||||||
isCheckbox: false,
|
isCheckbox: false,
|
||||||
|
isRankedChoice: false,
|
||||||
|
rankedChoiceDropdownContent: [],
|
||||||
options: OPTIONS,
|
options: OPTIONS,
|
||||||
votes: ["6c986ebcde3d5822a6e91a695c388094"],
|
votes: ["6c986ebcde3d5822a6e91a695c388094"],
|
||||||
});
|
});
|
||||||
|
|
||||||
await render(hbs`<PollOptions
|
await render(hbs`<PollOptions
|
||||||
@isCheckbox={{this.isCheckbox}}
|
@isCheckbox={{this.isCheckbox}}
|
||||||
|
@isRankedChoice={{this.isRankedChoice}}
|
||||||
|
@ranked_choice_dropdown_content={{this.ranked_choice_dropdown_content}}
|
||||||
@options={{this.options}}
|
@options={{this.options}}
|
||||||
@votes={{this.votes}}
|
@votes={{this.votes}}
|
||||||
@sendRadioClick={{this.toggleOption}}
|
@sendRadioClick={{this.toggleOption}}
|
||||||
|
@ -65,12 +73,16 @@ module("Poll | Component | poll-options", function (hooks) {
|
||||||
test("multi, not selected", async function (assert) {
|
test("multi, not selected", async function (assert) {
|
||||||
this.setProperties({
|
this.setProperties({
|
||||||
isCheckbox: true,
|
isCheckbox: true,
|
||||||
|
isRankedChoice: false,
|
||||||
|
rankedChoiceDropdownContent: [],
|
||||||
options: OPTIONS,
|
options: OPTIONS,
|
||||||
votes: [],
|
votes: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
await render(hbs`<PollOptions
|
await render(hbs`<PollOptions
|
||||||
@isCheckbox={{this.isCheckbox}}
|
@isCheckbox={{this.isCheckbox}}
|
||||||
|
@isRankedChoice={{this.isRankedChoice}}
|
||||||
|
@ranked_choice_dropdown_content={{this.ranked_choice_dropdown_content}}
|
||||||
@options={{this.options}}
|
@options={{this.options}}
|
||||||
@votes={{this.votes}}
|
@votes={{this.votes}}
|
||||||
@sendRadioClick={{this.toggleOption}}
|
@sendRadioClick={{this.toggleOption}}
|
||||||
|
@ -82,12 +94,16 @@ module("Poll | Component | poll-options", function (hooks) {
|
||||||
test("multi, selected", async function (assert) {
|
test("multi, selected", async function (assert) {
|
||||||
this.setProperties({
|
this.setProperties({
|
||||||
isCheckbox: true,
|
isCheckbox: true,
|
||||||
|
isRankedChoice: false,
|
||||||
|
rankedChoiceDropdownContent: [],
|
||||||
options: OPTIONS,
|
options: OPTIONS,
|
||||||
votes: ["6c986ebcde3d5822a6e91a695c388094"],
|
votes: ["6c986ebcde3d5822a6e91a695c388094"],
|
||||||
});
|
});
|
||||||
|
|
||||||
await render(hbs`<PollOptions
|
await render(hbs`<PollOptions
|
||||||
@isCheckbox={{this.isCheckbox}}
|
@isCheckbox={{this.isCheckbox}}
|
||||||
|
@isRankedChoice={{this.isRankedChoice}}
|
||||||
|
@ranked_choice_dropdown_content={{this.ranked_choice_dropdown_content}}
|
||||||
@options={{this.options}}
|
@options={{this.options}}
|
||||||
@votes={{this.votes}}
|
@votes={{this.votes}}
|
||||||
@sendRadioClick={{this.toggleOption}}
|
@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