Merge pull request #5540 from consuldemocracy/poll_form

Use checkboxes and radio buttons on poll forms
This commit is contained in:
Javi Martín
2025-08-14 14:13:26 +02:00
committed by GitHub
46 changed files with 909 additions and 765 deletions

View File

@@ -571,6 +571,9 @@ RSpec/BeNil:
Enabled: true Enabled: true
EnforcedStyle: be EnforcedStyle: be
RSpec/ChangeByZero:
Enabled: true
RSpec/ContextMethod: RSpec/ContextMethod:
Enabled: true Enabled: true

View File

@@ -120,6 +120,7 @@
//= require datepicker //= require datepicker
//= require authenticity_token_refresh //= require authenticity_token_refresh
//= require_tree ./admin //= require_tree ./admin
//= require_tree ./polls
//= require_tree ./sdg //= require_tree ./sdg
//= require_tree ./sdg_management //= require_tree ./sdg_management
//= require_tree ./custom //= require_tree ./custom
@@ -178,6 +179,7 @@ var initialize_modules = function() {
App.BudgetEditAssociations.initialize(); App.BudgetEditAssociations.initialize();
App.BudgetHideMoney.initialize(); App.BudgetHideMoney.initialize();
App.Datepicker.initialize(); App.Datepicker.initialize();
App.PollsForm.initialize();
App.SDGRelatedListSelector.initialize(); App.SDGRelatedListSelector.initialize();
App.SDGManagementRelationSearch.initialize(); App.SDGManagementRelationSearch.initialize();
App.AuthenticityTokenRefresh.initialize(); App.AuthenticityTokenRefresh.initialize();

View File

@@ -0,0 +1,27 @@
(function() {
"use strict";
App.PollsForm = {
updateMultipleChoiceStatus: function(fieldset) {
var max_votes = $(fieldset).attr("data-max-votes");
var checked_boxes = $(fieldset).find(":checkbox:checked");
var unchecked_boxes = $(fieldset).find(":checkbox:not(:checked)");
if (checked_boxes.length >= max_votes) {
$(unchecked_boxes).prop("disabled", true);
} else {
$(fieldset).find(":checkbox").prop("disabled", false);
}
},
initialize: function() {
$(".poll-form .multiple-choice").each(function() {
App.PollsForm.updateMultipleChoiceStatus(this);
});
$(".poll-form .multiple-choice :checkbox").on("change", function() {
var fieldset = $(this).closest("fieldset");
App.PollsForm.updateMultipleChoiceStatus(fieldset);
});
}
};
}).call(this);

View File

@@ -90,7 +90,7 @@
@include radio-or-checkbox-and-label-alignment; @include radio-or-checkbox-and-label-alignment;
span { span {
margin-left: 1ch; margin-#{$global-left}: 1ch;
} }
} }
} }

View File

@@ -1396,8 +1396,7 @@
position: relative; position: relative;
} }
.public .poll, .public .poll {
.poll-question {
border: 1px solid $border; border: 1px solid $border;
margin-bottom: calc($line-height / 2); margin-bottom: calc($line-height / 2);
padding: calc($line-height / 2); padding: calc($line-height / 2);
@@ -1413,47 +1412,6 @@
} }
} }
.poll-question {
padding: 0 $line-height;
h3 {
padding-top: $line-height;
}
}
.poll-question-options {
@include flex-with-gap($line-height * 0.25);
flex-wrap: wrap;
.button {
min-width: rem-calc(168);
@include breakpoint(medium down) {
width: 100%;
}
&.answered {
background: #f4f8ec;
border: 2px solid #92ba48;
color: color-pick-contrast(#f4f8ec);
position: relative;
&::after {
background: #92ba48;
border-radius: rem-calc(20);
color: #fff;
content: "\6c";
font-family: "icons" !important;
font-size: rem-calc(12);
padding: calc($line-height / 4);
position: absolute;
right: -6px;
top: -6px;
}
}
}
}
// 09. Polls results and stats // 09. Polls results and stats
// --------------------------- // ---------------------------

View File

@@ -0,0 +1,56 @@
.poll-form {
fieldset {
border: 1px solid $border;
border-radius: $global-radius;
padding: $line-height;
}
fieldset + fieldset {
margin-top: calc($line-height / 2);
}
legend {
@include header-font-size(h3);
float: $global-left;
margin-bottom: 0;
+ * {
clear: $global-left;
}
}
label {
@include radio-or-checkbox-and-label-alignment;
font-weight: normal;
&:first-of-type::before {
content: "\A";
margin-top: calc($line-height / 2);
white-space: pre;
}
}
.help-text {
display: block;
font-size: 1em;
font-style: normal;
font-weight: bold;
}
.read-more-links {
margin-top: calc($line-height / 2);
* {
margin-bottom: 0;
}
* + * {
margin-top: calc($line-height / 4);
}
}
[type=submit] {
margin-top: calc($line-height * 3 / 4);
}
}

View File

@@ -0,0 +1,21 @@
<% if can?(:answer, poll) %>
<% if voted_in_booth? %>
<%= callout(t("polls.show.already_voted_in_booth")) %>
<% elsif voted_in_web? %>
<% if voted_blank? %>
<%= callout(t("polls.show.already_voted_blank_in_web")) %>
<% else %>
<%= callout(t("polls.show.already_voted_in_web")) %>
<% end %>
<% end %>
<% else %>
<% if current_user.nil? %>
<%= callout(not_logged_in_text, html_class: "primary") %>
<% elsif current_user.unverified? %>
<%= callout(unverified_text) %>
<% elsif poll.expired? %>
<%= callout(t("polls.show.cant_answer_expired"), html_class: "alert") %>
<% else %>
<%= callout(t("polls.show.cant_answer_wrong_geozone")) %>
<% end %>
<% end %>

View File

@@ -0,0 +1,37 @@
class Polls::CalloutComponent < ApplicationComponent
attr_reader :poll
use_helpers :can?, :current_user, :link_to_signin, :link_to_signup
def initialize(poll)
@poll = poll
end
private
def voted_in_booth?
poll.voted_in_booth?(current_user)
end
def voted_in_web?
poll.voted_in_web?(current_user)
end
def voted_blank?
poll.answers.where(author: current_user).none?
end
def callout(text, html_class: "warning")
tag.div(text, class: "callout #{html_class}")
end
def not_logged_in_text
sanitize(t("polls.show.cant_answer_not_logged_in",
signin: link_to_signin,
signup: link_to_signup))
end
def unverified_text
sanitize(t("polls.show.cant_answer_verify",
verify_link: link_to(t("polls.show.verify_link"), verification_path)))
end
end

View File

@@ -0,0 +1,7 @@
<%= form_for web_vote, form_attributes do |f| %>
<% questions.each do |question| %>
<%= render Polls::Questions::QuestionComponent.new(question, form: f, disabled: disabled?) %>
<% end %>
<%= f.submit(class: "button", value: t("polls.form.vote"), disabled: disabled?) %>
<% end %>

View File

@@ -0,0 +1,19 @@
class Polls::FormComponent < ApplicationComponent
attr_reader :web_vote
use_helpers :cannot?, :current_user
delegate :poll, :questions, to: :web_vote
def initialize(web_vote)
@web_vote = web_vote
end
private
def form_attributes
{ url: answer_poll_path(poll), method: :post, html: { class: "poll-form" }}
end
def disabled?
cannot?(:answer, poll) || poll.voted_in_booth?(current_user)
end
end

View File

@@ -1,35 +0,0 @@
<div class="poll-question-options">
<% if can?(:answer, question) && !question.poll.voted_in_booth?(current_user) %>
<% question_options.each do |question_option| %>
<% if already_answered?(question_option) %>
<%= button_to question_option.title,
question_answer_path(question, user_answer(question_option)),
method: :delete,
remote: true,
title: t("poll_questions.show.voted", answer: question_option.title),
class: "button answered",
"aria-pressed": true %>
<% else %>
<%= button_to question_option.title,
question_answers_path(question, option_id: question_option.id),
remote: true,
title: t("poll_questions.show.vote_answer", answer: question_option.title),
class: "button secondary hollow",
"aria-pressed": false,
disabled: disable_option?(question_option) %>
<% end %>
<% end %>
<% elsif !user_signed_in? %>
<% question_options.each do |question_option| %>
<%= link_to question_option.title, new_user_session_path, class: "button secondary hollow" %>
<% end %>
<% elsif !current_user.level_two_or_three_verified? %>
<% question_options.each do |question_option| %>
<%= link_to question_option.title, verification_path, class: "button secondary hollow" %>
<% end %>
<% else %>
<% question_options.each do |question_option| %>
<span class="button secondary hollow disabled"><%= question_option.title %></span>
<% end %>
<% end %>
</div>

View File

@@ -1,30 +0,0 @@
class Polls::Questions::OptionsComponent < ApplicationComponent
attr_reader :question
use_helpers :can?, :current_user, :user_signed_in?
def initialize(question)
@question = question
end
def already_answered?(question_option)
user_answer(question_option).present?
end
def question_options
question.question_options
end
def user_answer(question_option)
user_answers.find_by(answer: question_option.title)
end
def disable_option?(question_option)
question.multiple? && user_answers.count == question.max_votes
end
private
def user_answers
@user_answers ||= question.answers.by_author(current_user)
end
end

View File

@@ -1,22 +1,23 @@
<div id="<%= dom_id(question) %>" class="poll-question"> <fieldset <%= fieldset_attributes %>>
<h3> <legend><%= question.title %></legend>
<%= question.title %>
</h3>
<% if question.votation_type.present? %> <% if multiple_choice? %>
<strong> <%= multiple_choice_help_text %>
<%= t("poll_questions.description.#{question.vote_type}", maximum: question.max_votes) %>
</strong> <% question.question_options.each do |option| %>
<%= multiple_choice_field(option) %>
<% end %>
<% else %>
<% question.question_options.each do |option| %>
<%= single_choice_field(option) %>
<% end %>
<% end %> <% end %>
<div id="<%= dom_id(question) %>_options" class="padding">
<%= render Polls::Questions::OptionsComponent.new(question) %>
</div>
<% if question.options_with_read_more? %> <% if question.options_with_read_more? %>
<div> <div class="read-more-links">
<p><%= t("poll_questions.read_more_about") %></p> <p><%= t("poll_questions.read_more_about") %></p>
<p><%= options_read_more_links %></p> <p><%= options_read_more_links %></p>
</div> </div>
<% end %> <% end %>
</div> <%= form.error_for(:"question_#{question.id}") %>
</fieldset>

View File

@@ -1,8 +1,30 @@
class Polls::Questions::QuestionComponent < ApplicationComponent class Polls::Questions::QuestionComponent < ApplicationComponent
attr_reader :question attr_reader :question, :form, :disabled
alias_method :disabled?, :disabled
def initialize(question:) def initialize(question, form:, disabled: false)
@question = question @question = question
@form = form
@disabled = disabled
end
private
def fieldset_attributes
tag.attributes(
id: dom_id(question),
disabled: ("disabled" if disabled?),
class: fieldset_class,
data: { max_votes: question.max_votes }
)
end
def fieldset_class
if multiple_choice?
"multiple-choice"
else
"single-choice"
end
end end
def options_read_more_links def options_read_more_links
@@ -10,4 +32,43 @@ class Polls::Questions::QuestionComponent < ApplicationComponent
link_to option.title, "#option_#{option.id}" link_to option.title, "#option_#{option.id}"
end, ", ") end, ", ")
end end
def multiple_choice?
question.multiple?
end
def multiple_choice_help_text
tag.span(
t("poll_questions.description.multiple", maximum: question.max_votes),
class: "help-text"
)
end
def multiple_choice_field(option)
choice_field(option) do
check_box_tag "web_vote[#{question.id}][option_id][]",
option.id,
checked?(option),
id: "web_vote_option_#{option.id}"
end
end
def single_choice_field(option)
choice_field(option) do
radio_button_tag "web_vote[#{question.id}][option_id]",
option.id,
checked?(option),
id: "web_vote_option_#{option.id}"
end
end
def choice_field(option, &block)
label_tag("web_vote_option_#{option.id}") do
block.call + option.title
end
end
def checked?(option)
form.object.answers[question.id].find { |answer| answer.option_id == option.id }
end
end end

View File

@@ -1,36 +0,0 @@
class Polls::AnswersController < ApplicationController
load_and_authorize_resource :question, class: "::Poll::Question"
load_and_authorize_resource :answer, class: "::Poll::Answer",
through: :question,
through_association: :answers,
only: :destroy
def create
authorize! :answer, @question
answer = @question.find_or_initialize_user_answer(current_user, params[:option_id])
answer.save_and_record_voter_participation
respond_to do |format|
format.html do
redirect_to request.referer
end
format.js do
render :show
end
end
end
def destroy
@answer.destroy_and_remove_voter_participation
respond_to do |format|
format.html do
redirect_to request.referer
end
format.js do
render :show
end
end
end
end

View File

@@ -9,7 +9,7 @@ class PollsController < ApplicationController
load_and_authorize_resource load_and_authorize_resource
has_filters %w[current expired] has_filters %w[current expired]
has_orders %w[most_voted newest oldest], only: :show has_orders %w[most_voted newest oldest], only: [:show, :answer]
def index def index
@polls = Kaminari.paginate_array( @polls = Kaminari.paginate_array(
@@ -18,10 +18,25 @@ class PollsController < ApplicationController
end end
def show def show
@questions = @poll.questions.for_render.sort_for_list @web_vote = Poll::WebVote.new(@poll, current_user)
@comment_tree = CommentTree.new(@poll, params[:page], @current_order) @comment_tree = CommentTree.new(@poll, params[:page], @current_order)
end end
def answer
@web_vote = Poll::WebVote.new(@poll, current_user)
if @web_vote.update(answer_params)
if answer_params.blank?
redirect_to @poll, notice: t("flash.actions.create.poll_voter_blank")
else
redirect_to @poll, notice: t("flash.actions.create.poll_voter")
end
else
@comment_tree = CommentTree.new(@poll, params[:page], @current_order)
render :show
end
end
def stats def stats
@stats = Poll::Stats.new(@poll).tap(&:generate) @stats = Poll::Stats.new(@poll).tap(&:generate)
end end
@@ -38,4 +53,8 @@ class PollsController < ApplicationController
def load_active_poll def load_active_poll
@active_poll = ActivePoll.first @active_poll = ActivePoll.first
end end
def answer_params
params[:web_vote] || {}
end
end end

View File

@@ -110,12 +110,6 @@ module Abilities
can :answer, Poll do |poll| can :answer, Poll do |poll|
poll.answerable_by?(user) poll.answerable_by?(user)
end end
can :answer, Poll::Question do |question|
question.answerable_by?(user)
end
can :destroy, Poll::Answer do |answer|
answer.author == user && answer.question.answerable_by?(user)
end
end end
can [:create, :show], ProposalNotification, proposal: { author_id: user.id } can [:create, :show], ProposalNotification, proposal: { author_id: user.id }

View File

@@ -23,6 +23,7 @@ class Poll < ApplicationRecord
has_many :officer_assignments, through: :booth_assignments has_many :officer_assignments, through: :booth_assignments
has_many :officers, through: :officer_assignments has_many :officers, through: :officer_assignments
has_many :questions, inverse_of: :poll, dependent: :destroy has_many :questions, inverse_of: :poll, dependent: :destroy
has_many :answers, through: :questions
has_many :comments, as: :commentable, inverse_of: :commentable has_many :comments, as: :commentable, inverse_of: :commentable
has_many :ballot_sheets has_many :ballot_sheets

View File

@@ -17,32 +17,13 @@ class Poll::Answer < ApplicationRecord
scope :by_author, ->(author_id) { where(author_id: author_id) } scope :by_author, ->(author_id) { where(author_id: author_id) }
scope :by_question, ->(question_id) { where(question_id: question_id) } scope :by_question, ->(question_id) { where(question_id: question_id) }
def save_and_record_voter_participation
author.with_lock do
save!
Poll::Voter.find_or_create_by!(user: author, poll: poll, origin: "web")
end
end
def destroy_and_remove_voter_participation
transaction do
destroy!
if author.poll_answers.where(question_id: poll.question_ids).none?
Poll::Voter.find_by(user: author, poll: poll, origin: "web").destroy!
end
end
end
private private
def max_votes def max_votes
return if !question || !author || persisted? return if !question || !author || persisted?
author.with_lock do
if question.answers.by_author(author).count >= question.max_votes if question.answers.by_author(author).count >= question.max_votes
errors.add(:answer, "Maximum number of votes per user exceeded") errors.add(:answer, "Maximum number of votes per user exceeded")
end end
end end
end end
end

View File

@@ -41,14 +41,6 @@ class Poll::Question < ApplicationRecord
end end
end end
delegate :answerable_by?, to: :poll
def self.answerable_by(user)
return none if user.nil? || user.unverified?
where(poll_id: Poll.answerable_by(user).pluck(:id))
end
def options_total_votes def options_total_votes
question_options.reduce(0) { |total, question_option| total + question_option.total_votes } question_options.reduce(0) { |total, question_option| total + question_option.total_votes }
end end

View File

@@ -42,11 +42,11 @@ class Poll::Stats
end end
def total_web_valid def total_web_valid
voters.where(origin: "web").count - total_web_white voters.where(origin: "web", user_id: poll.answers.select(:author_id).distinct).count
end end
def total_web_white def total_web_white
0 voters.where(origin: "web").count - total_web_valid
end end
def total_web_null def total_web_null

View File

@@ -0,0 +1,81 @@
class Poll::WebVote
include ActiveModel::Validations
attr_reader :poll, :user
delegate :t, to: "ApplicationController.helpers"
validate :max_answers
def initialize(poll, user)
@poll = poll
@user = user
end
def questions
poll.questions.for_render.sort_for_list
end
def answers
@answers ||= questions.to_h do |question|
[question.id, question.answers.where(author: user)]
end
end
def update(params)
all_valid = true
user.with_lock do
self.answers = given_answers(params)
questions.each do |question|
question.answers.where(author: user).where.not(id: answers[question.id].map(&:id)).destroy_all
if valid? && answers[question.id].all?(&:valid?)
Poll::Voter.find_or_create_by!(user: user, poll: poll, origin: "web")
answers[question.id].each(&:save!)
else
all_valid = false
end
end
raise ActiveRecord::Rollback unless all_valid
end
all_valid
end
def to_key
end
def persisted?
Poll::Voter.where(user: user, poll: poll, origin: "web").exists?
end
private
attr_writer :answers
def given_answers(params)
questions.to_h do |question|
[question.id, answers_for_question(question, params[question.id.to_s])]
end
end
def answers_for_question(question, question_params)
return [] unless question_params
Array(question_params[:option_id]).map do |option_id|
question.find_or_initialize_user_answer(user, option_id)
end
end
def max_answers
questions.each do |question|
if answers[question.id].count > question.max_votes
errors.add(
:"question_#{question.id}",
t("polls.form.maximum_exceeded", maximum: question.max_votes, given: answers[question.id].count)
)
end
end
end
end

View File

@@ -1,22 +0,0 @@
<% unless can?(:answer, @poll) %>
<% if current_user.nil? %>
<div class="callout primary">
<%= sanitize(t("polls.show.cant_answer_not_logged_in",
signin: link_to_signin(class: "probe-message"),
signup: link_to_signup(class: "probe-message"))) %>
</div>
<% elsif current_user.unverified? %>
<div class="callout warning">
<%= sanitize(t("polls.show.cant_answer_verify",
verify_link: link_to(t("polls.show.verify_link"), verification_path))) %>
</div>
<% elsif @poll.expired? %>
<div class="callout alert">
<%= t("polls.show.cant_answer_expired") %>
</div>
<% else %>
<div class="callout warning">
<%= t("polls.show.cant_answer_wrong_geozone") %>
</div>
<% end %>
<% end %>

View File

@@ -1 +0,0 @@
$("#<%= dom_id(@question) %>_options").html("<%= j render Polls::Questions::OptionsComponent.new(@question) %>");

View File

@@ -16,29 +16,15 @@
<div class="row margin"> <div class="row margin">
<div class="small-12 medium-9 column"> <div class="small-12 medium-9 column">
<%= render "callout" %> <%= render Polls::CalloutComponent.new(@poll) %>
<%= render Polls::FormComponent.new(@web_vote) %>
<% if @poll.voted_in_booth?(current_user) %>
<div class="callout warning">
<%= t("polls.show.already_voted_in_booth") %>
</div>
<% else %>
<% if current_user && @poll.voted_in_web?(current_user) && !@poll.expired? %>
<div class="callout warning">
<%= t("polls.show.already_voted_in_web") %>
</div>
<% end %>
<% end %>
<%= render Polls::Questions::QuestionComponent.with_collection(@questions) %>
</div> </div>
</div> </div>
<div class="expanded poll-more-info"> <div class="expanded poll-more-info">
<div class="row margin"> <div class="row margin">
<div class="small-12 medium-9 column"> <div class="small-12 medium-9 column">
<h3><%= t("polls.show.more_info_title") %></h3> <h2><%= t("polls.show.more_info_title") %></h2>
<%= auto_link_already_sanitized_html simple_format(@poll.description) %> <%= auto_link_already_sanitized_html simple_format(@poll.description) %>
</div> </div>
</div> </div>
@@ -46,7 +32,7 @@
<div id="poll_more_info_options" class="expanded poll-more-info-options"> <div id="poll_more_info_options" class="expanded poll-more-info-options">
<div class="row padding"> <div class="row padding">
<%= render Polls::Questions::ReadMoreComponent.with_collection(@questions) %> <%= render Polls::Questions::ReadMoreComponent.with_collection(@web_vote.questions) %>
</div> </div>
</div> </div>

View File

@@ -577,6 +577,9 @@ en:
polls: polls:
dates: "From %{open_at} to %{closed_at}" dates: "From %{open_at} to %{closed_at}"
final_date: "Final recounts/Results" final_date: "Final recounts/Results"
form:
vote: "Vote"
maximum_exceeded: "you've selected %{given} answers, but the maximum you can select is %{maximum}"
index: index:
filters: filters:
current: "Open" current: "Open"
@@ -602,6 +605,7 @@ en:
show: show:
already_voted_in_booth: "You have already participated in a physical booth. You can not participate again." already_voted_in_booth: "You have already participated in a physical booth. You can not participate again."
already_voted_in_web: "You have already participated in this poll. If you vote again it will be overwritten." already_voted_in_web: "You have already participated in this poll. If you vote again it will be overwritten."
already_voted_blank_in_web: "You have already participated in this poll by casting a blank vote. If you vote again it will be overwritten."
back: Back to voting back: Back to voting
cant_answer_not_logged_in: "You must %{signin} or %{signup} to participate." cant_answer_not_logged_in: "You must %{signin} or %{signup} to participate."
comments_tab: Comments comments_tab: Comments
@@ -634,11 +638,7 @@ en:
poll_header: poll_header:
back_to_proposal: Back to proposal back_to_proposal: Back to proposal
poll_questions: poll_questions:
show:
vote_answer: "Vote %{answer}"
voted: "You have voted %{answer}"
description: description:
unique: "You can select a maximum of 1 answer."
multiple: "You can select a maximum of %{maximum} answers." multiple: "You can select a maximum of %{maximum} answers."
read_more_about: "Read more about:" read_more_about: "Read more about:"
proposal_notifications: proposal_notifications:

View File

@@ -10,6 +10,8 @@ en:
poll_question_option: "Answer created successfully" poll_question_option: "Answer created successfully"
poll_question_option_video: "Video created successfully" poll_question_option_video: "Video created successfully"
poll_question_option_image: "Image uploaded successfully" poll_question_option_image: "Image uploaded successfully"
poll_voter: "Thank you for voting!"
poll_voter_blank: "Thank you for voting! Your vote has been registered as a blank vote."
proposal: "Proposal created successfully." proposal: "Proposal created successfully."
proposal_notification: "Your message has been sent correctly." proposal_notification: "Your message has been sent correctly."
budget_investment: "Budget Investment created successfully." budget_investment: "Budget Investment created successfully."

View File

@@ -577,6 +577,9 @@ es:
polls: polls:
dates: "Desde el %{open_at} hasta el %{closed_at}" dates: "Desde el %{open_at} hasta el %{closed_at}"
final_date: "Recuento final/Resultados" final_date: "Recuento final/Resultados"
form:
vote: "Votar"
maximum_exceeded: "has seleccionado %{given} respuestas, pero el máximo que puedes seleccionar es %{maximum}"
index: index:
filters: filters:
current: "Abiertas" current: "Abiertas"
@@ -602,6 +605,7 @@ es:
show: show:
already_voted_in_booth: "Ya has participado en esta votación en urnas presenciales, no puedes volver a participar." already_voted_in_booth: "Ya has participado en esta votación en urnas presenciales, no puedes volver a participar."
already_voted_in_web: "Ya has participado en esta votación. Si vuelves a votar se sobreescribirá tu resultado anterior." already_voted_in_web: "Ya has participado en esta votación. Si vuelves a votar se sobreescribirá tu resultado anterior."
already_voted_blank_in_web: "Ya has participado en esta votación mediante un voto en blanco. Si vuelves a votar se sobreescribirá tu resultado anterior."
back: Volver a votaciones back: Volver a votaciones
cant_answer_not_logged_in: "Necesitas %{signin} o %{signup} para participar." cant_answer_not_logged_in: "Necesitas %{signin} o %{signup} para participar."
comments_tab: Comentarios comments_tab: Comentarios
@@ -634,11 +638,7 @@ es:
poll_header: poll_header:
back_to_proposal: Volver a la propuesta back_to_proposal: Volver a la propuesta
poll_questions: poll_questions:
show:
vote_answer: "Votar %{answer}"
voted: "Has votado %{answer}"
description: description:
unique: "Puedes seleccionar un máximo de 1 respuesta."
multiple: "Puedes seleccionar un máximo de %{maximum} respuestas." multiple: "Puedes seleccionar un máximo de %{maximum} respuestas."
read_more_about: "Leer más:" read_more_about: "Leer más:"
proposal_notifications: proposal_notifications:

View File

@@ -10,6 +10,8 @@ es:
poll_question_option: "Respuesta creada correctamente" poll_question_option: "Respuesta creada correctamente"
poll_question_option_video: "Vídeo creado correctamente" poll_question_option_video: "Vídeo creado correctamente"
poll_question_option_image: "Imagen cargada correctamente" poll_question_option_image: "Imagen cargada correctamente"
poll_voter: "¡Gracias por votar!"
poll_voter_blank: "¡Gracias por votar! Tu voto se ha contabilizado como en blanco."
proposal: "Propuesta creada correctamente." proposal: "Propuesta creada correctamente."
proposal_notification: "Tu mensaje ha sido enviado correctamente." proposal_notification: "Tu mensaje ha sido enviado correctamente."
budget_investment: "Proyecto de gasto creado correctamente." budget_investment: "Proyecto de gasto creado correctamente."

View File

@@ -2,9 +2,6 @@ resources :polls, only: [:show, :index] do
member do member do
get :stats get :stats
get :results get :results
end post :answer
resources :questions, controller: "polls/questions", shallow: true, only: [] do
resources :answers, controller: "polls/answers", only: [:create, :destroy], shallow: false
end end
end end

View File

@@ -0,0 +1,27 @@
require "rails_helper"
describe Polls::CalloutComponent do
it "asks anonymous users to sign in" do
render_inline Polls::CalloutComponent.new(create(:poll))
expect(page).to have_content "You must sign in or sign up to participate"
end
it "shows a message to level 2 users when a poll has finished" do
sign_in(create(:user, :level_two))
render_inline Polls::CalloutComponent.new(create(:poll, :expired))
expect(page).to have_content "This poll has finished"
end
it "asks unverified users to verify their account" do
sign_in(create(:user, :incomplete_verification))
render_inline Polls::CalloutComponent.new(create(:poll))
expect(page).to have_content "You must verify your account in order to answer"
expect(page).not_to have_content "You have already participated in this poll. " \
"If you vote again it will be overwritten"
end
end

View File

@@ -0,0 +1,88 @@
require "rails_helper"
describe Polls::FormComponent do
let(:user) { create(:user, :level_two) }
let(:poll) { create(:poll) }
let(:web_vote) { Poll::WebVote.new(poll, user) }
before { create(:poll_question, :yes_no, poll: poll) }
it "renders disabled fields when the user has already voted in a booth" do
create(:poll_voter, :from_booth, poll: poll, user: user)
sign_in(user)
render_inline Polls::FormComponent.new(web_vote)
page.find("fieldset[disabled]") do |fieldset|
expect(fieldset).to have_field "Yes"
expect(fieldset).to have_field "No"
end
expect(page).to have_button "Vote", disabled: true
end
it "renders disabled answers to unverified users" do
sign_in(create(:user, :incomplete_verification))
render_inline Polls::FormComponent.new(web_vote)
page.find("fieldset[disabled]") do |fieldset|
expect(fieldset).to have_field "Yes"
expect(fieldset).to have_field "No"
end
expect(page).to have_button "Vote", disabled: true
end
context "expired poll" do
let(:poll) { create(:poll, :expired) }
it "renders disabled fields when the poll has expired" do
sign_in(user)
render_inline Polls::FormComponent.new(web_vote)
page.find("fieldset[disabled]") do |fieldset|
expect(fieldset).to have_field "Yes"
expect(fieldset).to have_field "No"
end
expect(page).to have_button "Vote", disabled: true
end
end
context "geozone restricted poll" do
let(:poll) { create(:poll, geozone_restricted: true) }
let(:geozone) { create(:geozone) }
before { poll.geozones << geozone }
context "user from another geozone" do
let(:user) { create(:user, :level_two) }
before { sign_in(user) }
it "renders disabled fields" do
render_inline Polls::FormComponent.new(web_vote)
page.find("fieldset[disabled]") do |fieldset|
expect(fieldset).to have_field "Yes"
expect(fieldset).to have_field "No"
end
expect(page).to have_button "Vote", disabled: true
end
end
context "user from the same geozone" do
let(:user) { create(:user, :level_two, geozone: geozone) }
before { sign_in(user) }
it "renders enabled answers" do
render_inline Polls::FormComponent.new(web_vote)
expect(page).not_to have_css "fieldset[disabled]"
expect(page).to have_field "Yes"
expect(page).to have_field "No"
expect(page).to have_button "Vote"
end
end
end
end

View File

@@ -1,112 +0,0 @@
require "rails_helper"
describe Polls::Questions::OptionsComponent do
include Rails.application.routes.url_helpers
let(:poll) { create(:poll) }
let(:question) { create(:poll_question, :yes_no, poll: poll) }
it "renders answers in given order" do
render_inline Polls::Questions::OptionsComponent.new(question)
expect("Yes").to appear_before("No")
end
it "renders buttons to vote question answers" do
sign_in(create(:user, :verified))
render_inline Polls::Questions::OptionsComponent.new(question)
expect(page).to have_button "Yes"
expect(page).to have_button "No"
expect(page).to have_css "button[aria-pressed='false']", count: 2
end
it "renders button to destroy current user answers" do
user = create(:user, :verified)
create(:poll_answer, author: user, question: question, answer: "Yes")
sign_in(user)
render_inline Polls::Questions::OptionsComponent.new(question)
expect(page).to have_button "You have voted Yes"
expect(page).to have_button "Vote No"
expect(page).to have_css "button[aria-pressed='true']", text: "Yes"
end
it "renders disabled buttons when max votes is reached" do
user = create(:user, :verified)
question = create(:poll_question_multiple, :abc, max_votes: 2, author: user)
create(:poll_answer, author: user, question: question, answer: "Answer A")
create(:poll_answer, author: user, question: question, answer: "Answer C")
sign_in(user)
render_inline Polls::Questions::OptionsComponent.new(question)
expect(page).to have_button "You have voted Answer A"
expect(page).to have_button "Vote Answer B", disabled: true
expect(page).to have_button "You have voted Answer C"
end
it "when user is not signed in, renders answers links pointing to user sign in path" do
render_inline Polls::Questions::OptionsComponent.new(question)
expect(page).to have_link "Yes", href: new_user_session_path
expect(page).to have_link "No", href: new_user_session_path
end
it "when user is not verified, renders answers links pointing to user verification in path" do
sign_in(create(:user))
render_inline Polls::Questions::OptionsComponent.new(question)
expect(page).to have_link "Yes", href: verification_path
expect(page).to have_link "No", href: verification_path
end
it "when user already voted in booth it renders disabled answers" do
user = create(:user, :level_two)
create(:poll_voter, :from_booth, poll: poll, user: user)
sign_in(user)
render_inline Polls::Questions::OptionsComponent.new(question)
expect(page).to have_css "span.disabled", text: "Yes"
expect(page).to have_css "span.disabled", text: "No"
end
it "user cannot vote when poll expired it renders disabled answers" do
question = create(:poll_question, :yes_no, poll: create(:poll, :expired))
sign_in(create(:user, :level_two))
render_inline Polls::Questions::OptionsComponent.new(question)
expect(page).to have_css "span.disabled", text: "Yes"
expect(page).to have_css "span.disabled", text: "No"
end
describe "geozone" do
let(:poll) { create(:poll, geozone_restricted: true) }
let(:geozone) { create(:geozone) }
let(:question) { create(:poll_question, :yes_no, poll: poll) }
it "when geozone which is not theirs it renders disabled answers" do
poll.geozones << geozone
sign_in(create(:user, :level_two))
render_inline Polls::Questions::OptionsComponent.new(question)
expect(page).to have_css "span.disabled", text: "Yes"
expect(page).to have_css "span.disabled", text: "No"
end
it "reading a same-geozone poll it renders buttons to vote question answers" do
poll.geozones << geozone
sign_in(create(:user, :level_two, geozone: geozone))
render_inline Polls::Questions::OptionsComponent.new(question)
expect(page).to have_button "Yes"
expect(page).to have_button "No"
end
end
end

View File

@@ -1,18 +1,73 @@
require "rails_helper" require "rails_helper"
describe Polls::Questions::QuestionComponent do describe Polls::Questions::QuestionComponent do
let(:poll) { create(:poll) }
let(:question) { create(:poll_question, :yes_no, poll: poll) }
let(:option_yes) { question.question_options.find_by(title: "Yes") }
let(:option_no) { question.question_options.find_by(title: "No") }
let(:user) { User.new }
let(:web_vote) { Poll::WebVote.new(poll, user) }
let(:form) { ConsulFormBuilder.new(:web_vote, web_vote, ApplicationController.new.view_context, {}) }
it "renders more information links when any question option has additional information" do it "renders more information links when any question option has additional information" do
question = create(:poll_question)
option_a = create(:poll_question_option, question: question, title: "Answer A")
option_b = create(:poll_question_option, question: question, title: "Answer B")
allow_any_instance_of(Poll::Question::Option).to receive(:with_read_more?).and_return(true) allow_any_instance_of(Poll::Question::Option).to receive(:with_read_more?).and_return(true)
render_inline Polls::Questions::QuestionComponent.new(question: question) render_inline Polls::Questions::QuestionComponent.new(question, form: form)
poll_question = page.find("#poll_question_#{question.id}") page.find("#poll_question_#{question.id}") do |poll_question|
expect(poll_question).to have_content("Read more about") expect(poll_question).to have_content "Read more about"
expect(poll_question).to have_link("Answer A", href: "#option_#{option_a.id}") expect(poll_question).to have_link "Yes", href: "#option_#{option_yes.id}"
expect(poll_question).to have_link("Answer B", href: "#option_#{option_b.id}") expect(poll_question).to have_link "No", href: "#option_#{option_no.id}"
expect(poll_question).to have_content("Answer A, Answer B") expect(poll_question).to have_content "Yes, No"
end
end
it "renders answers in given order" do
render_inline Polls::Questions::QuestionComponent.new(question, form: form)
expect("Yes").to appear_before("No")
end
it "renders disabled answers when given the disabled parameter" do
render_inline Polls::Questions::QuestionComponent.new(question, form: form, disabled: true)
page.find("fieldset[disabled]") do |fieldset|
expect(fieldset).to have_field "Yes"
expect(fieldset).to have_field "No"
end
end
context "Verified user" do
let(:user) { create(:user, :level_two) }
before { sign_in(user) }
it "renders radio buttons for single-choice questions" do
render_inline Polls::Questions::QuestionComponent.new(question, form: form)
expect(page).to have_field "Yes", type: :radio
expect(page).to have_field "No", type: :radio
expect(page).to have_field type: :radio, checked: false, count: 2
end
it "renders checkboxes for multiple-choice questions" do
question = create(:poll_question_multiple, :abc, poll: poll)
render_inline Polls::Questions::QuestionComponent.new(question, form: form)
expect(page).to have_field "Answer A", type: :checkbox
expect(page).to have_field "Answer B", type: :checkbox
expect(page).to have_field "Answer C", type: :checkbox
expect(page).to have_field type: :checkbox, checked: false, count: 3
expect(page).not_to have_field type: :checkbox, checked: true
end
it "selects the option when users have already voted" do
create(:poll_answer, author: user, question: question, option: option_yes)
render_inline Polls::Questions::QuestionComponent.new(question, form: form)
expect(page).to have_field "Yes", type: :radio, checked: true
expect(page).to have_field "No", type: :radio, checked: false
end
end end
end end

View File

@@ -1,23 +0,0 @@
require "rails_helper"
describe Polls::AnswersController do
describe "POST create" do
it "doesn't create duplicate records on simultaneous requests", :race_condition do
question = create(:poll_question_multiple, :abc)
sign_in(create(:user, :level_two))
2.times.map do
Thread.new do
post :create, params: {
question_id: question.id,
option_id: question.question_options.find_by(title: "Answer A").id,
format: :js
}
rescue ActiveRecord::RecordInvalid
end
end.each(&:join)
expect(Poll::Answer.count).to eq 1
end
end
end

View File

@@ -8,4 +8,25 @@ describe PollsController do
expect { get :index }.to raise_exception(FeatureFlags::FeatureDisabled) expect { get :index }.to raise_exception(FeatureFlags::FeatureDisabled)
end end
end end
describe "POST answer" do
it "doesn't create duplicate records on simultaneous requests", :race_condition do
question = create(:poll_question_multiple, :abc)
sign_in(create(:user, :level_two))
2.times.map do
Thread.new do
post :answer, params: {
id: question.poll.id,
web_vote: {
question.id.to_s => { option_id: question.question_options.find_by(title: "Answer A").id }
}
}
rescue AbstractController::DoubleRenderError
end
end.each(&:join)
expect(Poll::Answer.count).to eq 1
end
end
end end

View File

@@ -212,8 +212,14 @@ FactoryBot.define do
factory :poll_answer, class: "Poll::Answer" do factory :poll_answer, class: "Poll::Answer" do
question factory: [:poll_question, :yes_no] question factory: [:poll_question, :yes_no]
author factory: [:user, :level_two] author factory: [:user, :level_two]
answer { question.question_options.sample.title } option do
option { question.question_options.find_by(title: answer) } if answer
question.question_options.find_by(title: answer)
else
question.question_options.sample
end
end
after(:build) { |poll_answer| poll_answer.answer ||= poll_answer.option&.title }
end end
factory :poll_partial_result, class: "Poll::PartialResult" do factory :poll_partial_result, class: "Poll::PartialResult" do

View File

@@ -51,18 +51,6 @@ describe Abilities::Common do
let(:poll_from_own_geozone) { create(:poll, geozone_restricted_to: [geozone]) } let(:poll_from_own_geozone) { create(:poll, geozone_restricted_to: [geozone]) }
let(:poll_from_other_geozone) { create(:poll, geozone_restricted_to: [create(:geozone)]) } let(:poll_from_other_geozone) { create(:poll, geozone_restricted_to: [create(:geozone)]) }
let(:poll_question_from_own_geozone) { create(:poll_question, poll: poll_from_own_geozone) }
let(:poll_question_from_other_geozone) { create(:poll_question, poll: poll_from_other_geozone) }
let(:poll_question_from_all_geozones) { create(:poll_question, poll: poll) }
let(:expired_poll_question_from_own_geozone) do
create(:poll_question, poll: expired_poll_from_own_geozone)
end
let(:expired_poll_question_from_other_geozone) do
create(:poll_question, poll: expired_poll_from_other_geozone)
end
let(:expired_poll_question_from_all_geozones) { create(:poll_question, poll: expired_poll) }
let(:own_proposal_document) { build(:document, documentable: own_proposal) } let(:own_proposal_document) { build(:document, documentable: own_proposal) }
let(:proposal_document) { build(:document, documentable: proposal) } let(:proposal_document) { build(:document, documentable: proposal) }
let(:own_budget_investment_document) { build(:document, documentable: own_investment_in_accepting_budget) } let(:own_budget_investment_document) { build(:document, documentable: own_investment_in_accepting_budget) }
@@ -255,36 +243,22 @@ describe Abilities::Common do
it { should be_able_to(:answer, current_poll) } it { should be_able_to(:answer, current_poll) }
it { should_not be_able_to(:answer, expired_poll) } it { should_not be_able_to(:answer, expired_poll) }
it { should be_able_to(:answer, poll_question_from_own_geozone) } it { should be_able_to(:answer, poll_from_own_geozone) }
it { should be_able_to(:answer, poll_question_from_all_geozones) } it { should be_able_to(:answer, poll) }
it { should_not be_able_to(:answer, poll_question_from_other_geozone) } it { should_not be_able_to(:answer, poll_from_other_geozone) }
it { should_not be_able_to(:answer, expired_poll_question_from_own_geozone) } it { should_not be_able_to(:answer, expired_poll_from_own_geozone) }
it { should_not be_able_to(:answer, expired_poll_question_from_all_geozones) } it { should_not be_able_to(:answer, expired_poll_from_other_geozone) }
it { should_not be_able_to(:answer, expired_poll_question_from_other_geozone) }
context "Poll::Answer" do
let(:own_answer) { create(:poll_answer, author: user) }
let(:other_user_answer) { create(:poll_answer) }
let(:expired_poll) { create(:poll, :expired) }
let(:question) { create(:poll_question, :yes_no, poll: expired_poll) }
let(:expired_poll_answer) { create(:poll_answer, author: user, question: question, answer: "Yes") }
it { should be_able_to(:destroy, own_answer) }
it { should_not be_able_to(:destroy, other_user_answer) }
it { should_not be_able_to(:destroy, expired_poll_answer) }
end
context "without geozone" do context "without geozone" do
before { user.geozone = nil } before { user.geozone = nil }
it { should_not be_able_to(:answer, poll_question_from_own_geozone) } it { should_not be_able_to(:answer, poll_from_own_geozone) }
it { should be_able_to(:answer, poll_question_from_all_geozones) } it { should be_able_to(:answer, poll) }
it { should_not be_able_to(:answer, poll_question_from_other_geozone) } it { should_not be_able_to(:answer, poll_from_other_geozone) }
it { should_not be_able_to(:answer, expired_poll_question_from_own_geozone) } it { should_not be_able_to(:answer, expired_poll_from_own_geozone) }
it { should_not be_able_to(:answer, expired_poll_question_from_all_geozones) } it { should_not be_able_to(:answer, expired_poll_from_other_geozone) }
it { should_not be_able_to(:answer, expired_poll_question_from_other_geozone) }
end end
end end
@@ -342,23 +316,22 @@ describe Abilities::Common do
it { should be_able_to(:answer, current_poll) } it { should be_able_to(:answer, current_poll) }
it { should_not be_able_to(:answer, expired_poll) } it { should_not be_able_to(:answer, expired_poll) }
it { should be_able_to(:answer, poll_question_from_own_geozone) } it { should be_able_to(:answer, poll_from_own_geozone) }
it { should be_able_to(:answer, poll_question_from_all_geozones) } it { should be_able_to(:answer, poll) }
it { should_not be_able_to(:answer, poll_question_from_other_geozone) } it { should_not be_able_to(:answer, poll_from_other_geozone) }
it { should_not be_able_to(:answer, expired_poll_question_from_own_geozone) } it { should_not be_able_to(:answer, expired_poll_from_own_geozone) }
it { should_not be_able_to(:answer, expired_poll_question_from_all_geozones) } it { should_not be_able_to(:answer, expired_poll_from_other_geozone) }
it { should_not be_able_to(:answer, expired_poll_question_from_other_geozone) }
context "without geozone" do context "without geozone" do
before { user.geozone = nil } before { user.geozone = nil }
it { should_not be_able_to(:answer, poll_question_from_own_geozone) } it { should_not be_able_to(:answer, poll_from_own_geozone) }
it { should be_able_to(:answer, poll_question_from_all_geozones) } it { should be_able_to(:answer, poll) }
it { should_not be_able_to(:answer, poll_question_from_other_geozone) } it { should_not be_able_to(:answer, poll_from_other_geozone) }
it { should_not be_able_to(:answer, expired_poll_question_from_own_geozone) } it { should_not be_able_to(:answer, expired_poll_from_own_geozone) }
it { should_not be_able_to(:answer, expired_poll_question_from_all_geozones) } it { should_not be_able_to(:answer, expired_poll) }
it { should_not be_able_to(:answer, expired_poll_question_from_other_geozone) } it { should_not be_able_to(:answer, expired_poll_from_other_geozone) }
end end
end end

View File

@@ -101,136 +101,5 @@ describe Poll::Answer do
expect(build(:poll_answer, question: question, answer: "Four")).not_to be_valid expect(build(:poll_answer, question: question, answer: "Four")).not_to be_valid
end end
context "creating answers at the same time", :race_condition do
it "validates max votes on single-answer questions" do
author = create(:user)
question = create(:poll_question, :yes_no)
answer = build(:poll_answer, author: author, question: question, answer: "Yes")
other_answer = build(:poll_answer, author: author, question: question, answer: "No")
[answer, other_answer].map do |poll_answer|
Thread.new { poll_answer.save }
end.each(&:join)
expect(Poll::Answer.count).to be 1
end
it "validates max votes on multiple-answer questions" do
author = create(:user, :level_two)
question = create(:poll_question_multiple, :abc, max_votes: 2)
create(:poll_answer, question: question, answer: "Answer A", author: author)
answer = build(:poll_answer, question: question, answer: "Answer B", author: author)
other_answer = build(:poll_answer, question: question, answer: "Answer C", author: author)
[answer, other_answer].map do |poll_answer|
Thread.new { poll_answer.save }
end.each(&:join)
expect(Poll::Answer.count).to be 2
end
end
end
describe "#save_and_record_voter_participation" do
let(:author) { create(:user, :level_two) }
let(:poll) { create(:poll) }
let(:question) { create(:poll_question, :yes_no, poll: poll) }
it "creates a poll_voter with user and poll data" do
answer = create(:poll_answer, question: question, author: author, answer: "Yes")
expect(answer.poll.voters).to be_blank
answer.save_and_record_voter_participation
expect(poll.reload.voters.size).to eq(1)
voter = poll.voters.first
expect(voter.document_number).to eq(answer.author.document_number)
expect(voter.poll_id).to eq(answer.poll.id)
expect(voter.officer_id).to be nil
end
it "updates a poll_voter with user and poll data" do
answer = create(:poll_answer, question: question, author: author, answer: "Yes")
answer.save_and_record_voter_participation
expect(poll.reload.voters.size).to eq(1)
updated_answer = answer.question.find_or_initialize_user_answer(
answer.author,
answer.question.question_options.excluding(answer.option).sample.id
)
updated_answer.save_and_record_voter_participation
expect(poll.reload.voters.size).to eq(1)
voter = poll.voters.first
expect(voter.document_number).to eq(updated_answer.author.document_number)
expect(voter.poll_id).to eq(updated_answer.poll.id)
end
it "does not save the answer if the voter is invalid" do
allow_any_instance_of(Poll::Voter).to receive(:valid?).and_return(false)
answer = build(:poll_answer)
expect do
answer.save_and_record_voter_participation
end.to raise_error(ActiveRecord::RecordInvalid)
expect(answer).not_to be_persisted
end
it "does not create two voters when creating two answers at the same time", :race_condition do
answer = build(:poll_answer, question: question, author: author, answer: "Yes")
other_answer = build(:poll_answer, question: question, author: author, answer: "No")
[answer, other_answer].map do |poll_answer|
Thread.new do
poll_answer.save_and_record_voter_participation
rescue ActiveRecord::RecordInvalid
end
end.each(&:join)
expect(Poll::Voter.count).to be 1
end
it "does not create two voters when calling the method twice at the same time", :race_condition do
answer = create(:poll_answer, question: question, author: author, answer: "Yes")
2.times.map do
Thread.new { answer.save_and_record_voter_participation }
end.each(&:join)
expect(Poll::Voter.count).to be 1
end
end
describe "#destroy_and_remove_voter_participation" do
let(:poll) { create(:poll) }
let(:question) { create(:poll_question, :yes_no, poll: poll) }
it "destroys voter record and answer when it was the only user's answer" do
answer = build(:poll_answer, question: question)
answer.save_and_record_voter_participation
expect { answer.destroy_and_remove_voter_participation }
.to change { Poll::Answer.count }.by(-1)
.and change { Poll::Voter.count }.by(-1)
end
it "destroys the answer but does not destroy the voter record when the user
has answered other poll questions" do
answer = build(:poll_answer, question: question)
answer.save_and_record_voter_participation
other_question = create(:poll_question, :yes_no, poll: poll)
other_answer = build(:poll_answer, question: other_question, author: answer.author)
other_answer.save_and_record_voter_participation
expect(other_answer).to be_persisted
expect { answer.destroy_and_remove_voter_participation }
.to change { Poll::Answer.count }.by(-1)
.and change { Poll::Voter.count }.by(0)
end
end end
end end

View File

@@ -14,8 +14,6 @@ describe Poll::Stats do
end end
describe "total participants" do describe "total participants" do
before { allow(stats).to receive(:total_web_white).and_return(1) }
it "supports every channel" do it "supports every channel" do
3.times { create(:poll_voter, :from_web, poll: poll) } 3.times { create(:poll_voter, :from_web, poll: poll) }
create(:poll_recount, :from_booth, poll: poll, create(:poll_recount, :from_booth, poll: poll,
@@ -49,15 +47,29 @@ describe Poll::Stats do
end end
describe "#total_web_valid" do describe "#total_web_valid" do
before { allow(stats).to receive(:total_web_white).and_return(1) } it "returns only votes containing answers" do
question = create(:poll_question, :yes_no, poll: poll)
it "returns only valid votes" do 2.times do
3.times { create(:poll_voter, :from_web, poll: poll) } voter = create(:poll_voter, :from_web, poll: poll)
create(:poll_answer, author: voter.user, question: question)
end
create(:poll_voter, :from_web, poll: poll)
expect(stats.total_web_valid).to eq(2) expect(stats.total_web_valid).to eq(2)
end end
end end
describe "#total_web_white" do
it "returns voters with no answers" do
question = create(:poll_question, :yes_no, poll: poll)
3.times { create(:poll_voter, :from_web, poll: poll) }
create(:poll_answer, author: poll.voters.last.user, question: question)
expect(stats.total_web_white).to eq(2)
end
end
describe "#total_web_null" do describe "#total_web_null" do
it "returns 0" do it "returns 0" do
expect(stats.total_web_null).to eq(0) expect(stats.total_web_null).to eq(0)
@@ -93,8 +105,8 @@ describe Poll::Stats do
describe "valid percentage by channel" do describe "valid percentage by channel" do
it "is relative to the total amount of valid votes" do it "is relative to the total amount of valid votes" do
allow(stats).to receive(:total_web_valid).and_return(1)
create(:poll_recount, :from_booth, poll: poll, total_amount: 2) create(:poll_recount, :from_booth, poll: poll, total_amount: 2)
create(:poll_voter, :from_web, poll: poll)
expect(stats.valid_percentage_web).to eq(33.333) expect(stats.valid_percentage_web).to eq(33.333)
expect(stats.valid_percentage_booth).to eq(66.667) expect(stats.valid_percentage_booth).to eq(66.667)
@@ -123,7 +135,7 @@ describe Poll::Stats do
describe "#total_valid_votes" do describe "#total_valid_votes" do
it "counts valid votes from every channel" do it "counts valid votes from every channel" do
2.times { create(:poll_voter, :from_web, poll: poll) } allow(stats).to receive(:total_web_valid).and_return(2)
create(:poll_recount, :from_booth, poll: poll, total_amount: 3, white_amount: 10) create(:poll_recount, :from_booth, poll: poll, total_amount: 3, white_amount: 10)
create(:poll_recount, :from_booth, poll: poll, total_amount: 4, null_amount: 20) create(:poll_recount, :from_booth, poll: poll, total_amount: 4, null_amount: 20)
@@ -150,10 +162,9 @@ describe Poll::Stats do
end end
describe "total percentage by type" do describe "total percentage by type" do
before { allow(stats).to receive(:total_web_white).and_return(1) } before { allow(stats).to receive_messages(total_web_white: 1, total_web_valid: 2) }
it "is relative to the total amount of votes" do it "is relative to the total amount of votes" do
3.times { create(:poll_voter, :from_web, poll: poll) }
create(:poll_recount, :from_booth, poll: poll, create(:poll_recount, :from_booth, poll: poll,
total_amount: 8, total_amount: 8,
white_amount: 5, white_amount: 5,

View File

@@ -0,0 +1,111 @@
require "rails_helper"
describe Poll::WebVote do
describe "#update" do
let(:user) { create(:user, :level_two) }
let(:poll) { create(:poll) }
let!(:question) { create(:poll_question, :yes_no, poll: poll) }
let(:option_yes) { question.question_options.find_by(title: "Yes") }
let(:option_no) { question.question_options.find_by(title: "No") }
let(:web_vote) { Poll::WebVote.new(poll, user) }
it "creates a poll_voter with user and poll data" do
expect(poll.voters).to be_blank
expect(question.answers).to be_blank
web_vote.update(question.id.to_s => { option_id: option_yes.id.to_s })
expect(poll.reload.voters.size).to eq 1
expect(question.reload.answers.size).to eq 1
voter = poll.voters.first
answer = question.answers.first
expect(answer.author).to eq user
expect(voter.document_number).to eq user.document_number
expect(voter.poll_id).to eq answer.poll.id
expect(voter.officer_id).to be nil
end
it "updates a poll_voter with user and poll data" do
answer = create(:poll_answer, question: question, author: user, option: option_yes)
web_vote.update(question.id.to_s => { option_id: option_no.id.to_s })
expect(poll.reload.voters.size).to eq 1
expect(question.reload.answers.size).to eq 1
expect(question.answers.first).to eq answer.reload
voter = poll.voters.first
expect(answer.author).to eq user
expect(answer.option).to eq option_no
expect(voter.document_number).to eq answer.author.document_number
expect(voter.poll_id).to eq answer.poll.id
end
it "does not save the answer if the voter is invalid" do
allow_any_instance_of(Poll::Voter).to receive(:valid?).and_return(false)
expect do
web_vote.update(question.id.to_s => { option_id: option_yes.id.to_s })
end.to raise_error(ActiveRecord::RecordInvalid)
expect(poll.voters).to be_blank
expect(question.answers).to be_blank
end
it "creates a voter but does not create answers when leaving everything blank" do
web_vote.update({})
expect(poll.reload.voters.size).to eq 1
expect(question.reload.answers.size).to eq 0
end
it "deletes existing answers but keeps voters when no answers are given" do
create(:poll_answer, question: question, author: user, option: option_yes)
create(:poll_voter, poll: poll, user: user)
web_vote.update({})
expect(poll.reload.voters.size).to eq 1
expect(poll.voters.first.user).to eq user
expect(question.reload.answers.size).to eq 0
end
context "creating answers at the same time", :race_condition do
it "does not create two voters or two answers for two different answers" do
[option_yes, option_no].map do |option|
Thread.new { web_vote.update(question.id.to_s => { option_id: option.id.to_s }) }
end.each(&:join)
expect(Poll::Voter.count).to be 1
expect(Poll::Answer.count).to be 1
end
it "does not create two voters for duplicate answers" do
2.times.map do
Thread.new { web_vote.update(question.id.to_s => { option_id: option_yes.id.to_s }) }
end.each(&:join)
expect(Poll::Voter.count).to be 1
end
it "validates max votes on multiple-answer questions" do
question = create(:poll_question_multiple, :abc, poll: poll, max_votes: 2)
option_a = question.question_options.find_by(title: "Answer A")
option_b = question.question_options.find_by(title: "Answer B")
option_c = question.question_options.find_by(title: "Answer C")
create(:poll_answer, question: question, author: user, option: option_a)
[option_b, option_c].map do |option|
Thread.new do
web_vote.update(question.id.to_s => { option_id: [option_a.id.to_s, option.id.to_s] })
end
end.each(&:join)
expect(Poll::Answer.count).to be 2
end
end
end
end

View File

@@ -1,13 +1,14 @@
module Polls module Polls
def vote_for_poll_via_web(poll, question, option) def vote_for_poll_via_web(poll, questions_with_options)
visit poll_path(poll) visit poll_path(poll)
within("#poll_question_#{question.id}_options") do questions_with_options.each do |question, option|
click_button option within_fieldset(question.title) { choose option }
expect(page).to have_button("You have voted #{option}")
expect(page).not_to have_button("Vote #{option}")
end end
click_button "Vote"
expect(page).to have_content "Thank you for voting!"
end end
def vote_for_poll_via_booth def vote_for_poll_via_booth

View File

@@ -96,7 +96,7 @@ describe "Polls" do
expect(page).not_to have_css ".already-answer" expect(page).not_to have_css ".already-answer"
vote_for_poll_via_web(poll_with_question, question, "Yes") vote_for_poll_via_web(poll_with_question, question => "Yes")
visit polls_path visit polls_path
@@ -168,14 +168,6 @@ describe "Polls" do
end end
end end
scenario "Non-logged in users" do
create(:poll_question, :yes_no, poll: poll)
visit poll_path(poll)
expect(page).to have_content("You must sign in or sign up to participate")
end
scenario "Level 1 users" do scenario "Level 1 users" do
poll.update!(geozone_restricted_to: [geozone]) poll.update!(geozone_restricted_to: [geozone])
create(:poll_question, :yes_no, poll: poll) create(:poll_question, :yes_no, poll: poll)
@@ -186,54 +178,85 @@ describe "Polls" do
expect(page).to have_content("You must verify your account in order to answer") expect(page).to have_content("You must verify your account in order to answer")
end end
scenario "Level 2 users in an expired poll" do
expired_poll = create(:poll, :expired)
create(:poll_question, :yes_no, poll: expired_poll)
login_as(create(:user, :level_two, geozone: geozone))
visit poll_path(expired_poll)
expect(page).to have_content("This poll has finished")
end
scenario "Level 2 users answering" do scenario "Level 2 users answering" do
poll.update!(geozone_restricted_to: [geozone]) poll.update!(geozone_restricted_to: [geozone])
create(:poll_question, :yes_no, poll: poll, title: "Do you agree?")
question = create(:poll_question, :yes_no, poll: poll) login_as(create(:user, :level_two, geozone: geozone))
user = create(:user, :level_two, geozone: geozone)
login_as user
visit poll_path(poll) visit poll_path(poll)
within("#poll_question_#{question.id}_options") do within_fieldset("Do you agree?") { choose "Yes" }
click_button "Vote Yes" click_button "Vote"
expect(page).to have_button "You have voted Yes" expect(page).to have_content "Thank you for voting!"
expect(page).to have_button "Vote No" expect(page).to have_content "You have already participated in this poll. " \
"If you vote again it will be overwritten."
within_fieldset("Do you agree?") do
expect(page).to have_field "Yes", type: :radio, checked: true
end end
expect(page).to have_button "Vote"
end end
scenario "Level 2 users changing answer" do scenario "Level 2 users changing answer" do
poll.update!(geozone_restricted_to: [geozone])
question = create(:poll_question, :yes_no, poll: poll)
user = create(:user, :level_two, geozone: geozone) user = create(:user, :level_two, geozone: geozone)
question = create(:poll_question, :yes_no, poll: poll, title: "Do you agree?")
poll.update!(geozone_restricted_to: [geozone])
create(:poll_answer, author: user, question: question, answer: "Yes")
create(:poll_voter, poll: poll, user: user)
login_as user login_as user
visit poll_path(poll) visit poll_path(poll)
within("#poll_question_#{question.id}_options") do expect(page).to have_content "You have already participated in this poll. " \
click_button "Yes" "If you vote again it will be overwritten."
expect(page).to have_button "You have voted Yes" within_fieldset("Do you agree?") do
expect(page).to have_button "Vote No" expect(page).to have_field "Yes", type: :radio, checked: true
click_button "No" choose "No"
expect(page).to have_button "Vote Yes"
expect(page).to have_button "You have voted No"
end end
click_button "Vote"
expect(page).to have_content "Thank you for voting!"
within_fieldset("Do you agree?") do
expect(page).to have_field "No", type: :radio, checked: true
expect(page).to have_field "Yes", type: :radio, checked: false
end
expect(page).to have_button "Vote"
end
scenario "Level 2 users deleting their answer" do
user = create(:user, :level_two, geozone: geozone)
question = create(:poll_question_multiple, :abc, poll: poll, title: "Which ones are better?")
create(:poll_answer, author: user, question: question, answer: "Answer A")
create(:poll_voter, poll: poll, user: user)
login_as user
visit poll_path(poll)
expect(page).to have_content "You have already participated in this poll. " \
"If you vote again it will be overwritten."
within_fieldset("Which ones are better?") { uncheck "Answer A" }
click_button "Vote"
expect(page).to have_content "Thank you for voting! Your vote has been registered as a blank vote."
expect(page).to have_content "You have already participated in this poll by casting a blank vote. " \
"If you vote again it will be overwritten."
within_fieldset("Which ones are better?") do
expect(page).to have_field type: :checkbox, checked: false, count: 3
expect(page).not_to have_field type: :checkbox, checked: true
end
expect(page).to have_button "Vote"
end end
scenario "Shows SDG tags when feature is enabled" do scenario "Shows SDG tags when feature is enabled" do
@@ -259,20 +282,6 @@ describe "Polls" do
expect("Not restricted").to appear_before("Geozone Poll") expect("Not restricted").to appear_before("Geozone Poll")
expect("Geozone Poll").to appear_before("A Poll") expect("Geozone Poll").to appear_before("A Poll")
end end
scenario "Level 2 users answering in a browser without javascript", :no_js do
question = create(:poll_question, :yes_no, poll: poll)
user = create(:user, :level_two)
login_as user
visit poll_path(poll)
within("#poll_question_#{question.id}_options") do
click_button "Yes"
expect(page).to have_button "You have voted Yes"
expect(page).to have_button "No"
end
end
end end
context "Booth & Website", :with_frozen_time do context "Booth & Website", :with_frozen_time do
@@ -283,7 +292,7 @@ describe "Polls" do
scenario "Already voted on booth cannot vote on website" do scenario "Already voted on booth cannot vote on website" do
create(:poll_shift, officer: officer, booth: booth, date: Date.current, task: :vote_collection) create(:poll_shift, officer: officer, booth: booth, date: Date.current, task: :vote_collection)
create(:poll_officer_assignment, officer: officer, poll: poll, booth: booth, date: Date.current) create(:poll_officer_assignment, officer: officer, poll: poll, booth: booth, date: Date.current)
question = create(:poll_question, :yes_no, poll: poll) create(:poll_question, :yes_no, poll: poll, title: "Have you voted using a booth?")
user = create(:user, :level_two, :in_census) user = create(:user, :level_two, :in_census)
login_as(officer.user) login_as(officer.user)
@@ -304,12 +313,9 @@ describe "Polls" do
expect(page).to have_content "You have already participated in a physical booth. " \ expect(page).to have_content "You have already participated in a physical booth. " \
"You can not participate again." "You can not participate again."
within("#poll_question_#{question.id}_options") do within_fieldset("Have you voted using a booth?") do
expect(page).to have_content("Yes") expect(page).to have_field "Yes", type: :radio, disabled: true
expect(page).to have_content("No") expect(page).to have_field "No", type: :radio, disabled: true
expect(page).not_to have_button "Yes"
expect(page).not_to have_button "No"
end end
end end
end end

View File

@@ -17,18 +17,15 @@ describe "Poll Results" do
option5 = create(:poll_question_option, question: question2, title: "Yellow") option5 = create(:poll_question_option, question: question2, title: "Yellow")
login_as user1 login_as user1
vote_for_poll_via_web(poll, question1, "Yes") vote_for_poll_via_web(poll, question1 => "Yes", question2 => "Blue")
vote_for_poll_via_web(poll, question2, "Blue")
logout logout
login_as user2 login_as user2
vote_for_poll_via_web(poll, question1, "Yes") vote_for_poll_via_web(poll, question1 => "Yes", question2 => "Green")
vote_for_poll_via_web(poll, question2, "Green")
logout logout
login_as user3 login_as user3
vote_for_poll_via_web(poll, question1, "No") vote_for_poll_via_web(poll, question1 => "No", question2 => "Yellow")
vote_for_poll_via_web(poll, question2, "Yellow")
logout logout
travel_to(poll.ends_at + 1.day) travel_to(poll.ends_at + 1.day)

View File

@@ -2,68 +2,102 @@ require "rails_helper"
describe "Poll Votation Type" do describe "Poll Votation Type" do
let(:author) { create(:user, :level_two) } let(:author) { create(:user, :level_two) }
let(:poll) { create(:poll) }
before do before do
login_as(author) login_as(author)
end end
scenario "Unique answer" do scenario "Unique and multiple answers" do
question = create(:poll_question_unique, :yes_no) create(:poll_question_unique, :yes_no, poll: poll, title: "Is it that bad?")
create(:poll_question_multiple, :abcde, poll: poll, max_votes: 3, title: "Which ones do you prefer?")
visit poll_path(question.poll) visit poll_path(poll)
expect(page).to have_content "You can select a maximum of 1 answer." within_fieldset("Is it that bad?") { choose "Yes" }
expect(page).to have_content(question.title)
expect(page).to have_button("Vote Yes")
expect(page).to have_button("Vote No")
within "#poll_question_#{question.id}_options" do within_fieldset("Which ones do you prefer?") do
click_button "Yes" check "Answer A"
check "Answer C"
expect(page).to have_button("You have voted Yes")
expect(page).to have_button("Vote No")
click_button "No"
expect(page).to have_button("Vote Yes")
expect(page).to have_button("You have voted No")
end
end end
scenario "Multiple answers" do click_button "Vote"
question = create(:poll_question_multiple, :abc, max_votes: 2)
visit poll_path(question.poll)
expect(page).to have_content "You can select a maximum of 2 answers." expect(page).to have_content "Thank you for voting!"
expect(page).to have_content(question.title) expect(page).to have_content "You have already participated in this poll. " \
expect(page).to have_button("Vote Answer A") "If you vote again it will be overwritten."
expect(page).to have_button("Vote Answer B")
expect(page).to have_button("Vote Answer C")
within "#poll_question_#{question.id}_options" do within_fieldset("Is it that bad?") do
click_button "Vote Answer A" expect(page).to have_field "Yes", type: :radio, checked: true
end
expect(page).to have_button("You have voted Answer A") within_fieldset("Which ones do you prefer?") do
expect(page).to have_field "Answer A", type: :checkbox, checked: true
expect(page).to have_field "Answer B", type: :checkbox, checked: false
expect(page).to have_field "Answer C", type: :checkbox, checked: true
expect(page).to have_field "Answer D", type: :checkbox, checked: false
expect(page).to have_field "Answer E", type: :checkbox, checked: false
end
click_button "Vote Answer C" expect(page).to have_button "Vote"
end
expect(page).to have_button("You have voted Answer C") scenario "Maximum votes has been reached" do
expect(page).to have_button("Vote Answer B", disabled: true) question = create(:poll_question_multiple, :abc, poll: poll, max_votes: 2)
create(:poll_answer, author: author, question: question, answer: "Answer A")
click_button "You have voted Answer A" visit poll_path(poll)
expect(page).to have_button("Vote Answer A") expect(page).to have_field "Answer A", type: :checkbox, checked: true
expect(page).to have_button("Vote Answer B") expect(page).to have_field "Answer B", type: :checkbox, checked: false
expect(page).to have_field "Answer C", type: :checkbox, checked: false
click_button "You have voted Answer C" check "Answer C"
expect(page).to have_button("Vote Answer C") expect(page).to have_field "Answer A", type: :checkbox, checked: true
expect(page).to have_field "Answer B", type: :checkbox, checked: false, disabled: true
expect(page).to have_field "Answer C", type: :checkbox, checked: true
click_button "Vote Answer B" click_button "Vote"
expect(page).to have_button("You have voted Answer B") expect(page).to have_content "Thank you for voting!"
expect(page).to have_button("Vote Answer A") expect(page).to have_field "Answer A", type: :checkbox, checked: true
expect(page).to have_button("Vote Answer C") expect(page).to have_field "Answer B", type: :checkbox, checked: false, disabled: true
expect(page).to have_field "Answer C", type: :checkbox, checked: true
uncheck "Answer A"
expect(page).to have_field "Answer A", type: :checkbox, checked: false
expect(page).to have_field "Answer B", type: :checkbox, checked: false
expect(page).to have_field "Answer C", type: :checkbox, checked: true
end
scenario "Too many answers", :no_js do
create(:poll_question_multiple, :abcde, poll: poll, max_votes: 2, title: "Which ones are correct?")
visit poll_path(poll)
check "Answer A"
check "Answer B"
check "Answer D"
click_button "Vote"
within_fieldset("Which ones are correct?") do
expect(page).to have_content "you've selected 3 answers, but the maximum you can select is 2"
expect(page).to have_field "Answer A", type: :checkbox, checked: true
expect(page).to have_field "Answer B", type: :checkbox, checked: true
expect(page).to have_field "Answer C", type: :checkbox, checked: false
expect(page).to have_field "Answer D", type: :checkbox, checked: true
expect(page).to have_field "Answer E", type: :checkbox, checked: false
end
expect(page).not_to have_content "Thank you for voting!"
visit poll_path(poll)
expect(page).not_to have_content "but the maximum you can select"
within_fieldset("Which ones are correct?") do
expect(page).to have_field type: :checkbox, checked: false, count: 5
end end
end end
end end

View File

@@ -3,7 +3,7 @@ require "rails_helper"
describe "Voter" do describe "Voter" do
context "Origin", :with_frozen_time do context "Origin", :with_frozen_time do
let(:poll) { create(:poll) } let(:poll) { create(:poll) }
let!(:question) { create(:poll_question, :yes_no, poll: poll) } let!(:question) { create(:poll_question, :yes_no, poll: poll, title: "Is this question stupid?") }
let(:booth) { create(:poll_booth) } let(:booth) { create(:poll_booth) }
let(:officer) { create(:poll_officer) } let(:officer) { create(:poll_officer) }
let(:admin) { create(:administrator) } let(:admin) { create(:administrator) }
@@ -20,57 +20,12 @@ describe "Voter" do
login_as user login_as user
visit poll_path(poll) visit poll_path(poll)
within("#poll_question_#{question.id}_options") do within_fieldset("Is this question stupid?") { choose "Yes" }
click_button "Vote Yes" click_button "Vote"
expect(page).to have_button("You have voted Yes") expect(page).to have_content "Thank you for voting!"
expect(page).not_to have_button("Vote Yes") expect(page).to have_content "You have already participated in this poll. " \
end "If you vote again it will be overwritten."
refresh
expect(page).to have_content("You have already participated in this poll.")
expect(page).to have_content("If you vote again it will be overwritten")
end
scenario "Remove vote via web - Standard" do
user = create(:user, :level_two)
create(:poll_answer, question: question, author: user, answer: "Yes")
create(:poll_voter, poll: poll, user: user)
login_as user
visit poll_path(poll)
expect(page).to have_content("You have already participated in this poll.")
expect(page).to have_content("If you vote again it will be overwritten")
within("#poll_question_#{question.id}_options") do
click_button "You have voted Yes"
expect(page).to have_button("Vote Yes")
expect(page).to have_button("Vote No")
end
refresh
expect(page).not_to have_content("You have already participated in this poll.")
expect(page).not_to have_content("If you vote again it will be overwritten")
end
scenario "Voting via web as unverified user" do
user = create(:user, :incomplete_verification)
login_as user
visit poll_path(poll)
within("#poll_question_#{question.id}_options") do
expect(page).to have_link("Yes", href: verification_path)
expect(page).to have_link("No", href: verification_path)
end
expect(page).to have_content "You must verify your account in order to answer"
expect(page).not_to have_content "You have already participated in this poll. " \
"If you vote again it will be overwritten"
end end
scenario "Voting in booth" do scenario "Voting in booth" do
@@ -142,7 +97,7 @@ describe "Voter" do
scenario "Trying to vote in web and then in booth" do scenario "Trying to vote in web and then in booth" do
login_as user login_as user
vote_for_poll_via_web(poll, question, "Yes") vote_for_poll_via_web(poll, question => "Yes")
logout logout
login_through_form_as_officer(officer) login_through_form_as_officer(officer)
@@ -165,8 +120,9 @@ describe "Voter" do
login_as user login_as user
visit poll_path(poll) visit poll_path(poll)
within("#poll_question_#{question.id}_options") do within_fieldset "Is this question stupid?" do
expect(page).not_to have_button("Yes") expect(page).to have_field "Yes", type: :radio, disabled: true
expect(page).to have_field "No", type: :radio, disabled: true
end end
expect(page).to have_content "You have already participated in a physical booth. " \ expect(page).to have_content "You have already participated in a physical booth. " \
"You can not participate again." "You can not participate again."
@@ -185,7 +141,7 @@ describe "Voter" do
end end
end end
scenario "Voting in poll and then verifiying account" do scenario "Voting in poll and then verifying account" do
allow_any_instance_of(Verification::Sms).to receive(:generate_confirmation_code).and_return("1357") allow_any_instance_of(Verification::Sms).to receive(:generate_confirmation_code).and_return("1357")
user = create(:user) user = create(:user)
admin_user = admin.user admin_user = admin.user
@@ -203,8 +159,9 @@ describe "Voter" do
visit poll_path(poll) visit poll_path(poll)
within("#poll_question_#{question.id}_options") do within_fieldset "Is this question stupid?" do
expect(page).not_to have_button("Yes") expect(page).to have_field "Yes", type: :radio, disabled: true
expect(page).to have_field "No", type: :radio, disabled: true
end end
expect(page).to have_content "You have already participated in a physical booth. " \ expect(page).to have_content "You have already participated in a physical booth. " \